magnetar/consumer_listener.rs
1// SPDX-License-Identifier: Apache-2.0
2
3//! Push-delivery consumer listener. Mirrors
4//! `org.apache.pulsar.client.api.MessageListener` /
5//! `ConsumerBuilder#messageListener`.
6//!
7//! A [`MessageListener`] is a user callback invoked once per delivered message.
8//! Registering one on a consumer builder (via `message_listener(...)`) and
9//! subscribing with `subscribe_with_listener()` flips the consumer from
10//! **pull** mode (`receive` / `receive_async`) to **push** mode: a background
11//! poller task drives the consumer's existing `receive()` loop and hands each
12//! message to the callback.
13//!
14//! ## Design (runtime-side, proto stays sans-io — ADR-0004)
15//!
16//! `MessageListener` is a runtime concept. `magnetar-proto` cannot spawn tasks
17//! or invoke callbacks, so nothing here touches the sans-io state machine. The
18//! poller is a [`tokio::spawn`]ed task — exactly the pattern
19//! [`crate::TableView`]'s `spawn_drain` uses (ADR-0025: both engines schedule
20//! on tokio; determinism for the moonpool engine comes from substituting the
21//! `moonpool_core::Providers`, not from replacing the executor). It is
22//! engine-generic over `C: ConsumerApi + Clone`, so the same poller serves the
23//! tokio and moonpool consumers without a per-engine carve-out.
24//!
25//! ## Delivery semantics (match Java)
26//!
27//! - **Sequential, in order.** The poller awaits one `receive()`, runs the callback to completion,
28//! then pulls the next message. There is no per-message concurrency — order is preserved, exactly
29//! like Java's single-threaded per-consumer listener executor.
30//! - **No channel between the consumer and the listener** (ADR-0003). The poller calls `receive()`
31//! directly, which already parks on the per-consumer `Notify` / `Waker` slab inside the sans-io
32//! state machine.
33//! - **No auto-ack.** The callback is responsible for acking (positive ack, cumulative ack, or
34//! nack) — same contract as Java's `MessageListener`, which hands you the `Consumer` so you ack
35//! explicitly. The poller never acks on the callback's behalf.
36//! - **Clean shutdown.** An explicit or terminal remote consumer close makes `receive()` resolve
37//! with an error, so the poller loop ends without a panic. Dropping the returned
38//! [`MessageListenerHandle`] (or calling [`MessageListenerHandle::close`]) aborts the poller;
39//! task unwinding then drops its owned consumer clone. That clone triggers a best-effort close
40//! only if it is the final clone. Dropping an intermediate consumer clone does nothing.
41//!
42//! ## Pull / push are mutually exclusive
43//!
44//! Java forbids calling `receive()` on a consumer that has a `messageListener`.
45//! magnetar mirrors the intent by *moving* the consumer into the poller task:
46//! `subscribe_with_listener()` returns a [`MessageListenerHandle`], not the
47//! consumer, so there is no consumer handle left to call `receive()` on. The
48//! listener owns delivery for the lifetime of the handle.
49//!
50//! ## Wrapper consumers (multi-topic / partitioned / pattern)
51//!
52//! The single-topic poller above is bound to [`crate::ConsumerApi`], whose
53//! `receive()` yields a bare [`magnetar_proto::IncomingMessage`]. The wrapper
54//! consumers — [`crate::MultiTopicsConsumer`], [`crate::PartitionedConsumer`],
55//! [`crate::PatternConsumer`] — are **not** `ConsumerApi`: their `receive()`
56//! returns a topic-tagged wrapper message ([`crate::MultiTopicsMessage`] /
57//! [`crate::PatternMessage`]) because a message's originating topic matters once
58//! the consumer fans across many topics (the callback must know which child to
59//! ack against). They get a second poller, [`spawn_wrapper_message_listener`],
60//! generic over the [`WrapperReceiver`] trait (an `async fn receive()` yielding a
61//! topic + message). It preserves the exact same ADR-0064 semantics —
62//! sequential, in order, no auto-ack, clean shutdown on `receive()` error / handle
63//! drop — and its callback shape is [`WrapperMessageListener`] = `Fn(&str,
64//! &IncomingMessage)` (the topic is the extra argument, mirroring Java
65//! `Message#getTopicName()`).
66//!
67//! **Pattern-child inheritance.** A [`crate::PatternConsumer`] discovers new
68//! topics after subscribe (on PIP-145 `TopicListChanged` deltas, applied by
69//! [`crate::PatternConsumer::update`]) and a [`crate::PartitionedConsumer`] can
70//! grow its child set via [`crate::MultiTopicsConsumer::add_topic`]; in both cases
71//! the children added later **inherit** the listener. The wrapper poller does not
72//! simply re-snapshot on the next call — a parked `receive()` over the old child
73//! set would never see a child added while it waits. Instead each poller iteration
74//! **races** the in-flight `receive()` against
75//! [`WrapperReceiver::membership_changed`] (a `Notify` the wrapper signals on every
76//! add): when a child joins while the poller is parked, the membership signal wins,
77//! the stale receive is dropped (cancel-safe — unpopped messages stay queued), and
78//! the next iteration re-snapshots and starts draining the new child. This matches
79//! Java, where `MultiTopicsConsumerImpl` owns the single listener executor and
80//! creates every child — initial or later-discovered — with its own
81//! `messageListener` set to `null` (`getInternalConsumerConfig`), routing all
82//! delivery through the parent's listener.
83//!
84//! ## `ConsumerEventListener` (issue #348, ADR-0081)
85//!
86//! A second, unrelated push surface lives in this module: [`ConsumerEvent`],
87//! [`ConsumerEventListener`], and [`spawn_consumer_event_listener`] mirror
88//! Java `ConsumerEventListener#becameActive` and `#becameInactive` — the
89//! Failover subscription active/standby callback, NOT message delivery. The
90//! shape is the same poller pattern as [`spawn_message_listener`]
91//! (`tokio::spawn`ed `loop { await; callback }`, no channel, engine-generic
92//! over `C: ConsumerApi + Clone`), driving
93//! [`crate::ConsumerApi::next_active_change`] instead of
94//! [`crate::ConsumerApi::receive`]. The builder-surface twin of
95//! `message_listener(...)` and `subscribe_with_listener()` is
96//! `ConsumerBuilder::consumer_event_listener(...)` together with
97//! `subscribe_with_event_listener()`, which moves the consumer into the
98//! event poller the same way — each `subscribe_with_*` terminal attaches
99//! only its own listener kind (mirroring how the plain `subscribe()`
100//! ignores a configured `message_listener`). A caller that wants both a
101//! message listener AND a consumer event listener on the same consumer
102//! subscribes once (pull-mode), clones the consumer, and passes one clone to
103//! each of [`spawn_message_listener`] and [`spawn_consumer_event_listener`]
104//! directly — the same "clone to run two independent loops over the same
105//! handle" pattern [`spawn_message_listener`]'s own doc recommends for the
106//! ack side-channel.
107
108use std::future::Future;
109use std::sync::Arc;
110
111use tokio::task::JoinHandle;
112
113use crate::client::{IncomingMessage, PulsarError};
114
115/// Callback fired for every message delivered to a push-mode consumer.
116///
117/// Receives the façade [`IncomingMessage`] (the same rich type returned by
118/// `Consumer::receive`, with `key()`, `property()`, `publish_time_ms()`, … ).
119/// The callback runs inside the poller task, sequentially — keep it from
120/// blocking the runtime for long, the way Java's listener-executor contract
121/// expects. The callback **must ack explicitly** (the poller does not auto-ack;
122/// it has no handle to the consumer's ack path once it has handed off the
123/// message). Mirrors Java `MessageListener#received(Consumer, Message)`, minus
124/// the consumer argument: hold a clone of your consumer in the closure to ack.
125pub type MessageListener = Arc<dyn Fn(&IncomingMessage) + Send + Sync>;
126
127/// Owns the background poller task driving a push-mode consumer. Mirrors the
128/// lifetime semantics of [`crate::TableView`]'s drain task: dropping the handle
129/// aborts the poller; [`Self::close`] awaits a clean stop.
130///
131/// The poller terminates on its own when an explicit or terminal remote close
132/// makes `receive()` return an error. Dropping this handle aborts the poller,
133/// then task unwinding drops its owned consumer clone. That clone triggers a
134/// best-effort close only when it is the final clone; if other consumer clones
135/// remain, this drop stages no close.
136pub struct MessageListenerHandle {
137 handle: tokio::sync::Mutex<Option<JoinHandle<()>>>,
138}
139
140impl std::fmt::Debug for MessageListenerHandle {
141 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142 let running = self
143 .handle
144 .try_lock()
145 .is_ok_and(|g| g.as_ref().is_some_and(|h| !h.is_finished()));
146 f.debug_struct("MessageListenerHandle")
147 .field("running", &running)
148 .finish()
149 }
150}
151
152impl Drop for MessageListenerHandle {
153 fn drop(&mut self) {
154 if let Ok(mut g) = self.handle.try_lock() {
155 if let Some(h) = g.take() {
156 h.abort();
157 }
158 }
159 }
160}
161
162impl MessageListenerHandle {
163 /// `true` while the poller task is still running. Flips to `false` once the
164 /// consumer is closed (the loop broke) or the handle has been
165 /// [`Self::close`]d / dropped.
166 #[must_use]
167 pub fn is_running(&self) -> bool {
168 self.handle
169 .try_lock()
170 .is_ok_and(|g| g.as_ref().is_some_and(|h| !h.is_finished()))
171 }
172
173 /// Stop the poller task and wait for it to unwind. Idempotent — a second
174 /// call (or a call after the consumer already closed the loop) is a no-op.
175 pub async fn close(&self) {
176 let mut g = self.handle.lock().await;
177 if let Some(h) = g.take() {
178 h.abort();
179 let _ = h.await;
180 }
181 }
182}
183
184/// Attach a push-delivery listener to an already-subscribed `consumer`,
185/// returning the owning [`MessageListenerHandle`]. The poller drives
186/// `consumer.receive()` and invokes `listener` once per message, sequentially
187/// and in order, with **no auto-ack** — the callback acks explicitly.
188///
189/// This is the lower-level, Java-faithful "attach a listener to this consumer"
190/// entry, paired with the higher-level
191/// [`crate::ConsumerBuilder::subscribe_with_listener`] convenience. Use it when
192/// the callback needs to ack: **clone the consumer first**, move one clone here
193/// to drive delivery and capture the other in the closure to ack (mirroring how
194/// Java's `MessageListener#received(Consumer, Message)` hands you the consumer).
195/// Because acking is async and the callback is a synchronous `Fn`, ack from the
196/// closure via a fire-and-forget grouped ack
197/// ([`crate::ConsumerBuilder::ack_group_time`] +
198/// `Consumer::ack_grouped`) or by spawning the `ack()` future.
199///
200/// Engine-generic: `C: ConsumerApi + Clone` resolves to
201/// `magnetar_runtime_tokio::Consumer` or
202/// `magnetar_runtime_moonpool::Consumer<P>`. The task is a bare
203/// `loop { receive(); callback }` — no channel, no extra lock, no host-clock
204/// read (ADR-0003 / ADR-0011 / ADR-0038 all preserved). The loop breaks the
205/// first time an explicit or terminal remote close makes `receive()` return an
206/// error. Dropping the returned handle instead aborts the poller and drops this
207/// task's owned consumer clone; only a final clone triggers the best-effort
208/// consumer close.
209pub fn spawn_message_listener<C: crate::ConsumerApi + Clone>(
210 consumer: C,
211 listener: MessageListener,
212) -> MessageListenerHandle {
213 // Hand the façade message to the callback. The callback acks explicitly —
214 // the poller deliberately does NOT ack (Java parity).
215 spawn_listener_loop(consumer, move |msg| {
216 let msg: IncomingMessage = msg.into();
217 listener(&msg);
218 })
219}
220
221/// Core sequential poller shared by the raw and schema-aware listeners. Drives
222/// `consumer.receive()` and runs `on_message` to completion before pulling the
223/// next entry, preserving order and never overlapping two callback
224/// invocations. `on_message` receives the runtime's
225/// `magnetar_proto::IncomingMessage`; the raw / typed wrappers adapt it to
226/// their own callback shape. The loop breaks the first time `receive()` errors
227/// (closed / terminally-disconnected consumer) for clean, panic-free shutdown.
228pub(crate) fn spawn_listener_loop<C, F>(consumer: C, on_message: F) -> MessageListenerHandle
229where
230 C: crate::ConsumerApi + Clone,
231 F: Fn(magnetar_proto::IncomingMessage) + Send + 'static,
232{
233 let join = tokio::spawn(async move {
234 loop {
235 let Ok(msg) = crate::ConsumerApi::receive(&consumer).await else {
236 // Consumer closed / connection terminally lost: stop cleanly.
237 break;
238 };
239 on_message(msg);
240 }
241 });
242 MessageListenerHandle {
243 handle: tokio::sync::Mutex::new(Some(join)),
244 }
245}
246
247/// Callback fired for every message delivered to a push-mode **wrapper**
248/// consumer ([`crate::MultiTopicsConsumer`], [`crate::PartitionedConsumer`],
249/// [`crate::PatternConsumer`]).
250///
251/// Unlike the single-topic [`MessageListener`], the callback receives the
252/// originating **topic** alongside the façade [`IncomingMessage`] — the wrapper
253/// consumer fans across many topics, so the callback needs the topic to route an
254/// explicit ack to the right child (e.g.
255/// [`crate::MultiTopicsConsumer::ack`] / [`crate::PatternConsumer::ack`], both of
256/// which take `(topic, message_id)`). Mirrors Java
257/// `MessageListener#received(Consumer, Message)` where `Message#getTopicName()`
258/// supplies the topic.
259///
260/// Same contract as [`MessageListener`]: runs inside the poller task,
261/// sequentially, and **must ack explicitly** — the poller never auto-acks.
262pub type WrapperMessageListener = Arc<dyn Fn(&str, &IncomingMessage) + Send + Sync>;
263
264/// A wrapper consumer's `receive()` surface, abstracted for the wrapper poller.
265///
266/// Implemented by [`crate::MultiTopicsConsumer`] (hence
267/// [`crate::PartitionedConsumer`], which is a type alias) and
268/// [`crate::PatternConsumer`]. Each one's `receive()` yields a topic-tagged
269/// wrapper message; this trait normalises that to `(topic, message)` so one
270/// poller serves all three surfaces, exactly as the single-topic `spawn_listener_loop` serves
271/// every `ConsumerApi`.
272///
273/// `Clone + Send + 'static` so the poller can move the receiver into a
274/// [`tokio::spawn`]ed task (the wrapper consumers are cheap `Arc`-clones).
275pub trait WrapperReceiver: Clone + Send + Sync + 'static {
276 /// Receive the next message across the wrapper's current child set, returning
277 /// the originating topic and the message. A terminal error (every child closed
278 /// / disconnected) breaks the poller loop for a clean shutdown — the same
279 /// signal `ConsumerApi::receive` gives the single-topic poller. On an empty set
280 /// the wrapper `receive()` errors immediately; the poller does not treat that
281 /// as terminal (see [`Self::is_empty`]) — it parks on [`Self::membership_changed`].
282 fn wrapper_receive(
283 &self,
284 ) -> impl Future<Output = Result<(String, magnetar_proto::IncomingMessage), PulsarError>> + Send;
285
286 /// `true` when the wrapper currently holds no child consumers (e.g. a pattern
287 /// consumer whose pattern matched nothing yet). The poller parks on
288 /// [`Self::membership_changed`] rather than spinning on the empty-set error.
289 fn is_empty(&self) -> bool;
290
291 /// Resolves when a child consumer is added to the set after this future was
292 /// created. The poller races its in-flight [`Self::wrapper_receive`] against
293 /// this so a child discovered *after* the poller parked (pattern
294 /// `TopicListChanged` deltas, partition growth) is swept on the next iteration:
295 /// when this wins, the poller drops the stale receive (cancel-safe — unpopped
296 /// messages stay queued) and re-snapshots. No channel (ADR-0003); the
297 /// underlying `Notify` stores one permit so an add that races a wait is not lost.
298 fn membership_changed(&self) -> impl Future<Output = ()> + Send;
299}
300
301/// Event surfaced by a push-delivery [`ConsumerEventListener`] (issue #348).
302/// Mirrors Java `ConsumerEventListener#becameActive(Consumer, int)` /
303/// `becameInactive(Consumer, int)` — the Failover subscription
304/// active-consumer transitions. magnetar drops the `partitionId` argument
305/// (single-topic consumers only; a partitioned consumer's per-partition
306/// event listener is attached per child, so the topic/partition is already
307/// implicit in which consumer's listener fired).
308#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
309pub enum ConsumerEvent {
310 /// This consumer was promoted to the active Failover consumer.
311 BecameActive,
312 /// This consumer was demoted to Failover stand-by.
313 BecameInactive,
314}
315
316/// Callback fired for every Failover active/standby transition observed by a
317/// push-mode consumer event listener. Mirrors [`MessageListener`]'s shape —
318/// a **synchronous** callback, invoked sequentially from the poller task.
319/// Mirrors Java `ConsumerEventListener#becameActive` /
320/// `#becameInactive`, collapsed into one callback taking [`ConsumerEvent`]
321/// (no consumer/partition argument — hold a clone of your consumer in the
322/// closure if you need to act on it, the same convention
323/// [`MessageListener`] uses).
324pub type ConsumerEventListener = Arc<dyn Fn(ConsumerEvent) + Send + Sync>;
325
326/// Owns the background poller task driving a [`ConsumerEventListener`].
327/// Structurally identical to [`MessageListenerHandle`] — dropping the handle
328/// aborts the poller; [`Self::close`] awaits a clean stop.
329///
330/// The poller terminates on its own when an explicit or terminal remote
331/// close makes [`crate::ConsumerApi::next_active_change`] return an error.
332pub struct ConsumerEventListenerHandle {
333 handle: tokio::sync::Mutex<Option<JoinHandle<()>>>,
334}
335
336impl std::fmt::Debug for ConsumerEventListenerHandle {
337 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
338 let running = self
339 .handle
340 .try_lock()
341 .is_ok_and(|g| g.as_ref().is_some_and(|h| !h.is_finished()));
342 f.debug_struct("ConsumerEventListenerHandle")
343 .field("running", &running)
344 .finish()
345 }
346}
347
348impl Drop for ConsumerEventListenerHandle {
349 fn drop(&mut self) {
350 if let Ok(mut g) = self.handle.try_lock() {
351 if let Some(h) = g.take() {
352 h.abort();
353 }
354 }
355 }
356}
357
358impl ConsumerEventListenerHandle {
359 /// `true` while the poller task is still running. Flips to `false` once
360 /// the consumer reaches a terminal state (the loop broke) or the handle
361 /// has been [`Self::close`]d / dropped.
362 #[must_use]
363 pub fn is_running(&self) -> bool {
364 self.handle
365 .try_lock()
366 .is_ok_and(|g| g.as_ref().is_some_and(|h| !h.is_finished()))
367 }
368
369 /// Stop the poller task and wait for it to unwind. Idempotent — a second
370 /// call (or a call after the consumer already terminated the loop) is a
371 /// no-op.
372 pub async fn close(&self) {
373 let mut g = self.handle.lock().await;
374 if let Some(h) = g.take() {
375 h.abort();
376 let _ = h.await;
377 }
378 }
379}
380
381/// Attach a [`ConsumerEventListener`] to an already-subscribed `consumer`,
382/// returning the owning [`ConsumerEventListenerHandle`]. The poller drives
383/// `consumer.next_active_change()` in a loop and invokes `listener` once per
384/// transition — [`ConsumerEvent::BecameActive`] for `Ok(true)`,
385/// [`ConsumerEvent::BecameInactive`] for `Ok(false)` — stopping cleanly the
386/// first time the future resolves `Err` (closed / terminally-disconnected
387/// consumer).
388///
389/// Engine-generic: `C: ConsumerApi + Clone` resolves to
390/// `magnetar_runtime_tokio::Consumer` or `magnetar_runtime_moonpool::Consumer<P>`,
391/// exactly like [`spawn_message_listener`]. No channel (ADR-0003), no extra
392/// lock (ADR-0038 — the loop takes only what `next_active_change()` already
393/// takes), no host-clock read (ADR-0011). The callback runs **only** inside
394/// this detached poller task, never under any lock.
395pub fn spawn_consumer_event_listener<C: crate::ConsumerApi + Clone>(
396 consumer: C,
397 listener: ConsumerEventListener,
398) -> ConsumerEventListenerHandle {
399 let join = tokio::spawn(async move {
400 loop {
401 let Ok(active) = crate::ConsumerApi::next_active_change(&consumer).await else {
402 // Consumer closed / connection terminally lost: stop cleanly.
403 break;
404 };
405 listener(if active {
406 ConsumerEvent::BecameActive
407 } else {
408 ConsumerEvent::BecameInactive
409 });
410 }
411 });
412 ConsumerEventListenerHandle {
413 handle: tokio::sync::Mutex::new(Some(join)),
414 }
415}
416
417/// Spawn a push-delivery poller over a wrapper consumer, returning the owning
418/// [`MessageListenerHandle`]. The poller drives `receiver.wrapper_receive()` and
419/// invokes `listener(topic, &msg)` once per message, sequentially and in order,
420/// with **no auto-ack** — the callback acks explicitly via the wrapper's
421/// topic-routed ack (`ack(topic, id)`).
422///
423/// This is the wrapper-surface sibling of [`spawn_message_listener`]. The loop is
424/// the same bare `loop { receive(); callback }` shape — no channel (ADR-0003), no
425/// extra lock (ADR-0038), no host-clock read (ADR-0011) — and breaks the first
426/// time `wrapper_receive()` errors (closed / empty consumer set) for clean,
427/// panic-free shutdown.
428///
429/// Children discovered after subscribe (pattern `TopicListChanged` deltas,
430/// partition growth) inherit the listener: each iteration the poller races its
431/// in-flight `wrapper_receive()` (over the *current* child snapshot) against
432/// [`WrapperReceiver::membership_changed`]. When a child is added while the poller
433/// is parked, the membership signal wins, the stale receive is dropped
434/// (cancel-safe — unpopped messages stay queued), and the next iteration
435/// re-snapshots and starts draining the new child. An empty wrapper (e.g. a
436/// pattern with no current match) parks on the membership signal instead of
437/// spinning on the empty-set error.
438pub fn spawn_wrapper_message_listener<R: WrapperReceiver>(
439 receiver: R,
440 listener: WrapperMessageListener,
441) -> MessageListenerHandle {
442 let join = tokio::spawn(async move {
443 loop {
444 // An empty set: the wrapper `receive()` errors immediately, which is
445 // NOT terminal here — wait for a child to join, then re-loop.
446 if receiver.is_empty() {
447 receiver.membership_changed().await;
448 continue;
449 }
450 // Race the in-flight receive against a membership change so a child
451 // added after we parked is picked up on the next iteration.
452 let outcome = tokio::select! {
453 biased;
454 r = receiver.wrapper_receive() => r,
455 () = receiver.membership_changed() => continue,
456 };
457 let Ok((topic, msg)) = outcome else {
458 // Every child closed / terminally disconnected: stop cleanly.
459 // (An empty-set error is handled above and never reaches here.)
460 break;
461 };
462 // Hand the façade message to the callback. The callback acks
463 // explicitly via the wrapper's topic-routed ack — the poller never
464 // acks (Java parity).
465 let msg: IncomingMessage = msg.into();
466 listener(&topic, &msg);
467 }
468 });
469 MessageListenerHandle {
470 handle: tokio::sync::Mutex::new(Some(join)),
471 }
472}
473
474#[cfg(test)]
475mod tests {
476 use std::sync::atomic::{AtomicUsize, Ordering};
477
478 use super::*;
479
480 /// The poller's "receive → callback → next" sequencing, in isolation.
481 /// We replicate the loop body against a synthetic message stream (the real
482 /// poller needs a live consumer, which needs a broker) and assert the
483 /// callback observes every message exactly once, in order. This pins the
484 /// load-bearing invariant the spawned task relies on: sequential,
485 /// in-order, no-skip delivery.
486 #[test]
487 fn listener_fires_sequentially_in_order() {
488 let order = Arc::new(parking_lot::Mutex::new(Vec::<u64>::new()));
489 let order_cb = order.clone();
490 let listener: MessageListener = Arc::new(move |msg: &IncomingMessage| {
491 order_cb.lock().push(msg.sequence_id());
492 });
493
494 // Drive the exact loop body the poller runs, over a fixed sequence.
495 for seq in 0..5u64 {
496 let md = magnetar_proto::pb::MessageMetadata {
497 sequence_id: seq,
498 ..Default::default()
499 };
500 let msg = IncomingMessage {
501 id: magnetar_proto::MessageId::EARLIEST,
502 metadata: Arc::new(md),
503 payload: bytes::Bytes::new(),
504 redelivery_count: 0,
505 broker_entry_metadata: None,
506 };
507 listener(&msg);
508 }
509
510 assert_eq!(*order.lock(), vec![0, 1, 2, 3, 4]);
511 }
512
513 /// The callback must run to completion before the next message is handed
514 /// to it — i.e. the poller never overlaps two callback invocations. We
515 /// assert this by counting concurrent entries via a guard that would
516 /// observe a value > 1 if delivery were parallel.
517 #[test]
518 fn listener_never_overlaps_invocations() {
519 let in_flight = Arc::new(AtomicUsize::new(0));
520 let max_seen = Arc::new(AtomicUsize::new(0));
521 let inflight_cb = in_flight.clone();
522 let max_cb = max_seen.clone();
523 let listener: MessageListener = Arc::new(move |_msg: &IncomingMessage| {
524 let now = inflight_cb.fetch_add(1, Ordering::SeqCst) + 1;
525 max_cb.fetch_max(now, Ordering::SeqCst);
526 inflight_cb.fetch_sub(1, Ordering::SeqCst);
527 });
528
529 for seq in 0..8u64 {
530 let md = magnetar_proto::pb::MessageMetadata {
531 sequence_id: seq,
532 ..Default::default()
533 };
534 let msg = IncomingMessage {
535 id: magnetar_proto::MessageId::EARLIEST,
536 metadata: Arc::new(md),
537 payload: bytes::Bytes::new(),
538 redelivery_count: 0,
539 broker_entry_metadata: None,
540 };
541 listener(&msg);
542 }
543
544 assert_eq!(
545 max_seen.load(Ordering::SeqCst),
546 1,
547 "sequential delivery never overlaps two callback invocations"
548 );
549 }
550
551 /// Synthetic [`WrapperReceiver`] for the wrapper-poller tests: a queue of
552 /// `(topic, message)` pairs plus a membership-change `Notify`. `wrapper_receive`
553 /// pops the next pair (parking on `delivered` when the queue is empty) so the
554 /// poller's empty-set / membership-race control flow can be exercised without a
555 /// broker. `is_empty` is driven by a flag the test flips, and `membership_changed`
556 /// resolves when the test signals a new child joined.
557 #[derive(Clone)]
558 struct MockWrapper {
559 queue: Arc<parking_lot::Mutex<std::collections::VecDeque<(String, u64)>>>,
560 /// Wakes a parked `wrapper_receive` when a message is pushed.
561 delivered: Arc<tokio::sync::Notify>,
562 /// Signalled when the test simulates a child being added.
563 membership: Arc<tokio::sync::Notify>,
564 /// Mirrors the wrapper's empty-child-set predicate.
565 empty: Arc<std::sync::atomic::AtomicBool>,
566 }
567
568 impl MockWrapper {
569 fn new(empty: bool) -> Self {
570 Self {
571 queue: Arc::new(parking_lot::Mutex::new(std::collections::VecDeque::new())),
572 delivered: Arc::new(tokio::sync::Notify::new()),
573 membership: Arc::new(tokio::sync::Notify::new()),
574 empty: Arc::new(std::sync::atomic::AtomicBool::new(empty)),
575 }
576 }
577
578 /// Push a message and wake a parked `wrapper_receive`.
579 fn push(&self, topic: &str, seq: u64) {
580 self.queue.lock().push_back((topic.to_owned(), seq));
581 self.delivered.notify_one();
582 }
583
584 /// Simulate a child joining the set: clear empty + signal membership.
585 fn add_child(&self) {
586 self.empty.store(false, std::sync::atomic::Ordering::SeqCst);
587 self.membership.notify_one();
588 }
589 }
590
591 fn mock_message(seq: u64) -> magnetar_proto::IncomingMessage {
592 magnetar_proto::IncomingMessage {
593 message_id: magnetar_proto::MessageId::EARLIEST,
594 metadata: Arc::new(magnetar_proto::pb::MessageMetadata {
595 sequence_id: seq,
596 ..Default::default()
597 }),
598 single_metadata: None,
599 payload: bytes::Bytes::new(),
600 redelivery_count: 0,
601 broker_entry_metadata: None,
602 arrived_at: std::time::Instant::now(),
603 }
604 }
605
606 impl WrapperReceiver for MockWrapper {
607 async fn wrapper_receive(
608 &self,
609 ) -> Result<(String, magnetar_proto::IncomingMessage), PulsarError> {
610 loop {
611 if let Some((topic, seq)) = self.queue.lock().pop_front() {
612 return Ok((topic, mock_message(seq)));
613 }
614 self.delivered.notified().await;
615 }
616 }
617
618 fn is_empty(&self) -> bool {
619 self.empty.load(std::sync::atomic::Ordering::SeqCst)
620 }
621
622 async fn membership_changed(&self) {
623 self.membership.notified().await;
624 }
625 }
626
627 /// The wrapper poller delivers every queued message, topic-tagged, in order.
628 #[tokio::test(flavor = "current_thread")]
629 async fn wrapper_poller_delivers_topic_tagged_in_order() {
630 let mock = MockWrapper::new(false);
631 let seen: Arc<parking_lot::Mutex<Vec<(String, u64)>>> =
632 Arc::new(parking_lot::Mutex::new(Vec::new()));
633 let seen_cb = seen.clone();
634 let done = Arc::new(tokio::sync::Notify::new());
635 let done_cb = done.clone();
636 let listener: WrapperMessageListener =
637 Arc::new(move |topic: &str, msg: &IncomingMessage| {
638 seen_cb.lock().push((topic.to_owned(), msg.sequence_id()));
639 if seen_cb.lock().len() == 3 {
640 done_cb.notify_one();
641 }
642 });
643
644 mock.push("t-a", 0);
645 mock.push("t-b", 1);
646 mock.push("t-a", 2);
647
648 let handle = spawn_wrapper_message_listener(mock, listener);
649 tokio::time::timeout(std::time::Duration::from_secs(5), done.notified())
650 .await
651 .expect("poller delivered all queued messages");
652 handle.close().await;
653
654 assert_eq!(
655 *seen.lock(),
656 vec![
657 ("t-a".to_owned(), 0),
658 ("t-b".to_owned(), 1),
659 ("t-a".to_owned(), 2),
660 ],
661 "wrapper poller delivered every message, topic-tagged, in order",
662 );
663 }
664
665 /// Inheritance: the poller starts with an EMPTY wrapper (no child yet),
666 /// parks on the membership signal rather than spinning on the empty-set error,
667 /// and once a child joins + produces, delivers that late child's message. This
668 /// is the deterministic core of the e2e pattern-inheritance assertion.
669 #[tokio::test(flavor = "current_thread")]
670 async fn wrapper_poller_inherits_late_added_child() {
671 let mock = MockWrapper::new(true); // starts empty
672 let seen: Arc<parking_lot::Mutex<Vec<(String, u64)>>> =
673 Arc::new(parking_lot::Mutex::new(Vec::new()));
674 let seen_cb = seen.clone();
675 let done = Arc::new(tokio::sync::Notify::new());
676 let done_cb = done.clone();
677 let listener: WrapperMessageListener =
678 Arc::new(move |topic: &str, msg: &IncomingMessage| {
679 seen_cb.lock().push((topic.to_owned(), msg.sequence_id()));
680 done_cb.notify_one();
681 });
682
683 let handle = spawn_wrapper_message_listener(mock.clone(), listener);
684
685 // Let the poller reach the empty-set park.
686 tokio::task::yield_now().await;
687 assert!(
688 seen.lock().is_empty(),
689 "nothing delivered while the set is empty"
690 );
691
692 // A late child joins and produces — the poller must pick it up via the
693 // membership signal + the delivery wake.
694 mock.add_child();
695 mock.push("late-topic", 7);
696
697 tokio::time::timeout(std::time::Duration::from_secs(5), done.notified())
698 .await
699 .expect("poller delivered the late-added child's message (inheritance)");
700 handle.close().await;
701
702 assert_eq!(
703 *seen.lock(),
704 vec![("late-topic".to_owned(), 7)],
705 "the late-added child's message reached the inherited listener",
706 );
707 }
708}