Skip to main content

mq_bridge/endpoints/
stream_buffer.rs

1//  mq-bridge
2//  © Copyright 2025, by Marco Mengelkoch
3//  Licensed under MIT License, see License file for more details
4//  git clone https://github.com/marcomq/mq-bridge
5
6//! Correlated in-process stream response buffer endpoint.
7//!
8//! `stream_buffer` is a small endpoint for workflows where one publisher send
9//! produces many response messages. The main use case is
10//! `HttpConfig::stream_response_to`: an HTTP publisher sends a request as usual,
11//! reads a streaming HTTP response, and publishes each response item into this
12//! buffer with a `correlation_id`.
13//!
14//! The buffer is partitioned by `(topic, correlation_id)`. Publishers write to a
15//! topic and require each message to carry `metadata["correlation_id"]`.
16//! Consumers must be configured with both the same topic and the exact
17//! correlation id they want to read. This makes parallel streams safe by
18//! default: a consumer for request A cannot accidentally drain response items
19//! for request B.
20//!
21//! Commit semantics are intentionally mq-bridge-like:
22//!
23//! - received messages stay uncommitted until the returned batch commit
24//!   function is called;
25//! - `Ack` finalizes the read;
26//! - `Nack` or dropping the batch without committing requeues messages to the
27//!   same correlation partition;
28//! - acking an end marker with `metadata["http_stream_end"] == "true"` removes
29//!   that correlation partition.
30//!
31//! # HTTP streaming response example
32//!
33//! ```rust,ignore
34//! use mq_bridge::models::{Endpoint, EndpointType, HttpConfig, StreamBufferConfig};
35//! use mq_bridge::{CanonicalMessage, Payload};
36//!
37//! let buffer_topic = "llm-response-streams";
38//! let correlation_id = "request-42";
39//!
40//! // Configure the HTTP publisher to capture streamed response items into the
41//! // shared stream_buffer topic. This endpoint is publisher-only, so it does
42//! // not set a correlation_id.
43//! let http = Endpoint::new(EndpointType::Http(HttpConfig {
44//!     url: "http://127.0.0.1:8000/v1/generate".to_string(),
45//!     stream_response_to: Some(Box::new(Endpoint::new(EndpointType::StreamBuffer(
46//!         StreamBufferConfig::new(buffer_topic).with_capacity(100),
47//!     )))),
48//!     ..Default::default()
49//! }));
50//!
51//! // Send the request with an explicit correlation id. If you omit this
52//! // metadata, the HTTP publisher uses format!("{:032x}", message.message_id).
53//! let mut request = CanonicalMessage::new(Payload::Text("hello".into()));
54//! request.metadata.insert("correlation_id".into(), correlation_id.into());
55//! // create_publisher(&http).await?.send(request).await?;
56//!
57//! // Read only this request's streamed responses using the same topic and
58//! // correlation id. Other parallel HTTP responses remain isolated.
59//! let responses = Endpoint::new(EndpointType::StreamBuffer(
60//!     StreamBufferConfig::new(buffer_topic)
61//!         .with_correlation_id(correlation_id)
62//!         .with_capacity(100),
63//! ));
64//! // let batch = create_consumer(&responses).await?.receive_batch(10).await?;
65//! // (batch.commit)(MessageDisposition::Ack).await?;
66//!
67//!
68//!
69//! ```
70
71/// The publisher side writes messages to a shared `topic`. Each message must
72/// include `metadata["correlation_id"]`; HTTP response streaming adds this
73/// automatically from the request `correlation_id`, or falls back to the
74/// request `message_id` formatted as 32 lowercase hexadecimal characters.
75///
76/// The consumer side must be configured with both `topic` and `correlation_id`.
77/// It only receives messages for that correlation partition, so parallel HTTP
78/// streaming responses do not share a FIFO queue or consume each other's data.
79///
80/// Messages are removed from the buffer only when the received batch is acked.
81/// Nacked or dropped uncommitted batches are requeued to the same correlation
82/// partition. Acking a message with `metadata["http_stream_end"] == "true"`
83/// cleans up that partition.
84///
85/// # Example
86///
87/// ```rust,ignore
88/// use mq_bridge::models::{Endpoint, EndpointType, HttpConfig, StreamBufferConfig};
89///
90/// let responses = Endpoint::new(EndpointType::StreamBuffer(StreamBufferConfig {
91///     topic: "llm-responses".to_string(),
92///     correlation_id: None,
93///     capacity: Some(100),
94/// }));
95///
96/// let http_publisher = Endpoint::new(EndpointType::Http(HttpConfig {
97///     url: "http://127.0.0.1:8000/generate".to_string(),
98///     stream_response_to: Some(Box::new(responses)),
99///     ..Default::default()
100/// }));
101///
102/// // A UI or route can later read only this request's streamed responses:
103/// let response_consumer = Endpoint::new(EndpointType::StreamBuffer(
104///     StreamBufferConfig::new("llm-responses")
105///         .with_correlation_id("request-123")
106///         .with_capacity(100),
107/// ));
108/// ```
109use crate::models::StreamBufferConfig;
110use crate::traits::{
111    BatchCommitFunc, BoxFuture, ConsumerError, EndpointStatus, MessageConsumer, MessageDisposition,
112    MessagePublisher, PublisherError, ReceivedBatch, Sent, SentBatch,
113};
114use crate::CanonicalMessage;
115use anyhow::anyhow;
116use async_channel::{bounded, Receiver, Sender};
117use async_trait::async_trait;
118use once_cell::sync::Lazy;
119use std::any::Any;
120use std::collections::HashMap;
121use std::sync::{Arc, Mutex};
122use tracing::{trace, warn};
123
124const DEFAULT_CAPACITY: usize = 100;
125
126#[derive(Clone)]
127struct StreamPartition {
128    sender: Sender<Vec<CanonicalMessage>>,
129    receiver: Receiver<Vec<CanonicalMessage>>,
130}
131
132impl StreamPartition {
133    fn new(capacity: usize) -> Self {
134        let (sender, receiver) = bounded(capacity);
135        Self { sender, receiver }
136    }
137}
138
139type StreamTopic = HashMap<String, StreamPartition>;
140
141static STREAM_BUFFERS: Lazy<Mutex<HashMap<String, Arc<Mutex<StreamTopic>>>>> =
142    Lazy::new(|| Mutex::new(HashMap::new()));
143
144fn get_or_create_topic(topic: &str) -> Arc<Mutex<StreamTopic>> {
145    let mut buffers = STREAM_BUFFERS
146        .lock()
147        .expect("stream buffer registry poisoned");
148    buffers
149        .entry(topic.to_string())
150        .or_insert_with(|| Arc::new(Mutex::new(HashMap::new())))
151        .clone()
152}
153
154fn get_or_create_partition(topic: &str, correlation_id: &str, capacity: usize) -> StreamPartition {
155    let topic = get_or_create_topic(topic);
156    let mut partitions = topic.lock().expect("stream buffer topic poisoned");
157    partitions
158        .entry(correlation_id.to_string())
159        .or_insert_with(|| StreamPartition::new(capacity))
160        .clone()
161}
162
163fn remove_partition_if_current(
164    topic: &str,
165    correlation_id: &str,
166    sender: &Sender<Vec<CanonicalMessage>>,
167) {
168    let topic = get_or_create_topic(topic);
169    let mut partitions = topic.lock().expect("stream buffer topic poisoned");
170    let should_remove = partitions
171        .get(correlation_id)
172        .is_some_and(|partition| partition.sender.same_channel(sender));
173    if should_remove {
174        partitions.remove(correlation_id);
175        sender.close();
176    }
177}
178
179/// Publisher side of the `stream_buffer` endpoint.
180///
181/// `send` appends a message to the partition selected by
182/// `message.metadata["correlation_id"]`. It does not accept messages without a
183/// correlation id because otherwise consumers could not safely read one stream
184/// without also consuming another stream's responses.
185#[derive(Debug, Clone)]
186pub struct StreamBufferPublisher {
187    topic: String,
188    capacity: usize,
189}
190
191impl StreamBufferPublisher {
192    pub fn new(config: &StreamBufferConfig) -> anyhow::Result<Self> {
193        Ok(Self {
194            topic: config.topic.clone(),
195            capacity: config.capacity.unwrap_or(DEFAULT_CAPACITY),
196        })
197    }
198}
199
200#[async_trait]
201impl MessagePublisher for StreamBufferPublisher {
202    async fn send(&self, message: CanonicalMessage) -> Result<Sent, PublisherError> {
203        let correlation_id = message
204            .metadata
205            .get("correlation_id")
206            .cloned()
207            .ok_or_else(|| {
208                PublisherError::NonRetryable(anyhow!(
209                    "stream_buffer publisher requires message metadata 'correlation_id'"
210                ))
211            })?;
212        let partition = get_or_create_partition(&self.topic, &correlation_id, self.capacity);
213        partition
214            .sender
215            .send(vec![message])
216            .await
217            .map_err(|e| anyhow!("Failed to send to stream_buffer: {}", e))?;
218        Ok(Sent::Ack)
219    }
220
221    async fn send_batch(
222        &self,
223        messages: Vec<CanonicalMessage>,
224    ) -> Result<SentBatch, PublisherError> {
225        let mut messages = messages;
226        let Some(first) = messages.first() else {
227            return Ok(SentBatch::Ack);
228        };
229        let correlation_id = first
230            .metadata
231            .get("correlation_id")
232            .cloned()
233            .ok_or_else(|| {
234                PublisherError::NonRetryable(anyhow!(
235                    "stream_buffer publisher requires message metadata 'correlation_id'"
236                ))
237            })?;
238        if messages
239            .iter()
240            .any(|message| message.metadata.get("correlation_id") != Some(&correlation_id))
241        {
242            return Err(PublisherError::NonRetryable(anyhow!(
243                "stream_buffer publisher batch requires a single shared correlation_id"
244            )));
245        }
246        let partition = get_or_create_partition(&self.topic, &correlation_id, self.capacity);
247        if partition.sender.is_closed() {
248            return Err(PublisherError::Retryable(anyhow!(
249                "stream_buffer partition is closed"
250            )));
251        }
252        partition
253            .sender
254            .send(std::mem::take(&mut messages))
255            .await
256            .map_err(|e| anyhow!("Failed to send to stream_buffer: {}", e))?;
257        Ok(SentBatch::Ack)
258    }
259
260    async fn status(&self) -> EndpointStatus {
261        EndpointStatus {
262            healthy: true,
263            target: self.topic.clone(),
264            ..Default::default()
265        }
266    }
267
268    fn as_any(&self) -> &dyn Any {
269        self
270    }
271}
272
273/// Consumer side of the `stream_buffer` endpoint.
274///
275/// A consumer is bound to exactly one `(topic, correlation_id)` partition. Use
276/// this for reading the responses for one HTTP request, LLM generation, MCP
277/// call, or similar one-request/many-response workflow.
278#[derive(Debug)]
279pub struct StreamBufferConsumer {
280    topic: String,
281    correlation_id: String,
282    sender: Sender<Vec<CanonicalMessage>>,
283    receiver: Receiver<Vec<CanonicalMessage>>,
284    buffer: Vec<CanonicalMessage>,
285}
286
287impl StreamBufferConsumer {
288    pub fn new(config: &StreamBufferConfig) -> anyhow::Result<Self> {
289        let correlation_id = config.correlation_id.clone().ok_or_else(|| {
290            anyhow!("stream_buffer consumer requires 'correlation_id' in its config")
291        })?;
292        let partition = get_or_create_partition(
293            &config.topic,
294            &correlation_id,
295            config.capacity.unwrap_or(DEFAULT_CAPACITY),
296        );
297        Ok(Self {
298            topic: config.topic.clone(),
299            correlation_id,
300            sender: partition.sender,
301            receiver: partition.receiver,
302            buffer: Vec::new(),
303        })
304    }
305
306    async fn get_buffered_messages(
307        &mut self,
308        max_messages: usize,
309    ) -> Result<Vec<CanonicalMessage>, ConsumerError> {
310        let max_messages = max_messages.max(1);
311        if self.buffer.is_empty() {
312            self.buffer = match self.receiver.recv().await {
313                Ok(batch) => batch,
314                Err(_) => return Err(ConsumerError::EndOfStream),
315            };
316            self.buffer.reverse();
317        }
318
319        let num_to_take = self.buffer.len().min(max_messages);
320        let split_at = self.buffer.len() - num_to_take;
321        let mut messages = self.buffer.split_off(split_at);
322        messages.reverse();
323        Ok(messages)
324    }
325}
326
327impl Drop for StreamBufferConsumer {
328    fn drop(&mut self) {
329        if !self.buffer.is_empty() {
330            requeue_messages(self.sender.clone(), std::mem::take(&mut self.buffer));
331        }
332    }
333}
334
335struct RequeueGuard {
336    sender: Sender<Vec<CanonicalMessage>>,
337    messages: Vec<CanonicalMessage>,
338}
339
340impl Drop for RequeueGuard {
341    fn drop(&mut self) {
342        if !self.messages.is_empty() {
343            requeue_messages(self.sender.clone(), std::mem::take(&mut self.messages));
344        }
345    }
346}
347
348fn requeue_messages(sender: Sender<Vec<CanonicalMessage>>, messages: Vec<CanonicalMessage>) {
349    if messages.is_empty() {
350        return;
351    }
352    match sender.try_send(messages) {
353        Ok(_) => {}
354        Err(error) => {
355            let messages = match error {
356                async_channel::TrySendError::Full(messages) => messages,
357                async_channel::TrySendError::Closed(messages) => messages,
358            };
359            if let Ok(handle) = tokio::runtime::Handle::try_current() {
360                handle.spawn(async move {
361                    if let Err(error) = sender.send(messages).await {
362                        tracing::error!("Failed to requeue stream_buffer messages: {}", error);
363                    }
364                });
365            } else {
366                tracing::error!(
367                    "No active runtime found, could not requeue stream_buffer messages"
368                );
369            }
370        }
371    }
372}
373
374#[async_trait]
375impl MessageConsumer for StreamBufferConsumer {
376    async fn receive_batch(&mut self, max_messages: usize) -> Result<ReceivedBatch, ConsumerError> {
377        let mut messages = self.get_buffered_messages(max_messages).await?;
378        while messages.len() < max_messages.max(1) / 2 {
379            if let Ok(mut next_batch) = self.receiver.try_recv() {
380                let max_messages = max_messages.max(1);
381                if next_batch.len() + messages.len() > max_messages {
382                    let needed = max_messages - messages.len();
383                    let mut to_buffer = next_batch.split_off(needed);
384                    messages.append(&mut next_batch);
385                    self.buffer.append(&mut to_buffer);
386                    self.buffer.reverse();
387                    break;
388                }
389                messages.append(&mut next_batch);
390            } else {
391                break;
392            }
393        }
394
395        trace!(
396            topic = %self.topic,
397            correlation_id = %self.correlation_id,
398            count = messages.len(),
399            "Received stream_buffer messages"
400        );
401
402        let topic = self.topic.clone();
403        let correlation_id = self.correlation_id.clone();
404        let sender = self.sender.clone();
405        let expected_count = messages.len();
406        let mut guard = RequeueGuard {
407            sender: sender.clone(),
408            messages: messages.clone(),
409        };
410
411        let commit = Box::new(move |dispositions: Vec<MessageDisposition>| {
412            Box::pin(async move {
413                if dispositions.len() != expected_count {
414                    return Err(anyhow!(
415                        "stream_buffer commit received mismatched disposition count: expected {}, got {}",
416                        expected_count,
417                        dispositions.len()
418                    ));
419                }
420
421                let mut to_requeue = Vec::new();
422                let mut saw_acked_end_marker = false;
423                for (index, disposition) in dispositions.into_iter().enumerate() {
424                    match disposition {
425                        MessageDisposition::Ack | MessageDisposition::Reply(_) => {
426                            if guard
427                                .messages
428                                .get(index)
429                                .and_then(|message| message.metadata.get("http_stream_end"))
430                                .is_some_and(|value| value == "true")
431                            {
432                                saw_acked_end_marker = true;
433                            }
434                        }
435                        MessageDisposition::Nack => {
436                            if let Some(message) = guard.messages.get(index) {
437                                warn!(
438                                    topic = %topic,
439                                    correlation_id = %correlation_id,
440                                    index,
441                                    "Requeueing nacked stream_buffer message"
442                                );
443                                to_requeue.push(message.clone());
444                            }
445                        }
446                    }
447                }
448
449                std::mem::take(&mut guard.messages);
450                if !to_requeue.is_empty() {
451                    requeue_messages(sender.clone(), to_requeue);
452                } else if saw_acked_end_marker {
453                    remove_partition_if_current(&topic, &correlation_id, &sender);
454                }
455
456                Ok(())
457            }) as BoxFuture<'static, anyhow::Result<()>>
458        }) as BatchCommitFunc;
459
460        Ok(ReceivedBatch { messages, commit })
461    }
462
463    async fn status(&self) -> EndpointStatus {
464        EndpointStatus {
465            healthy: !self.receiver.is_closed(),
466            target: format!("{}/{}", self.topic, self.correlation_id),
467            pending: Some(self.receiver.len()),
468            capacity: Some(self.receiver.capacity().unwrap_or(0)),
469            ..Default::default()
470        }
471    }
472
473    fn as_any(&self) -> &dyn Any {
474        self
475    }
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481    use crate::traits::MessageConsumer;
482
483    fn config(topic: &str, correlation_id: Option<&str>) -> StreamBufferConfig {
484        StreamBufferConfig {
485            topic: topic.to_string(),
486            correlation_id: correlation_id.map(str::to_string),
487            capacity: Some(10),
488        }
489    }
490
491    #[tokio::test]
492    async fn test_stream_buffer_isolates_by_correlation_id() {
493        let topic = format!("stream_buffer_iso_{}", fast_uuid_v7::gen_id_str());
494        let publisher = StreamBufferPublisher::new(&config(&topic, None)).unwrap();
495
496        publisher
497            .send(CanonicalMessage::from_vec("a1").with_metadata_kv("correlation_id", "a"))
498            .await
499            .unwrap();
500        publisher
501            .send(CanonicalMessage::from_vec("b1").with_metadata_kv("correlation_id", "b"))
502            .await
503            .unwrap();
504        publisher
505            .send(CanonicalMessage::from_vec("a2").with_metadata_kv("correlation_id", "a"))
506            .await
507            .unwrap();
508
509        let mut consumer_a = StreamBufferConsumer::new(&config(&topic, Some("a"))).unwrap();
510        let mut consumer_b = StreamBufferConsumer::new(&config(&topic, Some("b"))).unwrap();
511
512        let first_a = consumer_a.receive().await.unwrap();
513        assert_eq!(first_a.message.get_payload_str(), "a1");
514        (first_a.commit)(MessageDisposition::Ack).await.unwrap();
515
516        let first_b = consumer_b.receive().await.unwrap();
517        assert_eq!(first_b.message.get_payload_str(), "b1");
518        (first_b.commit)(MessageDisposition::Ack).await.unwrap();
519
520        let second_a = consumer_a.receive().await.unwrap();
521        assert_eq!(second_a.message.get_payload_str(), "a2");
522        (second_a.commit)(MessageDisposition::Ack).await.unwrap();
523    }
524
525    #[tokio::test]
526    async fn test_stream_buffer_requeues_nacked_message() {
527        let topic = format!("stream_buffer_nack_{}", fast_uuid_v7::gen_id_str());
528        let publisher = StreamBufferPublisher::new(&config(&topic, None)).unwrap();
529        publisher
530            .send(CanonicalMessage::from_vec("retry").with_metadata_kv("correlation_id", "c"))
531            .await
532            .unwrap();
533
534        let mut consumer = StreamBufferConsumer::new(&config(&topic, Some("c"))).unwrap();
535        let received = consumer.receive().await.unwrap();
536        assert_eq!(received.message.get_payload_str(), "retry");
537        (received.commit)(MessageDisposition::Nack).await.unwrap();
538
539        let retried = consumer.receive().await.unwrap();
540        assert_eq!(retried.message.get_payload_str(), "retry");
541        (retried.commit)(MessageDisposition::Ack).await.unwrap();
542    }
543
544    #[tokio::test]
545    async fn test_stream_buffer_requeues_dropped_message() {
546        let topic = format!("stream_buffer_drop_{}", fast_uuid_v7::gen_id_str());
547        let publisher = StreamBufferPublisher::new(&config(&topic, None)).unwrap();
548        publisher
549            .send(CanonicalMessage::from_vec("held").with_metadata_kv("correlation_id", "c"))
550            .await
551            .unwrap();
552
553        let mut consumer = StreamBufferConsumer::new(&config(&topic, Some("c"))).unwrap();
554        {
555            let received = consumer.receive().await.unwrap();
556            assert_eq!(received.message.get_payload_str(), "held");
557        }
558
559        let redelivered = consumer.receive().await.unwrap();
560        assert_eq!(redelivered.message.get_payload_str(), "held");
561        (redelivered.commit)(MessageDisposition::Ack).await.unwrap();
562    }
563
564    #[tokio::test]
565    async fn test_stream_buffer_end_marker_ack_cleans_partition() {
566        let topic = format!("stream_buffer_end_{}", fast_uuid_v7::gen_id_str());
567        let publisher = StreamBufferPublisher::new(&config(&topic, None)).unwrap();
568        publisher
569            .send(
570                CanonicalMessage::from_vec("")
571                    .with_metadata_kv("correlation_id", "done")
572                    .with_metadata_kv("http_stream_end", "true"),
573            )
574            .await
575            .unwrap();
576
577        let mut consumer = StreamBufferConsumer::new(&config(&topic, Some("done"))).unwrap();
578        let end = consumer.receive().await.unwrap();
579        assert!(end.message.payload.is_empty());
580        (end.commit)(MessageDisposition::Ack).await.unwrap();
581
582        let result =
583            tokio::time::timeout(std::time::Duration::from_millis(100), consumer.receive()).await;
584        assert!(matches!(result, Ok(Err(ConsumerError::EndOfStream))));
585    }
586}