Skip to main content

mq_bridge/
traits.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
6pub use crate::errors::{ConsumerError, HandlerError, PublisherError};
7pub use crate::outcomes::{Handled, Received, ReceivedBatch, Sent, SentBatch};
8use crate::CanonicalMessage;
9use anyhow::anyhow;
10use async_trait::async_trait;
11pub use futures::future::BoxFuture;
12use std::any::Any;
13use std::sync::Arc;
14use tracing::warn;
15
16/// The disposition of a processed message.
17///
18/// Implements `From<Option<CanonicalMessage>>` for compatibility:
19/// `None` maps to `Ack`, `Some(msg)` maps to `Reply(msg)`.
20#[derive(Default, Debug, Clone)]
21#[allow(clippy::large_enum_variant)]
22pub enum MessageDisposition {
23    /// Acknowledge processing (success).
24    #[default]
25    Ack,
26    /// Acknowledge processing and send a reply.
27    Reply(CanonicalMessage),
28    /// Negative acknowledgement (failure).
29    Nack,
30}
31
32impl From<Option<CanonicalMessage>> for MessageDisposition {
33    fn from(opt: Option<CanonicalMessage>) -> Self {
34        match opt {
35            Some(msg) => MessageDisposition::Reply(msg),
36            None => MessageDisposition::Ack,
37        }
38    }
39}
40
41impl From<Handled> for MessageDisposition {
42    fn from(handled: Handled) -> Self {
43        match handled {
44            Handled::Ack => MessageDisposition::Ack,
45            Handled::Publish(msg) => MessageDisposition::Reply(msg),
46        }
47    }
48}
49
50/// A generic trait for handling messages (commands or events).
51///
52/// Handlers process an incoming message and can optionally return a new
53/// message (e.g. a reply) via `Handled::Publish`, or acknowledge processing via `Handled::Ack`.
54#[async_trait]
55pub trait Handler: Send + Sync + 'static {
56    async fn handle(&self, msg: CanonicalMessage) -> Result<Handled, HandlerError>;
57
58    async fn handle_many(&self, msgs: Vec<CanonicalMessage>) -> Vec<Result<Handled, HandlerError>> {
59        let mut results = Vec::with_capacity(msgs.len());
60        let mut remaining = msgs.len();
61        for msg in msgs {
62            remaining -= 1;
63            let result = self.handle(msg).await;
64            let aborted = match &result {
65                Err(HandlerError::Retryable(_)) => Some("retryable"),
66                Err(HandlerError::Connection(_)) => Some("connection"),
67                Err(HandlerError::NonRetryable(_)) => Some("non-retryable"),
68                Ok(_) => None,
69            };
70            results.push(result);
71            if let Some(kind) = aborted {
72                for _ in 0..remaining {
73                    results.push(Err(match kind {
74                        "retryable" => HandlerError::Retryable(anyhow!(
75                            "batch aborted after earlier retryable handler failure"
76                        )),
77                        "connection" => HandlerError::Connection(anyhow!(
78                            "batch aborted after earlier handler connection failure"
79                        )),
80                        _ => HandlerError::NonRetryable(anyhow!(
81                            "batch aborted after earlier non-retryable handler failure"
82                        )),
83                    }));
84                }
85                break;
86            }
87        }
88        results
89    }
90
91    /// Tries to register a handler for a specific type.
92    /// Returns `None` if this handler does not support registration (e.g. it's not a TypeHandler).
93    fn register_handler(
94        &self,
95        _type_name: &str,
96        _handler: Arc<dyn Handler>,
97    ) -> Option<Arc<dyn Handler>> {
98        None
99    }
100}
101
102#[async_trait]
103impl<T: Handler + ?Sized> Handler for Arc<T> {
104    async fn handle(&self, msg: CanonicalMessage) -> Result<Handled, HandlerError> {
105        (**self).handle(msg).await
106    }
107
108    async fn handle_many(&self, msgs: Vec<CanonicalMessage>) -> Vec<Result<Handled, HandlerError>> {
109        (**self).handle_many(msgs).await
110    }
111
112    fn register_handler(
113        &self,
114        type_name: &str,
115        handler: Arc<dyn Handler>,
116    ) -> Option<Arc<dyn Handler>> {
117        (**self).register_handler(type_name, handler)
118    }
119}
120
121/// A helper trait that allows implementing handlers using native `async fn` syntax
122/// without the `#[async_trait]` macro.
123///
124/// Implementations of this trait can be adapted to `Handler` using `SimpleHandler`.
125pub trait AsyncHandler: Send + Sync + 'static {
126    fn handle<'a>(&'a self, msg: CanonicalMessage) -> BoxFuture<'a, Result<Handled, HandlerError>>;
127}
128
129/// A wrapper struct that adapts an `AsyncHandler` to the `Handler` trait.
130pub struct SimpleHandler<T>(pub T);
131
132#[async_trait]
133impl<T: AsyncHandler> Handler for SimpleHandler<T> {
134    async fn handle(&self, msg: CanonicalMessage) -> Result<Handled, HandlerError> {
135        self.0.handle(msg).await
136    }
137}
138
139/// A closure that can be called to commit the message.
140/// It returns a `BoxFuture` to allow for async commit operations.
141pub type CommitFunc =
142    Box<dyn FnOnce(MessageDisposition) -> BoxFuture<'static, anyhow::Result<()>> + Send + 'static>;
143
144/// A closure for committing a batch of messages.
145pub type BatchCommitFunc = Box<
146    dyn FnOnce(Vec<MessageDisposition>) -> BoxFuture<'static, anyhow::Result<()>> + Send + 'static,
147>;
148
149/// Status information about an endpoint (Consumer or Publisher).
150#[derive(Debug, Clone, serde::Serialize)]
151pub struct EndpointStatus {
152    pub healthy: bool,
153    pub target: String,
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub pending: Option<usize>,
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub capacity: Option<usize>,
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub error: Option<String>,
160    pub details: serde_json::Value,
161}
162impl Default for EndpointStatus {
163    fn default() -> Self {
164        Self {
165            healthy: true,
166            target: String::new(),
167            pending: None,
168            capacity: None,
169            error: None,
170            details: serde_json::Value::Null,
171        }
172    }
173}
174
175/// How long a draining consumer blocks for a first message before yielding an
176/// empty batch so `exit_on_empty`/`--drain` can fire. Only applied while draining;
177/// the streaming path blocks indefinitely (event-driven, no added latency).
178///
179/// Defaults to 1s — a safety margin so a source with a momentary gap isn't declared
180/// drained prematurely. Override once at process start via the
181/// `MQ_BRIDGE_DRAIN_IDLE_TIMEOUT_MS` env var (e.g. `0` to yield immediately, which
182/// benchmarks want so the drain tail doesn't skew wall-clock timing). Read once and
183/// cached; changing the env var after first use has no effect.
184pub(crate) fn drain_idle_timeout() -> std::time::Duration {
185    static V: std::sync::OnceLock<std::time::Duration> = std::sync::OnceLock::new();
186    *V.get_or_init(|| {
187        std::env::var("MQ_BRIDGE_DRAIN_IDLE_TIMEOUT_MS")
188            .ok()
189            .and_then(|s| s.parse::<u64>().ok())
190            .map(std::time::Duration::from_millis)
191            .unwrap_or(std::time::Duration::from_millis(1000))
192    })
193}
194
195/// Awaits `fut`, but in drain mode gives up after [`drain_idle_timeout`], returning
196/// `None` so a blocking consumer can surface an empty batch instead of hanging.
197/// Outside drain mode it simply awaits, preserving the event-driven fast path.
198pub(crate) async fn drain_gated<F: std::future::Future>(
199    exit_on_empty: bool,
200    fut: F,
201) -> Option<F::Output> {
202    if exit_on_empty {
203        tokio::time::timeout(drain_idle_timeout(), fut).await.ok()
204    } else {
205        Some(fut.await)
206    }
207}
208
209#[async_trait]
210pub trait MessageConsumer: Send + Sync {
211    /// Returns an optional lifecycle hook that runs once after the consumer connection is created.
212    ///
213    /// The route awaits this hook before it reports itself as ready. Returning an error fails
214    /// route startup and lets the outer route runner reconnect or surface the startup failure.
215    ///
216    /// Use this for per-connection setup that should be shared by all messages read through this
217    /// consumer, such as warming a connection pool, creating SQLite tables or indexes, setting up
218    /// a Kafka consumer group, or authenticating a RabbitMQ channel.
219    ///
220    /// ```ignore
221    /// fn on_connect_hook(&self) -> Option<BoxFuture<'_, anyhow::Result<()>>> {
222    ///     Some(Box::pin(async move {
223    ///         self.pool.get().await?;
224    ///         self.db.execute("CREATE TABLE IF NOT EXISTS embeddings (...)").await?;
225    ///         Ok(())
226    ///     }))
227    /// }
228    /// ```
229    fn on_connect_hook(&self) -> Option<BoxFuture<'_, anyhow::Result<()>>> {
230        None
231    }
232
233    /// Returns an optional lifecycle hook that runs before the consumer is dropped.
234    ///
235    /// The route awaits this hook during shutdown or reconnect cleanup. Errors are logged as
236    /// warnings and do not replace the route's original result.
237    fn on_disconnect_hook(&self) -> Option<BoxFuture<'_, anyhow::Result<()>>> {
238        None
239    }
240
241    /// Receives a batch of messages.
242    ///
243    /// This method must be implemented by all consumers.
244    /// If in doubt, implement `receive_batch` to return a single message as a vector.
245    async fn receive_batch(&mut self, _max_messages: usize)
246        -> Result<ReceivedBatch, ConsumerError>;
247
248    /// Receives a single message.
249    async fn receive(&mut self) -> Result<Received, ConsumerError> {
250        // This default implementation ensures we get exactly one message,
251        // looping if the underlying batch consumer returns an empty batch.
252        loop {
253            let mut batch = self.receive_batch(1).await?;
254            if let Some(msg) = batch.messages.pop() {
255                debug_assert!(batch.messages.is_empty());
256                if !batch.messages.is_empty() {
257                    tracing::error!(
258                        "receive_batch(1) returned {} extra messages; dropping them (implementation bug)",
259                        batch.messages.len()
260                    );
261                }
262                return Ok(Received {
263                    message: msg,
264                    commit: into_commit_func(batch.commit),
265                });
266            }
267            // Batch was success but empty, which is unexpected for receive(1). Loop.
268            tokio::time::sleep(std::time::Duration::from_millis(1)).await;
269            tokio::task::yield_now().await;
270        }
271    }
272
273    async fn receive_batch_helper(
274        &mut self,
275        _max_messages: usize,
276    ) -> Result<ReceivedBatch, ConsumerError> {
277        let received = self.receive().await?; // The `?` now correctly handles ConsumerError
278        let batch_commit = Box::new(move |dispositions: Vec<MessageDisposition>| {
279            // The default implementation only handles one message, so we take the first disposition.
280            let single_disposition = dispositions
281                .into_iter()
282                .next()
283                .unwrap_or(MessageDisposition::Ack);
284            (received.commit)(single_disposition)
285        }) as BatchCommitFunc;
286        Ok(ReceivedBatch {
287            messages: vec![received.message],
288            commit: batch_commit,
289        })
290    }
291
292    /// Informs the consumer whether the owning route will terminate on an empty
293    /// batch (`exit_on_empty` / `--drain`). Called once by the route right after
294    /// creation. Blocking transports use it to gate an idle timeout (via
295    /// `drain_gated`) so a quiet source surfaces an empty batch instead of
296    /// hanging the drain; the file tail reader also uses it to decide whether a
297    /// final record with no trailing delimiter is complete (drain) or torn (live tail).
298    fn set_exit_on_empty(&mut self, _exit_on_empty: bool) {}
299
300    /// Whether this consumer's commits (acks) must be applied in the order the
301    /// batches were received.
302    ///
303    /// Defaults to `true` (the safe choice). Cumulative-ack transports such as
304    /// Kafka **must** keep this `true`: acking a later offset implicitly
305    /// acks everything before it, so committing out of order would silently drop
306    /// the messages in between on a crash. For these the route funnels commits
307    /// through a single ordered sequencer.
308    ///
309    /// Transports that ack each message/batch individually (NATS JetStream,
310    /// MQTT, MongoDB, in-memory) can override this to `false`. The route then runs
311    /// their commits concurrently (bounded by `commit_concurrency_limit`) instead
312    /// of serially, which removes the per-batch ack round trip as a throughput cap.
313    fn commit_requires_order(&self) -> bool {
314        true
315    }
316
317    async fn status(&self) -> EndpointStatus {
318        EndpointStatus {
319            healthy: true,
320            ..Default::default()
321        }
322    }
323
324    /// Releases this consumer's broker-side resources. The default awaits
325    /// `on_disconnect_hook()` if one is present; dropping the consumer afterwards
326    /// frees the underlying connection.
327    ///
328    /// Native Rust code rarely calls this directly — scope-based `Drop` already
329    /// cleans up. It exists mainly so the language bindings can expose an explicit
330    /// `close()`, since GC'd hosts (Python/Node) have no deterministic drop point.
331    async fn close(&mut self) -> anyhow::Result<()> {
332        if let Some(hook) = self.on_disconnect_hook() {
333            hook.await?;
334        }
335        Ok(())
336    }
337
338    fn as_any(&self) -> &dyn Any;
339}
340
341#[async_trait]
342pub trait MessagePublisher: Send + Sync + 'static {
343    /// Returns an optional lifecycle hook that runs once after the publisher connection is created.
344    ///
345    /// The route awaits this hook before it reports itself as ready. Returning an error fails
346    /// route startup and lets the outer route runner reconnect or surface the startup failure.
347    ///
348    /// Use this for per-connection setup that should be shared by all messages published through
349    /// this publisher, such as loading an embedding model, warming a connection pool, creating
350    /// SQLite tables or indexes, setting up a Kafka producer transaction context, or
351    /// authenticating a RabbitMQ channel.
352    ///
353    /// ```ignore
354    /// struct SqliteEmbeddingPublisher {
355    ///     model: Arc<tokio::sync::Mutex<Option<EmbeddingModel>>>,
356    ///     db: sqlx::SqlitePool,
357    /// }
358    ///
359    /// impl MessagePublisher for SqliteEmbeddingPublisher {
360    ///     fn on_connect_hook(&self) -> Option<BoxFuture<'_, anyhow::Result<()>>> {
361    ///         Some(Box::pin(async move {
362    ///             let mut model = self.model.lock().await;
363    ///             if model.is_none() {
364    ///                 *model = Some(EmbeddingModel::load("all-MiniLM-L6-v2").await?);
365    ///             }
366    ///             sqlx::query("CREATE INDEX IF NOT EXISTS idx_embeddings_id ON embeddings(id)")
367    ///                 .execute(&self.db)
368    ///                 .await?;
369    ///             Ok(())
370    ///         }))
371    ///     }
372    /// }
373    /// ```
374    fn on_connect_hook(&self) -> Option<BoxFuture<'_, anyhow::Result<()>>> {
375        None
376    }
377
378    /// Returns an optional lifecycle hook that runs before the publisher is dropped.
379    ///
380    /// The route awaits this hook during shutdown or reconnect cleanup. Errors are logged as
381    /// warnings and do not replace the route's original result.
382    fn on_disconnect_hook(&self) -> Option<BoxFuture<'_, anyhow::Result<()>>> {
383        None
384    }
385
386    /// Sends a batch of messages.
387    ///
388    /// This method must be implemented by all publishers.
389    /// If in doubt, implement `send_batch` to send messages one at a time.
390    async fn send_batch(
391        &self,
392        messages: Vec<CanonicalMessage>,
393    ) -> Result<SentBatch, PublisherError>;
394
395    async fn send(&self, message: CanonicalMessage) -> Result<Sent, PublisherError> {
396        let message_id = message.message_id;
397        let expects_reply = message.metadata.contains_key("reply_to");
398        match self.send_batch(vec![message]).await {
399            Ok(SentBatch::Ack) => {
400                if expects_reply {
401                    warn!("Message {:032x} expected a reply (reply_to set), but publisher returned Ack. Response loop might be broken.", message_id);
402                }
403                Ok(Sent::Ack)
404            }
405            Ok(SentBatch::Partial {
406                mut responses,
407                mut failed,
408            }) => {
409                if let Some((_, err)) = failed.pop() {
410                    Err(err)
411                } else if let Some(res) = responses.as_mut().and_then(|r| r.pop()) {
412                    Ok(Sent::Response(res))
413                } else {
414                    if expects_reply {
415                        warn!("Message {:032x} expected a reply (reply_to set), but publisher returned Ack. Response loop might be broken.", message_id);
416                    }
417                    Ok(Sent::Ack)
418                }
419            }
420            Err(e) => Err(e),
421        }
422    }
423
424    async fn flush(&self) -> anyhow::Result<()> {
425        Ok(())
426    }
427
428    async fn status(&self) -> EndpointStatus {
429        EndpointStatus {
430            healthy: true,
431            ..Default::default()
432        }
433    }
434    fn as_any(&self) -> &dyn Any;
435}
436
437#[async_trait]
438impl<T: MessagePublisher + ?Sized> MessagePublisher for Arc<T> {
439    fn on_connect_hook(&self) -> Option<BoxFuture<'_, anyhow::Result<()>>> {
440        (**self).on_connect_hook()
441    }
442
443    fn on_disconnect_hook(&self) -> Option<BoxFuture<'_, anyhow::Result<()>>> {
444        (**self).on_disconnect_hook()
445    }
446
447    async fn send(&self, message: CanonicalMessage) -> Result<Sent, PublisherError> {
448        (**self).send(message).await
449    }
450
451    async fn send_batch(
452        &self,
453        messages: Vec<CanonicalMessage>,
454    ) -> Result<SentBatch, PublisherError> {
455        (**self).send_batch(messages).await
456    }
457
458    async fn flush(&self) -> anyhow::Result<()> {
459        (**self).flush().await
460    }
461
462    async fn status(&self) -> EndpointStatus {
463        (**self).status().await
464    }
465
466    fn as_any(&self) -> &dyn Any {
467        (**self).as_any()
468    }
469}
470
471#[async_trait]
472impl<T: MessagePublisher + ?Sized> MessagePublisher for Box<T> {
473    fn on_connect_hook(&self) -> Option<BoxFuture<'_, anyhow::Result<()>>> {
474        (**self).on_connect_hook()
475    }
476
477    fn on_disconnect_hook(&self) -> Option<BoxFuture<'_, anyhow::Result<()>>> {
478        (**self).on_disconnect_hook()
479    }
480
481    async fn send(&self, message: CanonicalMessage) -> Result<Sent, PublisherError> {
482        (**self).send(message).await
483    }
484
485    async fn send_batch(
486        &self,
487        messages: Vec<CanonicalMessage>,
488    ) -> Result<SentBatch, PublisherError> {
489        (**self).send_batch(messages).await
490    }
491
492    async fn flush(&self) -> anyhow::Result<()> {
493        (**self).flush().await
494    }
495
496    async fn status(&self) -> EndpointStatus {
497        (**self).status().await
498    }
499
500    fn as_any(&self) -> &dyn Any {
501        (**self).as_any()
502    }
503}
504
505/// Factory for creating custom endpoints (consumers and publishers).
506#[async_trait]
507pub trait CustomEndpointFactory: Send + Sync + std::fmt::Debug {
508    async fn create_consumer(
509        &self,
510        _route_name: &str,
511        _config: &serde_json::Value,
512    ) -> anyhow::Result<Box<dyn MessageConsumer>> {
513        Err(anyhow::anyhow!(
514            "This custom endpoint does not support creating consumers"
515        ))
516    }
517    async fn create_publisher(
518        &self,
519        _route_name: &str,
520        _config: &serde_json::Value,
521    ) -> anyhow::Result<Box<dyn MessagePublisher>> {
522        Err(anyhow::anyhow!(
523            "This custom endpoint does not support creating publishers"
524        ))
525    }
526}
527
528/// Factory for creating custom middleware.
529#[async_trait]
530pub trait CustomMiddlewareFactory: Send + Sync + std::fmt::Debug {
531    async fn apply_consumer(
532        &self,
533        consumer: Box<dyn MessageConsumer>,
534        _route_name: &str,
535        _config: &serde_json::Value,
536    ) -> anyhow::Result<Box<dyn MessageConsumer>> {
537        Ok(consumer)
538    }
539
540    async fn apply_publisher(
541        &self,
542        publisher: Box<dyn MessagePublisher>,
543        _route_name: &str,
544        _config: &serde_json::Value,
545    ) -> anyhow::Result<Box<dyn MessagePublisher>> {
546        Ok(publisher)
547    }
548}
549
550/// Default number of per-message sends kept in flight concurrently by
551/// [`send_batch_helper`]. Bounds in-flight work so a large batch cannot overwhelm
552/// the underlying client's buffers (e.g. NATS JetStream PubAcks).
553pub const SEND_BATCH_CONCURRENCY: usize = 128;
554
555/// A helper function to send messages in bulk by calling `send` for each one.
556/// This is useful for `MessagePublisher` implementations that don't have a native bulk sending mechanism.
557/// Requires that "send" is implemented for the publisher. Otherwise causes an infinite loop,
558/// as send is calling "send_batch" by default.
559///
560/// Sends are pipelined: up to [`SEND_BATCH_CONCURRENCY`] are kept in flight at once
561/// via `buffer_unordered`, then responses and failures are restored to input order
562/// before returning. This avoids head-of-line blocking when an early send is slow
563/// while still overlapping per-message round trips (e.g. JetStream PubAcks).
564pub async fn send_batch_helper<P: MessagePublisher + ?Sized>(
565    publisher: &P,
566    messages: Vec<CanonicalMessage>,
567    callback: impl for<'a> Fn(&'a P, CanonicalMessage) -> BoxFuture<'a, Result<Sent, PublisherError>>
568        + Send
569        + Sync,
570) -> Result<SentBatch, PublisherError> {
571    use futures::stream::StreamExt;
572
573    let mut responses = Vec::new();
574    let mut failed_messages = Vec::new();
575
576    // Pair each result with its message so failures can report the message back.
577    // Poll unordered to keep slots full, then sort successful responses and
578    // failures back into input order before exposing them to callers.
579    let callback = &callback;
580    let mut results = futures::stream::iter(messages.into_iter().enumerate().map(
581        |(idx, msg)| async move {
582            let result = callback(publisher, msg.clone()).await;
583            (idx, msg, result)
584        },
585    ))
586    .buffer_unordered(SEND_BATCH_CONCURRENCY);
587
588    while let Some((idx, msg, result)) = results.next().await {
589        match result {
590            Ok(Sent::Response(resp)) => responses.push((idx, resp)),
591            Ok(Sent::Ack) => {}
592            // Each send is awaited independently, so report each message's actual
593            // outcome. Transient (Retryable/Connection) failures are surfaced with
594            // their error so the route can Nack them for redelivery; messages that
595            // did succeed before/around the failure are not needlessly resent.
596            Err(e) => failed_messages.push((idx, msg, e)),
597        }
598    }
599
600    responses.sort_by_key(|(idx, _)| *idx);
601    let responses: Vec<_> = responses.into_iter().map(|(_, resp)| resp).collect();
602    failed_messages.sort_by_key(|(idx, _, _)| *idx);
603    let failed_messages: Vec<_> = failed_messages
604        .into_iter()
605        .map(|(_, msg, err)| (msg, err))
606        .collect();
607
608    if failed_messages.is_empty() && responses.is_empty() {
609        Ok(SentBatch::Ack)
610    } else {
611        Ok(SentBatch::Partial {
612            responses: if responses.is_empty() {
613                None
614            } else {
615                Some(responses)
616            },
617            failed: failed_messages,
618        })
619    }
620}
621
622/// Converts a `BatchCommitFunc` into a `CommitFunc` by wrapping it.
623/// This allows a function that commits a batch of messages to be used where a
624/// function that commits a single message is expected.
625pub fn into_commit_func(batch_commit: BatchCommitFunc) -> CommitFunc {
626    Box::new(move |disposition: MessageDisposition| {
627        let batch_disposition = vec![disposition];
628        batch_commit(batch_disposition)
629    })
630}
631
632/// Converts a `CommitFunc` into a `BatchCommitFunc` by wrapping it.
633/// This allows a function that commits a single message to be used where a
634/// function that commits a batch of messages is expected. It does so by
635/// extracting the first message from the response vector (if any) and passing
636/// it to the underlying single-message commit function.
637pub fn into_batch_commit_func(commit: CommitFunc) -> BatchCommitFunc {
638    Box::new(move |mut dispositions: Vec<MessageDisposition>| {
639        let single_disposition = if dispositions.len() > 1 {
640            warn!(
641                "into_batch_commit_func called with batch of {} messages; dropping all responses to avoid partial commit (incorrect usage)",
642                dispositions.len()
643            );
644            // Default to Ack to avoid hanging if we can't process the batch correctly
645            MessageDisposition::Ack
646        } else {
647            dispositions.pop().unwrap_or(MessageDisposition::Ack)
648        };
649        commit(single_disposition)
650    })
651}
652
653#[cfg(test)]
654mod tests {
655    use super::*;
656    use crate::CanonicalMessage;
657    use anyhow::anyhow;
658    use std::sync::{
659        atomic::{AtomicUsize, Ordering},
660        Arc,
661    };
662
663    struct MockPublisher;
664    #[async_trait]
665    impl MessagePublisher for MockPublisher {
666        async fn send_batch(
667            &self,
668            _msgs: Vec<CanonicalMessage>,
669        ) -> Result<SentBatch, PublisherError> {
670            Ok(SentBatch::Ack)
671        }
672        fn as_any(&self) -> &dyn Any {
673            self
674        }
675    }
676
677    #[tokio::test]
678    async fn test_send_batch_helper_partial_failure() {
679        let publisher = MockPublisher;
680        let msgs = vec![
681            CanonicalMessage::from("1"),
682            CanonicalMessage::from("2"),
683            CanonicalMessage::from("3"),
684        ];
685
686        let result = send_batch_helper(&publisher, msgs.clone(), |_pub, msg| {
687            Box::pin(async move {
688                let payload = msg.get_payload_str();
689                if payload == "1" {
690                    Ok(Sent::Response(CanonicalMessage::from("resp1")))
691                } else if payload == "2" {
692                    Err(PublisherError::Retryable(anyhow!("fail")))
693                } else {
694                    Ok(Sent::Ack)
695                }
696            })
697        })
698        .await;
699
700        match result {
701            Ok(SentBatch::Partial { responses, failed }) => {
702                assert!(responses.is_some());
703                let resps = responses.unwrap();
704                assert_eq!(resps.len(), 1);
705                assert_eq!(resps[0].get_payload_str(), "resp1");
706
707                // Verify failures. Sends are pipelined and awaited independently,
708                // so only message 2 (the one that errored) is reported failed;
709                // message 3 still succeeds (Ack) and is not needlessly resent.
710                assert_eq!(failed.len(), 1);
711                assert_eq!(failed[0].0.get_payload_str(), "2");
712                assert!(matches!(failed[0].1, PublisherError::Retryable(_)));
713            }
714            _ => panic!("Expected Partial result"),
715        }
716    }
717
718    #[tokio::test]
719    async fn test_send_batch_helper_preserves_response_order() {
720        // The first message resolves last (longest sleep). The helper polls sends
721        // unordered internally, but responses must still come back in input order.
722        let publisher = MockPublisher;
723        let count = 16u64;
724        let msgs: Vec<CanonicalMessage> = (0..count)
725            .map(|i| CanonicalMessage::from(i.to_string()))
726            .collect();
727
728        let result = send_batch_helper(&publisher, msgs, |_pub, msg| {
729            Box::pin(async move {
730                let i: u64 = msg.get_payload_str().parse().unwrap();
731                // Earlier messages sleep longer, so completion order is reversed.
732                tokio::time::sleep(std::time::Duration::from_millis((count - i) * 2)).await;
733                let mut resp = CanonicalMessage::from(msg.get_payload_str().to_string());
734                resp.message_id = msg.message_id;
735                Ok(Sent::Response(resp))
736            })
737        })
738        .await
739        .unwrap();
740
741        match result {
742            SentBatch::Partial { responses, failed } => {
743                assert!(failed.is_empty());
744                let responses = responses.expect("expected responses");
745                let order: Vec<u64> = responses
746                    .iter()
747                    .map(|r| r.get_payload_str().parse().unwrap())
748                    .collect();
749                assert_eq!(
750                    order,
751                    (0..count).collect::<Vec<u64>>(),
752                    "send_batch_helper must preserve input order",
753                );
754            }
755            SentBatch::Ack => panic!("expected per-message responses"),
756        }
757    }
758
759    #[tokio::test]
760    async fn test_send_batch_helper_keeps_pipeline_full_when_early_send_is_slow() {
761        let publisher = Arc::new(MockPublisher);
762        let total = SEND_BATCH_CONCURRENCY + 1;
763        let msgs: Vec<CanonicalMessage> = (0..total)
764            .map(|i| CanonicalMessage::from(i.to_string()))
765            .collect();
766        let started = Arc::new(AtomicUsize::new(0));
767        let all_started = Arc::new(tokio::sync::Notify::new());
768        let release_first = Arc::new(tokio::sync::Notify::new());
769
770        let helper = tokio::spawn({
771            let publisher = Arc::clone(&publisher);
772            let started = Arc::clone(&started);
773            let all_started = Arc::clone(&all_started);
774            let release_first = Arc::clone(&release_first);
775            async move {
776                send_batch_helper(&publisher, msgs, |_pub, msg| {
777                    let started = Arc::clone(&started);
778                    let all_started = Arc::clone(&all_started);
779                    let release_first = Arc::clone(&release_first);
780                    Box::pin(async move {
781                        let idx: usize = msg.get_payload_str().parse().unwrap();
782                        if started.fetch_add(1, Ordering::SeqCst) + 1 == total {
783                            all_started.notify_waiters();
784                        }
785                        if idx == 0 {
786                            release_first.notified().await;
787                        }
788                        let mut resp = CanonicalMessage::from(idx.to_string());
789                        resp.message_id = msg.message_id;
790                        Ok(Sent::Response(resp))
791                    })
792                })
793                .await
794            }
795        });
796
797        tokio::time::timeout(std::time::Duration::from_millis(200), async {
798            loop {
799                // Register the waiter before checking so a notify_waiters() landing
800                // between the check and the await is not lost (Notify doesn't buffer).
801                let notified = all_started.notified();
802                tokio::pin!(notified);
803                notified.as_mut().enable();
804                if started.load(Ordering::SeqCst) == total {
805                    break;
806                }
807                notified.await;
808            }
809        })
810        .await
811        .expect("a completed later send should free a slot even while the first send is blocked");
812
813        release_first.notify_waiters();
814        let result = helper.await.unwrap().unwrap();
815        match result {
816            SentBatch::Partial { responses, failed } => {
817                assert!(failed.is_empty());
818                let order: Vec<usize> = responses
819                    .expect("expected responses")
820                    .iter()
821                    .map(|r| r.get_payload_str().parse().unwrap())
822                    .collect();
823                assert_eq!(order, (0..total).collect::<Vec<_>>());
824            }
825            SentBatch::Ack => panic!("expected per-message responses"),
826        }
827    }
828
829    #[tokio::test]
830    async fn test_send_propagates_single_error() {
831        struct FailPublisher;
832        #[async_trait]
833        impl MessagePublisher for FailPublisher {
834            async fn send_batch(
835                &self,
836                msgs: Vec<CanonicalMessage>,
837            ) -> Result<SentBatch, PublisherError> {
838                // Simulate what send_batch_helper does on single failure
839                Ok(SentBatch::Partial {
840                    responses: None,
841                    failed: vec![(
842                        msgs[0].clone(),
843                        PublisherError::NonRetryable(anyhow!("inner")),
844                    )],
845                })
846            }
847            fn as_any(&self) -> &dyn Any {
848                self
849            }
850        }
851
852        let publ = FailPublisher;
853        let res = publ.send(CanonicalMessage::from("test")).await;
854
855        assert!(res.is_err());
856        match res.unwrap_err() {
857            PublisherError::NonRetryable(e) => assert_eq!(e.to_string(), "inner"),
858            _ => panic!("Expected NonRetryable error"),
859        }
860    }
861
862    #[tokio::test]
863    async fn test_simple_handler_wrapper() {
864        struct MyLogic;
865        impl AsyncHandler for MyLogic {
866            fn handle<'a>(
867                &'a self,
868                _msg: CanonicalMessage,
869            ) -> BoxFuture<'a, Result<Handled, HandlerError>> {
870                Box::pin(async { Ok(Handled::Ack) })
871            }
872        }
873
874        let handler = SimpleHandler(MyLogic);
875        let res = handler.handle(CanonicalMessage::from("test")).await;
876        assert!(matches!(res, Ok(Handled::Ack)));
877    }
878}