use crate::models::StreamBufferConfig;
use crate::traits::{
BatchCommitFunc, BoxFuture, ConsumerError, EndpointStatus, MessageConsumer, MessageDisposition,
MessagePublisher, PublisherError, ReceivedBatch, Sent, SentBatch,
};
use crate::CanonicalMessage;
use anyhow::anyhow;
use async_channel::{bounded, Receiver, Sender};
use async_trait::async_trait;
use once_cell::sync::Lazy;
use std::any::Any;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tracing::{trace, warn};
const DEFAULT_CAPACITY: usize = 100;
#[derive(Clone)]
struct StreamPartition {
sender: Sender<Vec<CanonicalMessage>>,
receiver: Receiver<Vec<CanonicalMessage>>,
}
impl StreamPartition {
fn new(capacity: usize) -> Self {
let (sender, receiver) = bounded(capacity);
Self { sender, receiver }
}
}
type StreamTopic = HashMap<String, StreamPartition>;
static STREAM_BUFFERS: Lazy<Mutex<HashMap<String, Arc<Mutex<StreamTopic>>>>> =
Lazy::new(|| Mutex::new(HashMap::new()));
fn get_or_create_topic(topic: &str) -> Arc<Mutex<StreamTopic>> {
let mut buffers = STREAM_BUFFERS
.lock()
.expect("stream buffer registry poisoned");
buffers
.entry(topic.to_string())
.or_insert_with(|| Arc::new(Mutex::new(HashMap::new())))
.clone()
}
fn get_or_create_partition(topic: &str, correlation_id: &str, capacity: usize) -> StreamPartition {
let topic = get_or_create_topic(topic);
let mut partitions = topic.lock().expect("stream buffer topic poisoned");
partitions
.entry(correlation_id.to_string())
.or_insert_with(|| StreamPartition::new(capacity))
.clone()
}
fn remove_partition_if_current(
topic: &str,
correlation_id: &str,
sender: &Sender<Vec<CanonicalMessage>>,
) {
let mut buffers = STREAM_BUFFERS
.lock()
.expect("stream buffer registry poisoned");
let Some(topic_arc) = buffers.get(topic).cloned() else {
return;
};
let mut partitions = topic_arc.lock().expect("stream buffer topic poisoned");
let should_remove = partitions
.get(correlation_id)
.is_some_and(|partition| partition.sender.same_channel(sender));
if should_remove {
partitions.remove(correlation_id);
sender.close();
let now_empty = partitions.is_empty();
drop(partitions);
if now_empty {
buffers.remove(topic);
}
}
}
#[derive(Debug, Clone)]
pub struct StreamBufferPublisher {
topic: String,
capacity: usize,
}
impl StreamBufferPublisher {
pub fn new(config: &StreamBufferConfig) -> anyhow::Result<Self> {
Ok(Self {
topic: config.topic.clone(),
capacity: config.capacity.unwrap_or(DEFAULT_CAPACITY),
})
}
}
#[async_trait]
impl MessagePublisher for StreamBufferPublisher {
async fn send(&self, message: CanonicalMessage) -> Result<Sent, PublisherError> {
let correlation_id = message
.metadata
.get("correlation_id")
.cloned()
.ok_or_else(|| {
PublisherError::NonRetryable(anyhow!(
"stream_buffer publisher requires message metadata 'correlation_id'"
))
})?;
let partition = get_or_create_partition(&self.topic, &correlation_id, self.capacity);
if partition.sender.is_closed() {
return Err(PublisherError::Retryable(anyhow!(
"stream_buffer partition is closed"
)));
}
partition.sender.send(vec![message]).await.map_err(|e| {
PublisherError::Retryable(anyhow!("Failed to send to stream_buffer: {}", e))
})?;
Ok(Sent::Ack)
}
async fn send_batch(
&self,
messages: Vec<CanonicalMessage>,
) -> Result<SentBatch, PublisherError> {
let mut messages = messages;
let Some(first) = messages.first() else {
return Ok(SentBatch::Ack);
};
let correlation_id = first
.metadata
.get("correlation_id")
.cloned()
.ok_or_else(|| {
PublisherError::NonRetryable(anyhow!(
"stream_buffer publisher requires message metadata 'correlation_id'"
))
})?;
if messages
.iter()
.any(|message| message.metadata.get("correlation_id") != Some(&correlation_id))
{
return Err(PublisherError::NonRetryable(anyhow!(
"stream_buffer publisher batch requires a single shared correlation_id"
)));
}
let partition = get_or_create_partition(&self.topic, &correlation_id, self.capacity);
if partition.sender.is_closed() {
return Err(PublisherError::Retryable(anyhow!(
"stream_buffer partition is closed"
)));
}
partition
.sender
.send(std::mem::take(&mut messages))
.await
.map_err(|e| {
PublisherError::Retryable(anyhow!("Failed to send to stream_buffer: {}", e))
})?;
Ok(SentBatch::Ack)
}
async fn status(&self) -> EndpointStatus {
EndpointStatus {
healthy: true,
target: self.topic.clone(),
..Default::default()
}
}
fn as_any(&self) -> &dyn Any {
self
}
}
#[derive(Debug)]
pub struct StreamBufferConsumer {
topic: String,
correlation_id: String,
sender: Sender<Vec<CanonicalMessage>>,
receiver: Receiver<Vec<CanonicalMessage>>,
buffer: Vec<CanonicalMessage>,
}
impl StreamBufferConsumer {
pub fn new(config: &StreamBufferConfig) -> anyhow::Result<Self> {
let correlation_id = config.correlation_id.clone().ok_or_else(|| {
anyhow!("stream_buffer consumer requires 'correlation_id' in its config")
})?;
let partition = get_or_create_partition(
&config.topic,
&correlation_id,
config.capacity.unwrap_or(DEFAULT_CAPACITY),
);
Ok(Self {
topic: config.topic.clone(),
correlation_id,
sender: partition.sender,
receiver: partition.receiver,
buffer: Vec::new(),
})
}
async fn get_buffered_messages(
&mut self,
max_messages: usize,
) -> Result<Vec<CanonicalMessage>, ConsumerError> {
let max_messages = max_messages.max(1);
if self.buffer.is_empty() {
self.buffer = match self.receiver.recv().await {
Ok(batch) => batch,
Err(_) => return Err(ConsumerError::EndOfStream),
};
self.buffer.reverse();
}
let num_to_take = self.buffer.len().min(max_messages);
let split_at = self.buffer.len() - num_to_take;
let mut messages = self.buffer.split_off(split_at);
messages.reverse();
Ok(messages)
}
}
impl Drop for StreamBufferConsumer {
fn drop(&mut self) {
self.buffer.clear();
remove_partition_if_current(&self.topic, &self.correlation_id, &self.sender);
}
}
struct RequeueGuard {
sender: Sender<Vec<CanonicalMessage>>,
messages: Vec<CanonicalMessage>,
}
impl Drop for RequeueGuard {
fn drop(&mut self) {
if !self.messages.is_empty() {
requeue_messages(self.sender.clone(), std::mem::take(&mut self.messages));
}
}
}
fn requeue_messages(sender: Sender<Vec<CanonicalMessage>>, messages: Vec<CanonicalMessage>) {
if messages.is_empty() {
return;
}
match sender.try_send(messages) {
Ok(_) => {}
Err(error) => {
let messages = match error {
async_channel::TrySendError::Full(messages) => messages,
async_channel::TrySendError::Closed(messages) => messages,
};
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(async move {
if let Err(error) = sender.send(messages).await {
tracing::error!("Failed to requeue stream_buffer messages: {}", error);
}
});
} else {
tracing::error!(
"No active runtime found, could not requeue stream_buffer messages"
);
}
}
}
}
#[async_trait]
impl MessageConsumer for StreamBufferConsumer {
async fn receive_batch(&mut self, max_messages: usize) -> Result<ReceivedBatch, ConsumerError> {
let mut messages = self.get_buffered_messages(max_messages).await?;
while messages.len() < max_messages.max(1) / 2 {
if let Ok(mut next_batch) = self.receiver.try_recv() {
let max_messages = max_messages.max(1);
if next_batch.len() + messages.len() > max_messages {
let needed = max_messages - messages.len();
let mut to_buffer = next_batch.split_off(needed);
messages.append(&mut next_batch);
self.buffer.append(&mut to_buffer);
self.buffer.reverse();
break;
}
messages.append(&mut next_batch);
} else {
break;
}
}
trace!(
topic = %self.topic,
correlation_id = %self.correlation_id,
count = messages.len(),
"Received stream_buffer messages"
);
let topic = self.topic.clone();
let correlation_id = self.correlation_id.clone();
let sender = self.sender.clone();
let expected_count = messages.len();
let mut guard = RequeueGuard {
sender: sender.clone(),
messages: messages.clone(),
};
let commit = Box::new(move |dispositions: Vec<MessageDisposition>| {
Box::pin(async move {
if dispositions.len() != expected_count {
return Err(anyhow!(
"stream_buffer commit received mismatched disposition count: expected {}, got {}",
expected_count,
dispositions.len()
));
}
let mut to_requeue = Vec::new();
let mut saw_acked_end_marker = false;
for (index, disposition) in dispositions.into_iter().enumerate() {
match disposition {
MessageDisposition::Ack | MessageDisposition::Reply(_) => {
if guard
.messages
.get(index)
.and_then(|message| message.metadata.get("http_stream_end"))
.is_some_and(|value| value == "true")
{
saw_acked_end_marker = true;
}
}
MessageDisposition::Nack => {
if let Some(message) = guard.messages.get(index) {
warn!(
topic = %topic,
correlation_id = %correlation_id,
index,
"Requeueing nacked stream_buffer message"
);
to_requeue.push(message.clone());
}
}
}
}
std::mem::take(&mut guard.messages);
if !to_requeue.is_empty() {
requeue_messages(sender.clone(), to_requeue);
}
if saw_acked_end_marker {
remove_partition_if_current(&topic, &correlation_id, &sender);
}
Ok(())
}) as BoxFuture<'static, anyhow::Result<()>>
}) as BatchCommitFunc;
Ok(ReceivedBatch { messages, commit })
}
async fn status(&self) -> EndpointStatus {
EndpointStatus {
healthy: !self.receiver.is_closed(),
target: format!("{}/{}", self.topic, self.correlation_id),
pending: Some(self.receiver.len()),
capacity: Some(self.receiver.capacity().unwrap_or(0)),
details: serde_json::json!({
"queued_batches": self.receiver.len(),
"buffered_messages": self.buffer.len(),
}),
..Default::default()
}
}
fn as_any(&self) -> &dyn Any {
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::MessageConsumer;
fn config(topic: &str, correlation_id: Option<&str>) -> StreamBufferConfig {
StreamBufferConfig {
topic: topic.to_string(),
correlation_id: correlation_id.map(str::to_string),
capacity: Some(10),
}
}
#[tokio::test]
async fn test_stream_buffer_isolates_by_correlation_id() {
let topic = format!("stream_buffer_iso_{}", fast_uuid_v7::gen_id_str());
let publisher = StreamBufferPublisher::new(&config(&topic, None)).unwrap();
publisher
.send(CanonicalMessage::from_vec("a1").with_metadata_kv("correlation_id", "a"))
.await
.unwrap();
publisher
.send(CanonicalMessage::from_vec("b1").with_metadata_kv("correlation_id", "b"))
.await
.unwrap();
publisher
.send(CanonicalMessage::from_vec("a2").with_metadata_kv("correlation_id", "a"))
.await
.unwrap();
let mut consumer_a = StreamBufferConsumer::new(&config(&topic, Some("a"))).unwrap();
let mut consumer_b = StreamBufferConsumer::new(&config(&topic, Some("b"))).unwrap();
let first_a = consumer_a.receive().await.unwrap();
assert_eq!(first_a.message.get_payload_str(), "a1");
(first_a.commit)(MessageDisposition::Ack).await.unwrap();
let first_b = consumer_b.receive().await.unwrap();
assert_eq!(first_b.message.get_payload_str(), "b1");
(first_b.commit)(MessageDisposition::Ack).await.unwrap();
let second_a = consumer_a.receive().await.unwrap();
assert_eq!(second_a.message.get_payload_str(), "a2");
(second_a.commit)(MessageDisposition::Ack).await.unwrap();
}
#[tokio::test]
async fn test_stream_buffer_requeues_nacked_message() {
let topic = format!("stream_buffer_nack_{}", fast_uuid_v7::gen_id_str());
let publisher = StreamBufferPublisher::new(&config(&topic, None)).unwrap();
publisher
.send(CanonicalMessage::from_vec("retry").with_metadata_kv("correlation_id", "c"))
.await
.unwrap();
let mut consumer = StreamBufferConsumer::new(&config(&topic, Some("c"))).unwrap();
let received = consumer.receive().await.unwrap();
assert_eq!(received.message.get_payload_str(), "retry");
(received.commit)(MessageDisposition::Nack).await.unwrap();
let retried = consumer.receive().await.unwrap();
assert_eq!(retried.message.get_payload_str(), "retry");
(retried.commit)(MessageDisposition::Ack).await.unwrap();
}
#[tokio::test]
async fn test_stream_buffer_requeues_dropped_message() {
let topic = format!("stream_buffer_drop_{}", fast_uuid_v7::gen_id_str());
let publisher = StreamBufferPublisher::new(&config(&topic, None)).unwrap();
publisher
.send(CanonicalMessage::from_vec("held").with_metadata_kv("correlation_id", "c"))
.await
.unwrap();
let mut consumer = StreamBufferConsumer::new(&config(&topic, Some("c"))).unwrap();
{
let received = consumer.receive().await.unwrap();
assert_eq!(received.message.get_payload_str(), "held");
}
let redelivered = consumer.receive().await.unwrap();
assert_eq!(redelivered.message.get_payload_str(), "held");
(redelivered.commit)(MessageDisposition::Ack).await.unwrap();
}
#[tokio::test]
async fn test_stream_buffer_end_marker_ack_cleans_partition() {
let topic = format!("stream_buffer_end_{}", fast_uuid_v7::gen_id_str());
let publisher = StreamBufferPublisher::new(&config(&topic, None)).unwrap();
publisher
.send(
CanonicalMessage::from_vec("")
.with_metadata_kv("correlation_id", "done")
.with_metadata_kv("http_stream_end", "true"),
)
.await
.unwrap();
let mut consumer = StreamBufferConsumer::new(&config(&topic, Some("done"))).unwrap();
let end = consumer.receive().await.unwrap();
assert!(end.message.payload.is_empty());
(end.commit)(MessageDisposition::Ack).await.unwrap();
let result =
tokio::time::timeout(std::time::Duration::from_millis(100), consumer.receive()).await;
assert!(matches!(result, Ok(Err(ConsumerError::EndOfStream))));
}
}