Skip to main content

magnetar/
multi_topics.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Multi-topics consumer — subscribes to N topics and merges their delivery streams.
4//!
5//! Mirrors Java's `MultiTopicsConsumerImpl`. The consumer is a thin coordinator over a
6//! `Vec<C>` (where `C: crate::ConsumerApi`) with `receive()` returning the first message
7//! ready across all underlying consumers. Cancelling the future leaves un-popped messages
8//! in their respective consumer queues — see the `cancel-safe` discussion in
9//! [`magnetar_runtime_tokio::Consumer::receive`].
10//!
11//! Engine genericity (pass-2)
12//! --------------------------
13//! [`MultiTopicsConsumer<C>`] is generic over `C: crate::ConsumerApi` so the same
14//! coordinator drives the tokio and moonpool runtimes. The companion
15//! [`MultiTopicsConsumerBuilder<'a, E>`] is generic over `E: crate::Engine` (default
16//! [`crate::TokioEngine`]) and routes `.subscribe()` through the engine-generic
17//! [`crate::ConsumerBuilder`] (which dispatches through
18//! [`crate::SubscribeApi`]) so each per-topic child uses the engine's concrete
19//! consumer type. Tokio-only methods (currently none) would live in
20//! `impl MultiTopicsConsumer<magnetar_runtime_tokio::Consumer>` carve-outs.
21//!
22//! Dynamic membership
23//! ------------------
24//! The consumer set is held under a [`parking_lot::Mutex`], so callers can subscribe new
25//! topics via [`MultiTopicsConsumer::add_topic`] and tear them down via
26//! [`MultiTopicsConsumer::remove_topic`] without rebuilding the consumer. New topics inherit
27//! every knob set on the original [`MultiTopicsConsumerBuilder`] (captured as a template
28//! inside [`Inner`], mirroring `PatternConsumer`). Mirrors Java
29//! `MultiTopicsConsumerImpl#subscribeAsync(String)` / `#unsubscribeAsync(String)`.
30//!
31//! No regex / pattern subscription (yet); callers pass an explicit topic list. Regex /
32//! pattern support layers on top via a broker-side topic-list-watcher (PIP-145), which is
33//! exposed by [`magnetar_proto::Connection`] but not wired through this facade — see
34//! [`crate::PatternConsumer`].
35
36use std::sync::Arc;
37use std::sync::atomic::Ordering;
38use std::time::Duration;
39
40use futures_util::future::select_all;
41use magnetar_proto::{IncomingMessage, MessageId};
42use parking_lot::Mutex;
43use tokio::sync::Notify;
44
45use crate::auto_update_task::{AutoUpdateTask, spawn_auto_update_task};
46use crate::client::{PulsarError, SeekTarget};
47use crate::consumer_template::ConsumerTemplate;
48use crate::{ConsumerApi, Engine, PulsarClient, SubscribeApi};
49
50/// Multi-topics consumer. Each contained consumer subscribes to one topic; `receive()`
51/// returns the next message across the whole set.
52///
53/// Generic over `C: crate::ConsumerApi` — the default is the tokio runtime's
54/// `Consumer`. The companion [`MultiTopicsConsumerBuilder<'a, E>`] selects the
55/// engine and produces a `MultiTopicsConsumer<<E::ClientState as
56/// SubscribeApi>::Consumer>` on `.subscribe()`.
57#[derive(Debug)]
58pub struct MultiTopicsConsumer<C: ConsumerApi = magnetar_runtime_tokio::Consumer> {
59    inner: Arc<Inner<C>>,
60}
61
62#[derive(Debug)]
63struct Inner<C: ConsumerApi> {
64    /// Active consumer set. Held under a mutex so [`MultiTopicsConsumer::add_topic`] /
65    /// [`MultiTopicsConsumer::remove_topic`] can mutate the set without rebuilding the
66    /// consumer. The Vec is wrapped in an `Arc` so the many read-heavy callers
67    /// (`receive`, `seek_*`, `unsubscribe`, `last_message_ids`, stats aggregators)
68    /// snapshot via a cheap `Arc::clone` instead of a deep `Vec::clone`; writers use
69    /// `Arc::make_mut` to copy-on-write the Vec. The mutex is never held across
70    /// `.await` — readers drop the guard immediately after capturing the Arc.
71    consumers: Mutex<Arc<Vec<NamedConsumer<C>>>>,
72    /// Signalled whenever a child consumer is added to the set
73    /// ([`MultiTopicsConsumer::add_topic`]). The push-delivery poller
74    /// ([`crate::consumer_listener::spawn_wrapper_message_listener`]) races its
75    /// in-flight `receive()` against this so a child discovered *after* the poller
76    /// parked is swept on the next iteration (pattern-child / partition-growth
77    /// inheritance, ADR-0064). `Notify` (not a channel) keeps ADR-0003 intact; it
78    /// stores one permit, so a signal that arrives between two waits is not lost.
79    membership_changed: Arc<Notify>,
80    /// Round-robin cursor used by `receive` to record the index of the topic that produced
81    /// the last message. Wrapped in a Mutex because [`MultiTopicsConsumer`] is `&self` —
82    /// cloning the handle should not require mutable access.
83    cursor: std::sync::atomic::AtomicUsize,
84    /// Template for subscribing newly-added topics. Captures every
85    /// [`crate::ConsumerBuilder`] knob the user set on the original
86    /// [`MultiTopicsConsumerBuilder`].
87    template: ConsumerTemplate,
88    /// Optional background partition-watcher task. `Some` when the builder
89    /// configured
90    /// [`MultiTopicsConsumerBuilder::auto_update_partitions_interval`], `None`
91    /// otherwise (default). Tracks one watched topic — the "base" topic used by a
92    /// [`crate::PartitionedConsumer`] (the underlying surface). Aborts on drop.
93    ///
94    /// The task uses [`tokio::task::JoinHandle`] + [`tokio::sync::Notify`]
95    /// directly — both engines (`TokioEngine`, `MoonpoolEngine<P>`) schedule
96    /// onto tokio under the hood (see `Engine::TaskHandle = tokio::task::JoinHandle`
97    /// in `engine.rs`), so this scaffolding is engine-invariant.
98    auto_update: Option<Arc<AutoUpdateTask>>,
99}
100
101#[derive(Debug, Clone)]
102struct NamedConsumer<C: ConsumerApi> {
103    topic: String,
104    consumer: C,
105}
106
107async fn republish_snapshot<T, E, F, Fut, Topic>(
108    members: &Mutex<Arc<Vec<T>>>,
109    topic: Topic,
110    mut republish: F,
111) -> Result<usize, PulsarError>
112where
113    T: Clone,
114    E: std::fmt::Display,
115    F: FnMut(T) -> Fut,
116    Fut: std::future::Future<Output = Result<usize, E>>,
117    Topic: Fn(&T) -> &str,
118{
119    let snapshot = members.lock().clone();
120    let mut republished = 0usize;
121    for child in snapshot.iter().cloned() {
122        let child_topic = topic(&child).to_owned();
123        let count = republish(child).await.map_err(|error| {
124            PulsarError::Other(format!(
125                "republish_dead_letters for topic {child_topic}: {error}"
126            ))
127        })?;
128        republished = republished.saturating_add(count);
129    }
130    Ok(republished)
131}
132
133/// A message yielded by [`MultiTopicsConsumer::receive`], carrying the topic it came from.
134#[derive(Debug)]
135pub struct MultiTopicsMessage {
136    /// The topic the message originated from (the same string the caller supplied to the
137    /// builder).
138    pub topic: String,
139    /// Underlying message + payload.
140    pub message: IncomingMessage,
141}
142
143impl<C: ConsumerApi + Clone> MultiTopicsConsumer<C> {
144    /// Topics this consumer is currently subscribed to, in the order they were added (initial
145    /// builder order followed by [`Self::add_topic`] insertions, minus any topic removed via
146    /// [`Self::remove_topic`]).
147    #[must_use]
148    pub fn topics(&self) -> Vec<String> {
149        self.inner
150            .consumers
151            .lock()
152            .iter()
153            .map(|c| c.topic.clone())
154            .collect()
155    }
156
157    /// Number of underlying consumers (one per topic).
158    #[must_use]
159    pub fn len(&self) -> usize {
160        self.inner.consumers.lock().len()
161    }
162
163    /// `true` if the consumer set is currently empty (e.g. every topic has been removed).
164    #[must_use]
165    pub fn is_empty(&self) -> bool {
166        self.inner.consumers.lock().is_empty()
167    }
168
169    /// Shared subscription name across every per-topic child. Mirrors Java
170    /// `Consumer#getSubscription` at the multi-topic / partitioned scope.
171    #[must_use]
172    pub fn subscription(&self) -> &str {
173        &self.inner.template.subscription
174    }
175
176    /// Subscribe a new per-topic child against the current consumer set. The new child
177    /// inherits every knob configured on the original [`MultiTopicsConsumerBuilder`].
178    /// Mirrors Java `MultiTopicsConsumerImpl#subscribeAsync(String topicName)`.
179    ///
180    /// Idempotent: if `topic` is already in the set the call is a no-op and returns `Ok(())`
181    /// — mirrors Java's behaviour of refusing to double-subscribe the same topic.
182    ///
183    /// # Errors
184    ///
185    /// Returns the underlying subscribe error if the broker refuses the new subscription.
186    /// The consumer set is left untouched on error.
187    pub async fn add_topic<E>(
188        &self,
189        client: &PulsarClient<E>,
190        topic: impl Into<String>,
191    ) -> Result<(), PulsarError>
192    where
193        E: Engine,
194        E::ClientState: SubscribeApi<Consumer = C>,
195    {
196        let topic = topic.into();
197        // Check membership under the lock and release before awaiting — never hold the
198        // mutex across an `.await`.
199        let already_subscribed = self
200            .inner
201            .consumers
202            .lock()
203            .iter()
204            .any(|nc| nc.topic == topic);
205        if already_subscribed {
206            return Ok(());
207        }
208        let builder = self.inner.template.apply(client.consumer(topic.clone()));
209        let consumer = builder.subscribe().await?;
210        // Re-check membership under the lock to handle a concurrent `add_topic(topic)` —
211        // if a peer raced us, drop our newly-subscribed consumer and close it; otherwise
212        // push it. The guard is released before any `.await`
213        // (clippy::await_holding_lock).
214        let to_close: Option<C> = {
215            let mut guard = self.inner.consumers.lock();
216            if guard.iter().any(|nc| nc.topic == topic) {
217                Some(consumer)
218            } else {
219                // Copy-on-write the Vec inside the Arc so concurrent
220                // readers holding an `Arc::clone` snapshot keep their
221                // view; only the writer sees the new push.
222                Arc::make_mut(&mut guard).push(NamedConsumer { topic, consumer });
223                None
224            }
225        };
226        if let Some(c) = to_close {
227            let _ = ConsumerApi::close_owned(c).await;
228        } else {
229            // A new child joined the set — wake any parked wrapper-listener
230            // poller so it re-snapshots and starts draining the new child.
231            self.inner.membership_changed.notify_one();
232        }
233        Ok(())
234    }
235
236    /// Tear down the per-topic child subscribed to `topic` and remove it from the set.
237    /// Mirrors Java `MultiTopicsConsumerImpl#unsubscribeAsync(String topicName)`.
238    ///
239    /// No-op if `topic` is not currently in the set.
240    ///
241    /// # Errors
242    ///
243    /// Returns the underlying close error from the per-topic consumer.
244    pub async fn remove_topic(&self, topic: &str) -> Result<(), PulsarError> {
245        // Remove under the lock, release, then close — never hold the mutex across await.
246        let removed: Option<NamedConsumer<C>> = {
247            let mut guard = self.inner.consumers.lock();
248            let pos = guard.iter().position(|nc| nc.topic == topic);
249            pos.map(|pos| Arc::make_mut(&mut guard).remove(pos))
250        };
251        if let Some(nc) = removed {
252            ConsumerApi::close_owned(nc.consumer)
253                .await
254                .map_err(|e| PulsarError::Other(format!("remove_topic close: {e}")))?;
255        }
256        Ok(())
257    }
258
259    /// Negatively acknowledge a message. The caller supplies the topic the message came
260    /// from (returned alongside the message in [`MultiTopicsMessage::topic`]) so the nack
261    /// goes to the correct per-topic consumer.
262    pub fn negative_ack(&self, topic: &str, message_id: MessageId) -> Result<(), PulsarError> {
263        let consumer = self
264            .lookup(topic)
265            .map_err(|err| PulsarError::Config(format!("negative_ack: {err}")))?;
266        consumer.negative_ack(message_id);
267        Ok(())
268    }
269
270    /// Negatively acknowledge with an explicit per-message redelivery delay. Mirrors
271    /// Java's PIP-37 backoff path at the multi-topic / partitioned scope. The caller
272    /// supplies the topic the message came from so the nack routes to the correct child.
273    pub fn negative_ack_with_delay(
274        &self,
275        topic: &str,
276        message_id: MessageId,
277        delay: std::time::Duration,
278    ) -> Result<(), PulsarError> {
279        let consumer = self
280            .lookup(topic)
281            .map_err(|err| PulsarError::Config(format!("negative_ack_with_delay: {err}")))?;
282        consumer.negative_ack_with_delay(message_id, delay);
283        Ok(())
284    }
285
286    /// Cumulative ack. The caller supplies the topic the message came from so the ack
287    /// routes to the correct child. Mirrors Java
288    /// `Consumer#acknowledgeCumulativeAsync(MessageId)` at the multi-topic scope.
289    pub async fn ack_cumulative(
290        &self,
291        topic: &str,
292        message_id: MessageId,
293    ) -> Result<(), PulsarError> {
294        let consumer = self
295            .lookup(topic)
296            .map_err(|err| PulsarError::Config(format!("ack_cumulative: {err}")))?;
297        consumer
298            .ack_cumulative(message_id)
299            .await
300            .map_err(|e| PulsarError::Other(format!("ack_cumulative: {e}")))
301    }
302
303    /// Fire-and-forget ack into the per-topic child's ack-grouping tracker (opt-in via
304    /// `MultiTopicsConsumerBuilder::ack_group_time`). The caller supplies the topic the
305    /// message came from so the ack routes to the correct child. See
306    /// [`magnetar_runtime_tokio::Consumer::ack_grouped`].
307    pub fn ack_grouped(&self, topic: &str, message_id: MessageId) -> Result<(), PulsarError> {
308        let consumer = self
309            .lookup(topic)
310            .map_err(|err| PulsarError::Config(format!("ack_grouped: {err}")))?;
311        consumer.ack_grouped(message_id);
312        Ok(())
313    }
314
315    /// Fire-and-forget cumulative ack into the per-topic child's ack-grouping tracker.
316    /// See [`Self::ack_grouped`] for the routing semantics.
317    pub fn ack_grouped_cumulative(
318        &self,
319        topic: &str,
320        message_id: MessageId,
321    ) -> Result<(), PulsarError> {
322        let consumer = self
323            .lookup(topic)
324            .map_err(|err| PulsarError::Config(format!("ack_grouped_cumulative: {err}")))?;
325        consumer.ack_grouped_cumulative(message_id);
326        Ok(())
327    }
328
329    /// Republish `msg` via `retry_producer` with a delay, then ack the original on the
330    /// per-topic child. Mirrors Java `Consumer#reconsumeLater` at the multi-topic scope.
331    /// The caller supplies the topic the message came from (returned alongside the
332    /// message in [`MultiTopicsMessage::topic`]) so the ack routes to the correct child.
333    pub async fn reconsume_later(
334        &self,
335        topic: &str,
336        retry_producer: &C::Producer,
337        msg: IncomingMessage,
338        delay: std::time::Duration,
339    ) -> Result<(), PulsarError> {
340        let consumer = self
341            .lookup(topic)
342            .map_err(|err| PulsarError::Config(format!("reconsume_later: {err}")))?;
343        consumer
344            .reconsume_later(retry_producer, msg, delay)
345            .await
346            .map_err(|e| PulsarError::Other(format!("reconsume_later: {e}")))
347    }
348
349    /// Same as [`Self::reconsume_later`] but stamps custom properties on the republished
350    /// message. Mirrors Java's properties-aware reconsumeLater overload.
351    pub async fn reconsume_later_with_properties(
352        &self,
353        topic: &str,
354        retry_producer: &C::Producer,
355        msg: IncomingMessage,
356        custom_properties: Vec<(String, String)>,
357        delay: std::time::Duration,
358    ) -> Result<(), PulsarError> {
359        let consumer = self.lookup(topic).map_err(|err| {
360            PulsarError::Config(format!("reconsume_later_with_properties: {err}"))
361        })?;
362        consumer
363            .reconsume_later_with_properties(retry_producer, msg, custom_properties, delay)
364            .await
365            .map_err(|e| PulsarError::Other(format!("reconsume_later_with_properties: {e}")))
366    }
367
368    /// Republish every child consumer's buffered dead letters through one shared
369    /// `dlq_producer` destination and return the saturating sum of republished messages.
370    ///
371    /// Each call snapshots membership independently when it starts. Children added later do
372    /// not enter that snapshot; removing a snapshotted child does not remove it from the
373    /// traversal, but [`Self::remove_topic`] may close the shared child handle and thereby
374    /// affect that child's operation. The collection lock is released before the first
375    /// `.await`, and children are processed sequentially in the snapshot's deterministic
376    /// vector/topic order. Each child delegates to
377    /// [`ConsumerApi::republish_dead_letters`], whose underlying operation confirms each
378    /// replacement publication before acknowledging the original message.
379    ///
380    /// Cancellation stops future child work and preserves children already completed.
381    /// Likewise, the first child error stops the operation immediately; prior successful
382    /// children are not rolled back. Concurrent calls are not serialized and race the
383    /// per-child runtime operations. Per-child counts and outcomes therefore follow the
384    /// runtime's existing destructive-drain behavior; this aggregate coordinator adds no
385    /// deduplication guarantee across calls. An empty snapshot returns `Ok(0)`.
386    pub async fn republish_dead_letters(
387        &self,
388        dlq_producer: &C::Producer,
389    ) -> Result<usize, PulsarError> {
390        republish_snapshot(
391            &self.inner.consumers,
392            |child| child.topic.as_str(),
393            |child| async move { child.consumer.republish_dead_letters(dlq_producer).await },
394        )
395        .await
396    }
397
398    /// Tell the broker to redeliver every unacked message across every child consumer.
399    /// Mirrors Java `Consumer#redeliverUnacknowledgedMessages` at the multi-topic scope.
400    pub fn redeliver_unacked(&self) {
401        for nc in self.inner.consumers.lock().iter() {
402            nc.consumer.redeliver_unacked();
403        }
404    }
405
406    /// Receive the next message across any subscribed topic. The future is cancel-safe:
407    /// dropping it without polling to completion leaves all unpopped messages in their
408    /// respective per-consumer queues.
409    pub async fn receive(&self) -> Result<MultiTopicsMessage, PulsarError> {
410        // Snapshot the consumer set under the lock and release before awaiting — holding
411        // the mutex across an await would serialise receive against add_topic /
412        // remove_topic.
413        let snapshot: Arc<Vec<NamedConsumer<C>>> = self.inner.consumers.lock().clone();
414        if snapshot.is_empty() {
415            return Err(PulsarError::Config(
416                "no topics subscribed to receive from".to_owned(),
417            ));
418        }
419        if snapshot.len() == 1 {
420            let nc = &snapshot[0];
421            let msg = nc
422                .consumer
423                .receive()
424                .await
425                .map_err(|e| PulsarError::Other(format!("receive: {e}")))?;
426            self.inner.cursor.store(0, Ordering::Relaxed);
427            return Ok(MultiTopicsMessage {
428                topic: nc.topic.clone(),
429                message: msg,
430            });
431        }
432
433        // F4: rotate the snapshot by the round-robin cursor BEFORE
434        // building the futures so `select_all` does not always favour
435        // index 0. Without the rotation a topic that always has a
436        // message ready starves every later topic — `select_all` returns
437        // the first ready future in input order. The cursor was being
438        // updated but never used. The rotation gives a "start sweep at
439        // cursor, wrap around" semantic that mirrors Java's
440        // `MultiTopicsConsumerImpl#internalReceiveAsync` round-robin.
441        let len = snapshot.len();
442        let start = self.inner.cursor.load(Ordering::Relaxed) % len;
443        // `rotated_indices[i] = (start + i) % len` — the order in which
444        // `select_all` sees the per-topic futures. We track both the
445        // futures and the original snapshot indices so a winning result
446        // maps back to the right topic in `snapshot`.
447        let rotated_indices: Vec<usize> = (0..len).map(|i| (start + i) % len).collect();
448        let futures: Vec<_> = rotated_indices
449            .iter()
450            .map(|&i| snapshot[i].consumer.receive())
451            .collect();
452        let (result, rotated_idx, _rest) = select_all(futures).await;
453        let original_idx = rotated_indices[rotated_idx];
454        let topic = snapshot[original_idx].topic.clone();
455        let message = result.map_err(|e| PulsarError::Other(format!("receive: {e}")))?;
456        // Advance the cursor past the topic we just served so the next
457        // `receive()` starts the sweep at the topic *after* this one.
458        self.inner
459            .cursor
460            .store((original_idx + 1) % len, Ordering::Relaxed);
461        Ok(MultiTopicsMessage { topic, message })
462    }
463
464    /// Acknowledge a message. The caller supplies the topic the message came from (returned
465    /// alongside the message in [`MultiTopicsMessage::topic`]) so we can route the ack to
466    /// the correct per-topic consumer.
467    pub async fn ack(&self, topic: &str, message_id: MessageId) -> Result<(), PulsarError> {
468        let consumer = self
469            .lookup(topic)
470            .map_err(|err| PulsarError::Config(format!("ack on multi-consumer: {err}")))?;
471        consumer
472            .ack(message_id)
473            .await
474            .map_err(|e| PulsarError::Other(format!("ack: {e}")))
475    }
476
477    /// `true` while every child consumer reports the underlying connection is up.
478    /// Mirrors Java `Consumer#isConnected` at the multi-topic / partitioned scope.
479    #[must_use]
480    pub fn is_connected(&self) -> bool {
481        let guard = self.inner.consumers.lock();
482        !guard.is_empty() && guard.iter().all(|c| c.consumer.is_connected())
483    }
484
485    /// Earliest disconnect wall-clock across all child consumers. `None` if no child has
486    /// ever disconnected.
487    #[must_use]
488    pub fn last_disconnected_timestamp(&self) -> Option<std::time::SystemTime> {
489        self.inner
490            .consumers
491            .lock()
492            .iter()
493            .filter_map(|c| c.consumer.last_disconnected_timestamp())
494            .min()
495    }
496
497    /// Aggregate cumulative stats across all child consumers (issue #347).
498    /// Thin wrapper over [`magnetar_proto::ConsumerStats::fold`] — collects
499    /// each child's `(stats(), receive_latency_histogram())` snapshot (taken
500    /// under the same lock acquisition so they're consistent with each
501    /// other) and folds them per that function's documented per-field rule:
502    /// the six cumulative totals + `pending_batch_acks` sum; `msgs_per_sec`
503    /// / `bytes_per_sec` sum as f64 (fan-in throughput); `receive_latency_max_ms`
504    /// is the exact max; `receive_latency_p50_ms` / `receive_latency_p99_ms`
505    /// are recomputed from a REAL merge of every child's receive-latency
506    /// histogram — summing or maxing percentiles directly (the previous
507    /// implementation's bug: those three fields, plus `msgs_per_sec` /
508    /// `bytes_per_sec` / `pending_batch_acks`, were silently left at their
509    /// `ConsumerStats::default()` zero) is not statistically sound.
510    ///
511    /// Applies equally to [`crate::PartitionedConsumer`] (a
512    /// `MultiTopicsConsumer` type alias) since it shares this
513    /// implementation.
514    ///
515    /// The two rate fields are populated by the client-wide sweep armed with
516    /// [`crate::ClientBuilder::stats_interval`], which reaches every child
517    /// because it ticks each slot on the connection rather than fanning out
518    /// from here (ADR-0089 — Java's `MultiTopicsConsumerImpl.getStats()` has no
519    /// fan-out either, and one clock ticking every child is what makes the f64
520    /// sum well-defined). With that knob unset they stay caller-driven and
521    /// therefore `0.0`; see
522    /// [`magnetar_proto::consumer::ConsumerState::record_rate_window`].
523    ///
524    /// A child added mid-window by [`Self::add_topic`] or by partition growth
525    /// is seeded at its own creation, so it contributes a full snapshot of its
526    /// counters immediately but `0.0` to the rate fields for its first full
527    /// interval — the window needs a baseline first. Java's recorders behave
528    /// identically.
529    #[must_use]
530    pub fn aggregate_stats(&self) -> magnetar_proto::ConsumerStats {
531        let children: Vec<_> = self
532            .inner
533            .consumers
534            .lock()
535            .iter()
536            .map(|nc| (nc.consumer.stats(), nc.consumer.receive_latency_histogram()))
537            .collect();
538        magnetar_proto::ConsumerStats::fold(children)
539    }
540
541    /// Sum of buffered messages across every child consumer's receiver queue. Mirrors
542    /// Java `Consumer#getNumMessagesInQueue` aggregated over partitions/topics.
543    #[must_use]
544    pub fn available_in_queue(&self) -> usize {
545        self.inner
546            .consumers
547            .lock()
548            .iter()
549            .map(|c| c.consumer.available_in_queue())
550            .sum()
551    }
552
553    /// Sum of outstanding broker permits across every child consumer. Mirrors Java
554    /// `ConsumerBase#getAvailablePermits` aggregated over partitions/topics.
555    ///
556    /// Each child reports the real decrementing balance since issue #414 (ADR-0101
557    /// amending ADR-0082), so the sum falls under dispatch instead of sitting pinned at
558    /// the children's combined receiver-queue size.
559    #[must_use]
560    pub fn available_permits(&self) -> u32 {
561        self.inner
562            .consumers
563            .lock()
564            .iter()
565            .map(|c| c.consumer.available_permits())
566            .fold(0u32, u32::saturating_add)
567    }
568
569    /// `true` if any child consumer has received at least one message. Mirrors Java
570    /// `Consumer#hasReceivedAnyMessage` at the multi-topic / partitioned scope.
571    #[must_use]
572    pub fn has_received_any_message(&self) -> bool {
573        self.inner
574            .consumers
575            .lock()
576            .iter()
577            .any(|c| c.consumer.has_received_any_message())
578    }
579
580    /// `true` once every child consumer is closed. Mirrors Java `Consumer#isClosed` at the
581    /// multi-topic / partitioned scope.
582    #[must_use]
583    pub fn is_closed(&self) -> bool {
584        let guard = self.inner.consumers.lock();
585        guard.iter().all(|c| c.consumer.is_closed())
586    }
587
588    /// Pause every child consumer. Mirrors Java `Consumer#pause` at the multi-topic scope.
589    pub fn pause(&self) {
590        for nc in self.inner.consumers.lock().iter() {
591            nc.consumer.pause();
592        }
593    }
594
595    /// Resume every child consumer.
596    pub fn resume(&self) {
597        for nc in self.inner.consumers.lock().iter() {
598            nc.consumer.resume();
599        }
600    }
601
602    /// `true` once every child consumer has reached end-of-topic. Mirrors Java
603    /// `Consumer#hasReachedEndOfTopic` at the multi-topic scope.
604    #[must_use]
605    pub fn has_reached_end_of_topic(&self) -> bool {
606        let guard = self.inner.consumers.lock();
607        !guard.is_empty() && guard.iter().all(|c| c.consumer.has_reached_end_of_topic())
608    }
609
610    /// Close every underlying consumer. Returns the first error encountered; the rest are
611    /// dropped (every child still gets a chance to close).
612    pub async fn close(self) -> Result<(), PulsarError> {
613        let inner = match Arc::try_unwrap(self.inner) {
614            Ok(i) => i,
615            Err(arc) => {
616                // Clones outlive us; nothing safe to close concurrently.
617                drop(arc);
618                return Ok(());
619            }
620        };
621        // Last Arc — `into_inner` on the Mutex yields the Arc, then we
622        // try to unwrap it. `Arc::try_unwrap` succeeds because the
623        // outer `arc` (the only other strong ref to `Inner`) has just
624        // been dropped or is about to be.
625        let consumers_arc = inner.consumers.into_inner();
626        let consumers = Arc::try_unwrap(consumers_arc).unwrap_or_else(|arc| (*arc).clone());
627        let mut first_err: Result<(), PulsarError> = Ok(());
628        for nc in consumers {
629            if let Err(e) = ConsumerApi::close_owned(nc.consumer).await
630                && first_err.is_ok()
631            {
632                first_err = Err(PulsarError::Other(format!("close: {e}")));
633            }
634        }
635        first_err
636    }
637
638    /// Unsubscribe every child subscription. Mirrors Java `Consumer#unsubscribe` at the
639    /// multi-topic / partitioned scope. Returns the first error encountered; the rest are
640    /// dropped (every child still gets a chance to issue its unsubscribe).
641    pub async fn unsubscribe(&self, force: bool) -> Result<(), PulsarError> {
642        // Snapshot under the lock and release before awaiting — never hold the mutex
643        // across an `.await`.
644        let snapshot: Arc<Vec<NamedConsumer<C>>> = self.inner.consumers.lock().clone();
645        let mut first_err: Result<(), PulsarError> = Ok(());
646        for nc in snapshot.iter() {
647            if let Err(e) = nc.consumer.unsubscribe(force).await
648                && first_err.is_ok()
649            {
650                first_err = Err(PulsarError::Other(format!("unsubscribe: {e}")));
651            }
652        }
653        first_err
654    }
655
656    /// Seek every child consumer to the given publish-time deadline. Mirrors Java
657    /// `Consumer#seek(long)` at the multi-topic scope.
658    pub async fn seek_to_timestamp(&self, publish_time_ms: u64) -> Result<(), PulsarError> {
659        let snapshot: Arc<Vec<NamedConsumer<C>>> = self.inner.consumers.lock().clone();
660        let mut first_err: Result<(), PulsarError> = Ok(());
661        for nc in snapshot.iter() {
662            if let Err(e) = nc.consumer.seek_to_timestamp(publish_time_ms).await
663                && first_err.is_ok()
664            {
665                first_err = Err(PulsarError::Other(format!("seek_to_timestamp: {e}")));
666            }
667        }
668        first_err
669    }
670
671    /// Seek every child consumer to the earliest message. Mirrors Java
672    /// `Consumer#seek(MessageId.earliest)` at the multi-topic scope.
673    pub async fn seek_to_earliest(&self) -> Result<(), PulsarError> {
674        let snapshot: Arc<Vec<NamedConsumer<C>>> = self.inner.consumers.lock().clone();
675        let mut first_err: Result<(), PulsarError> = Ok(());
676        for nc in snapshot.iter() {
677            if let Err(e) = nc.consumer.seek_to_earliest().await
678                && first_err.is_ok()
679            {
680                first_err = Err(PulsarError::Other(format!("seek_to_earliest: {e}")));
681            }
682        }
683        first_err
684    }
685
686    /// Seek every child consumer to the latest (head) position. Mirrors Java
687    /// `Consumer#seek(MessageId.latest)` at the multi-topic scope.
688    pub async fn seek_to_latest(&self) -> Result<(), PulsarError> {
689        let snapshot: Arc<Vec<NamedConsumer<C>>> = self.inner.consumers.lock().clone();
690        let mut first_err: Result<(), PulsarError> = Ok(());
691        for nc in snapshot.iter() {
692            if let Err(e) = nc.consumer.seek_to_latest().await
693                && first_err.is_ok()
694            {
695                first_err = Err(PulsarError::Other(format!("seek_to_latest: {e}")));
696            }
697        }
698        first_err
699    }
700
701    /// Seek every child consumer to a per-topic target computed by `f`. Mirrors Java's
702    /// `Consumer#seek(Function<String, Object>)` (where the function returns either a
703    /// `MessageId` or a `Long` publish-time millis-since-epoch).
704    ///
705    /// `f` is invoked synchronously per child, in the order supplied to the builder, with
706    /// the child's topic name (matching what `topics()` returns — for a
707    /// [`crate::PartitionedConsumer`] this is `<topic>-partition-N`). The returned
708    /// [`SeekTarget`] is then dispatched to the appropriate per-topic seek primitive.
709    ///
710    /// All children are attempted even if one fails; the first error encountered is
711    /// returned and subsequent errors are dropped (every child still gets a chance to
712    /// issue its seek). This matches the existing [`Self::seek_to_timestamp`] semantics.
713    pub async fn seek_per_partition<F>(&self, mut f: F) -> Result<(), PulsarError>
714    where
715        F: FnMut(&str) -> SeekTarget,
716    {
717        let snapshot: Arc<Vec<NamedConsumer<C>>> = self.inner.consumers.lock().clone();
718        let mut first_err: Result<(), PulsarError> = Ok(());
719        for nc in snapshot.iter() {
720            let target = f(nc.topic.as_str());
721            let res = match target {
722                SeekTarget::MessageId(id) => nc.consumer.seek_to_message(id).await,
723                SeekTarget::PublishTimeMs(ts) => nc.consumer.seek_to_timestamp(ts).await,
724            };
725            if let Err(e) = res
726                && first_err.is_ok()
727            {
728                first_err = Err(PulsarError::Other(format!("seek_per_partition: {e}")));
729            }
730        }
731        first_err
732    }
733
734    /// Ask the broker for each topic's last-published message id. Returns one `(topic, id)`
735    /// per child consumer, in the order they appear in the current consumer set. Mirrors
736    /// Java `Consumer#getLastMessageIds` for partitioned/multi-topic consumers.
737    pub async fn last_message_ids(&self) -> Result<Vec<(String, MessageId)>, PulsarError> {
738        let snapshot: Arc<Vec<NamedConsumer<C>>> = self.inner.consumers.lock().clone();
739        let mut out = Vec::with_capacity(snapshot.len());
740        for nc in snapshot.iter() {
741            let id = nc
742                .consumer
743                .last_message_id()
744                .await
745                .map_err(|e| PulsarError::Other(format!("last_message_id: {e}")))?;
746            out.push((nc.topic.clone(), id));
747        }
748        Ok(out)
749    }
750
751    fn lookup(&self, topic: &str) -> Result<C, String> {
752        self.inner
753            .consumers
754            .lock()
755            .iter()
756            .find(|c| c.topic == topic)
757            .map(|c| c.consumer.clone())
758            .ok_or_else(|| format!("unknown topic {topic} on multi-consumer"))
759    }
760
761    /// Returns `true` if a background partition-watcher was spawned for this
762    /// consumer (i.e.
763    /// [`MultiTopicsConsumerBuilder::auto_update_partitions_interval`] was set on
764    /// the builder, or the surface was opened as a
765    /// [`crate::PartitionedConsumer`] with
766    /// [`crate::PartitionedConsumerBuilder::auto_update_partitions_interval`]).
767    #[must_use]
768    pub fn has_auto_update_partitions(&self) -> bool {
769        self.inner.auto_update.is_some()
770    }
771
772    /// Most recent partition count observed by the background partition watcher.
773    /// `None` when no watcher was configured.
774    #[must_use]
775    pub fn observed_partitions(&self) -> Option<u32> {
776        self.inner
777            .auto_update
778            .as_ref()
779            .map(|t| t.observed_partitions.load(Ordering::Relaxed))
780    }
781
782    /// Monotonic count of partition-change events observed by the background
783    /// watcher. Returns `None` when no watcher was configured.
784    #[must_use]
785    pub fn partition_change_count(&self) -> Option<u64> {
786        self.inner
787            .auto_update
788            .as_ref()
789            .map(|t| t.change_count.load(Ordering::Relaxed))
790    }
791
792    /// `Arc<Notify>` signalled by the background partition-watcher on every timer
793    /// tick and on every observed partition-count change driven by
794    /// [`Self::refresh_partitions`]. Returns `None` when no watcher was
795    /// configured. Callers may `await` `notified()` on the returned handle to
796    /// react to ticks without polling [`Self::partition_change_count`].
797    #[must_use]
798    pub fn partitions_changed_notify(&self) -> Option<Arc<Notify>> {
799        self.inner.auto_update.as_ref().map(|t| t.changed.clone())
800    }
801
802    /// Query the broker for the current partition count of the topic this
803    /// consumer was opened against (the base topic, for a
804    /// [`crate::PartitionedConsumer`]), and update [`Self::observed_partitions`] /
805    /// [`Self::partition_change_count`] in place if the count differs from the
806    /// last observation.
807    ///
808    /// This is the user-driven half of the
809    /// [`MultiTopicsConsumerBuilder::auto_update_partitions_interval`] machinery.
810    /// Returns the freshly-observed count on success, or `Ok(None)` if no watcher
811    /// was configured.
812    ///
813    /// **Note**: this method only updates the observed count. It does *not*
814    /// itself subscribe to new per-partition topics that show up after creation —
815    /// the surface still subscribes to its initial set. Callers that need the
816    /// expanded set should add the new per-partition topics via
817    /// [`Self::add_topic`] in response to the signal.
818    ///
819    /// # Errors
820    ///
821    /// Surfaces [`PulsarError::Client`] when the broker metadata lookup fails.
822    pub async fn refresh_partitions<E>(
823        &self,
824        client: &PulsarClient<E>,
825    ) -> Result<Option<u32>, PulsarError>
826    where
827        E: Engine,
828        E::ClientState: crate::BrokerMetadataApi,
829    {
830        let Some(task) = self.inner.auto_update.as_ref() else {
831            return Ok(None);
832        };
833        let count = client.partitions_for_topic(&task.topic).await?;
834        // Atomic swap-then-compare: a real change (prev != count) bumps the
835        // change counter and wakes the notify. The Mutex previously held
836        // here served no compound invariant — just a single u32 store.
837        let prev = task.observed_partitions.swap(count, Ordering::Relaxed);
838        if prev != count {
839            task.change_count.fetch_add(1, Ordering::Relaxed);
840            task.changed.notify_waiters();
841        }
842        Ok(Some(count))
843    }
844}
845
846impl<C: ConsumerApi> Clone for MultiTopicsConsumer<C> {
847    fn clone(&self) -> Self {
848        Self {
849            inner: self.inner.clone(),
850        }
851    }
852}
853
854/// Push-delivery support: a [`MultiTopicsConsumer`] (and therefore a
855/// [`crate::PartitionedConsumer`], a type alias) drives the wrapper listener
856/// poller via its topic-fanning [`Self::receive`]. The `C: Clone` bound +
857/// `Send + 'static` let the poller move a cheap `Arc`-clone of the consumer into
858/// its [`tokio::spawn`]ed task. Topics added later via [`Self::add_topic`] (or by
859/// a [`crate::PartitionedConsumerBuilder`] partition refresh) are picked up
860/// automatically — `receive()` re-snapshots the child set every call.
861impl<C> crate::consumer_listener::WrapperReceiver for MultiTopicsConsumer<C>
862where
863    C: ConsumerApi + Clone + Send + Sync + 'static,
864{
865    async fn wrapper_receive(&self) -> Result<(String, IncomingMessage), PulsarError> {
866        let m = self.receive().await?;
867        Ok((m.topic, m.message))
868    }
869
870    fn is_empty(&self) -> bool {
871        MultiTopicsConsumer::is_empty(self)
872    }
873
874    async fn membership_changed(&self) {
875        let notify = self.inner.membership_changed.clone();
876        notify.notified().await;
877    }
878}
879
880/// Builder for [`MultiTopicsConsumer`]. Mirrors `org.apache.pulsar.client.api.ConsumerBuilder`
881/// at the multi-topic layer.
882///
883/// Generic over `E: crate::Engine` (default [`crate::TokioEngine`]). The
884/// `.subscribe()` method dispatches through the engine-generic
885/// [`crate::ConsumerBuilder`] (which routes through [`crate::SubscribeApi`]) so
886/// each per-topic child uses the engine's concrete consumer type. The returned
887/// [`MultiTopicsConsumer<C>`] has `C = <E::ClientState as
888/// SubscribeApi>::Consumer`.
889pub struct MultiTopicsConsumerBuilder<'a, E: Engine = crate::TokioEngine> {
890    client: &'a PulsarClient<E>,
891    topics: Vec<String>,
892    subscription: Option<String>,
893    consumer_name: Option<String>,
894    sub_type: magnetar_proto::pb::command_subscribe::SubType,
895    receiver_queue_size: usize,
896    /// Issue #301: pluggable receiver-queue policy applied to every child consumer.
897    receiver_queue_policy: Option<std::sync::Arc<dyn magnetar_proto::ReceiverQueuePolicy>>,
898    receiver_queue_adjust_interval: Option<std::time::Duration>,
899    initial_position: magnetar_proto::pb::command_subscribe::InitialPosition,
900    durable: bool,
901    properties: Vec<(String, String)>,
902    negative_ack_redelivery_delay: Option<std::time::Duration>,
903    ack_timeout: Option<std::time::Duration>,
904    ack_group_time: Option<std::time::Duration>,
905    dlq_policy: Option<(u32, Option<String>)>,
906    max_pending_chunked_message: Option<usize>,
907    auto_ack_oldest_chunked_message_on_queue_full: Option<bool>,
908    expire_time_of_incomplete_chunked_message: Option<std::time::Duration>,
909    read_compacted: bool,
910    priority_level: Option<i32>,
911    subscription_properties: Vec<(String, String)>,
912    key_shared: Option<magnetar_proto::KeySharedConfig>,
913    replicate_subscription_state: Option<bool>,
914    force_topic_creation: Option<bool>,
915    start_message_rollback_duration_sec: Option<u64>,
916    auto_update_partitions_interval: Option<Duration>,
917    /// Base topic recorded for the partition-watcher when this builder is driven by
918    /// [`crate::PartitionedConsumerBuilder`]. `None` for direct multi-topic use —
919    /// callers there pass the explicit topic list, so there is no single base topic
920    /// to watch.
921    auto_update_base_topic: Option<String>,
922    /// Optional push-delivery callback (Java `ConsumerBuilder#messageListener` at
923    /// the multi-topic scope). Set via [`Self::message_listener`] and subscribe via
924    /// [`Self::subscribe_with_listener`]; the plain [`Self::subscribe`] ignores it
925    /// and returns a pull-mode [`MultiTopicsConsumer`]. The callback receives the
926    /// originating topic (so it can route an explicit ack to the right child).
927    listener: Option<crate::consumer_listener::WrapperMessageListener>,
928}
929
930impl<E: Engine> std::fmt::Debug for MultiTopicsConsumerBuilder<'_, E> {
931    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
932        f.debug_struct("MultiTopicsConsumerBuilder")
933            .field("topics", &self.topics)
934            .field("subscription", &self.subscription)
935            .field("sub_type", &self.sub_type)
936            .finish_non_exhaustive()
937    }
938}
939
940impl<'a, E: Engine> MultiTopicsConsumerBuilder<'a, E> {
941    pub(crate) fn new(client: &'a PulsarClient<E>) -> Self {
942        Self {
943            client,
944            topics: Vec::new(),
945            subscription: None,
946            consumer_name: None,
947            sub_type: magnetar_proto::pb::command_subscribe::SubType::Exclusive,
948            receiver_queue_size: 1000,
949            receiver_queue_policy: None,
950            receiver_queue_adjust_interval: None,
951            initial_position: magnetar_proto::pb::command_subscribe::InitialPosition::Latest,
952            durable: true,
953            properties: Vec::new(),
954            negative_ack_redelivery_delay: None,
955            ack_timeout: None,
956            ack_group_time: None,
957            dlq_policy: None,
958            max_pending_chunked_message: None,
959            auto_ack_oldest_chunked_message_on_queue_full: None,
960            expire_time_of_incomplete_chunked_message: None,
961            read_compacted: false,
962            priority_level: None,
963            subscription_properties: Vec::new(),
964            key_shared: None,
965            replicate_subscription_state: None,
966            force_topic_creation: None,
967            start_message_rollback_duration_sec: None,
968            auto_update_partitions_interval: None,
969            auto_update_base_topic: None,
970            listener: None,
971        }
972    }
973
974    /// Test-support seam (`#[doc(hidden)]`, not part of the stable API):
975    /// `true` once a push-delivery listener has been set via
976    /// [`Self::message_listener`]. Lets the builder-surface guard test pin the
977    /// listener → field wiring without opening a real broker connection.
978    #[doc(hidden)]
979    #[must_use]
980    pub fn has_listener_for_test(&self) -> bool {
981        self.listener.is_some()
982    }
983
984    /// Test-support seam (`#[doc(hidden)]`): the consumer name this builder will
985    /// propagate to every per-topic child via
986    /// [`crate::consumer_template::ConsumerTemplate`]. Lets the builder-surface
987    /// guard test pin the `name()` → field plumbing without a broker.
988    #[doc(hidden)]
989    #[must_use]
990    pub fn consumer_name_for_test(&self) -> Option<&str> {
991        self.consumer_name.as_deref()
992    }
993
994    /// Register a push-delivery callback (Java `ConsumerBuilder#messageListener`
995    /// at the multi-topic scope). Once set, subscribe via
996    /// [`Self::subscribe_with_listener`] to start a background poller that drives
997    /// [`MultiTopicsConsumer::receive`] and hands every message to `listener`,
998    /// sequentially and in order. The callback receives the originating topic so
999    /// it can ack against the right child via
1000    /// [`MultiTopicsConsumer::ack`] / [`MultiTopicsConsumer::ack_grouped`].
1001    ///
1002    /// The plain [`Self::subscribe`] ignores the listener and returns a pull-mode
1003    /// consumer. Pull and push are mutually exclusive (Java parity): use
1004    /// `subscribe_with_listener` for push and never call `receive()`, or use
1005    /// `subscribe` for pull. The callback **must ack explicitly** — the poller
1006    /// never auto-acks.
1007    #[must_use]
1008    pub fn message_listener(
1009        mut self,
1010        listener: crate::consumer_listener::WrapperMessageListener,
1011    ) -> Self {
1012        self.listener = Some(listener);
1013        self
1014    }
1015
1016    /// Append a topic. Subscribing to the same topic twice yields two separate
1017    /// per-topic consumer sessions.
1018    #[must_use]
1019    pub fn topic(mut self, topic: impl Into<String>) -> Self {
1020        self.topics.push(topic.into());
1021        self
1022    }
1023
1024    /// Append multiple topics from any iterable.
1025    #[must_use]
1026    pub fn topics(mut self, topics: impl IntoIterator<Item = impl Into<String>>) -> Self {
1027        self.topics.extend(topics.into_iter().map(Into::into));
1028        self
1029    }
1030
1031    /// Mirrors `ConsumerBuilder::name`. The name is propagated verbatim to every
1032    /// per-topic child consumer (no per-topic suffix), so broker `topics stats`
1033    /// reports the same `consumerName` for each child — matching the Java client
1034    /// and making multi-instance consumers attributable. Default `None` lets the
1035    /// broker assign a name.
1036    #[must_use]
1037    pub fn name(mut self, name: impl Into<String>) -> Self {
1038        self.consumer_name = Some(name.into());
1039        self
1040    }
1041
1042    /// Test-support seam (`#[doc(hidden)]`): the bounded-chunk-reassembly knobs
1043    /// this builder will propagate to every per-topic child via
1044    /// [`crate::consumer_template::ConsumerTemplate`]. Lets the builder-surface
1045    /// guard test pin the setter → field plumbing without a broker.
1046    #[doc(hidden)]
1047    #[must_use]
1048    pub fn chunk_knobs_for_test(
1049        &self,
1050    ) -> (Option<usize>, Option<bool>, Option<std::time::Duration>) {
1051        (
1052            self.max_pending_chunked_message,
1053            self.auto_ack_oldest_chunked_message_on_queue_full,
1054            self.expire_time_of_incomplete_chunked_message,
1055        )
1056    }
1057
1058    /// Required: set the subscription name.
1059    #[must_use]
1060    pub fn subscription(mut self, name: impl Into<String>) -> Self {
1061        self.subscription = Some(name.into());
1062        self
1063    }
1064
1065    /// Set the subscription type.
1066    #[must_use]
1067    pub fn subscription_type(
1068        mut self,
1069        sub_type: magnetar_proto::pb::command_subscribe::SubType,
1070    ) -> Self {
1071        self.sub_type = sub_type;
1072        self
1073    }
1074
1075    /// Set the receiver queue size per consumer.
1076    #[must_use]
1077    pub fn receiver_queue_size(mut self, size: usize) -> Self {
1078        self.receiver_queue_size = size;
1079        self.receiver_queue_policy = None;
1080        self.receiver_queue_adjust_interval = None;
1081        self
1082    }
1083
1084    /// Set a pluggable receiver-queue-size policy (issue #301) applied to every
1085    /// per-topic child. Pass [`magnetar_proto::Auto`] to let each partition's
1086    /// queue self-tune. Enabling a policy turns on auto-adjust with a default
1087    /// 5-second tick; override with [`Self::receiver_queue_adjust_interval`].
1088    #[must_use]
1089    pub fn receiver_queue_policy(
1090        mut self,
1091        policy: std::sync::Arc<dyn magnetar_proto::ReceiverQueuePolicy>,
1092    ) -> Self {
1093        self.receiver_queue_policy = Some(policy);
1094        if self.receiver_queue_adjust_interval.is_none() {
1095            self.receiver_queue_adjust_interval = Some(Duration::from_secs(5));
1096        }
1097        self
1098    }
1099
1100    /// Override the auto-adjust tick cadence for [`Self::receiver_queue_policy`].
1101    #[must_use]
1102    pub fn receiver_queue_adjust_interval(mut self, interval: Duration) -> Self {
1103        self.receiver_queue_adjust_interval = Some(interval);
1104        self
1105    }
1106
1107    /// Set the initial position.
1108    #[must_use]
1109    pub fn initial_position(
1110        mut self,
1111        position: magnetar_proto::pb::command_subscribe::InitialPosition,
1112    ) -> Self {
1113        self.initial_position = position;
1114        self
1115    }
1116
1117    /// Toggle durability of the underlying subscriptions.
1118    #[must_use]
1119    pub fn durable(mut self, durable: bool) -> Self {
1120        self.durable = durable;
1121        self
1122    }
1123
1124    /// Mirrors `ConsumerBuilder::property` — forwarded onto every per-topic child.
1125    #[must_use]
1126    pub fn property(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
1127        self.properties.push((key.into(), value.into()));
1128        self
1129    }
1130
1131    /// Mirrors `ConsumerBuilder::negative_ack_redelivery_delay`.
1132    #[must_use]
1133    pub fn negative_ack_redelivery_delay(mut self, delay: std::time::Duration) -> Self {
1134        self.negative_ack_redelivery_delay = Some(delay);
1135        self
1136    }
1137
1138    /// Mirrors `ConsumerBuilder::ack_timeout`.
1139    #[must_use]
1140    pub fn ack_timeout(mut self, timeout: std::time::Duration) -> Self {
1141        self.ack_timeout = Some(timeout);
1142        self
1143    }
1144
1145    /// Mirrors `ConsumerBuilder::ack_group_time`. Applied to every per-topic child.
1146    #[must_use]
1147    pub fn ack_group_time(mut self, window: std::time::Duration) -> Self {
1148        self.ack_group_time = Some(window);
1149        self
1150    }
1151
1152    /// Mirrors `ConsumerBuilder::dead_letter_policy`.
1153    #[must_use]
1154    pub fn dead_letter_policy(
1155        mut self,
1156        max_redeliver_count: u32,
1157        dead_letter_topic: Option<String>,
1158    ) -> Self {
1159        self.dlq_policy = Some((max_redeliver_count, dead_letter_topic));
1160        self
1161    }
1162
1163    /// Mirrors `ConsumerBuilder::max_pending_chunked_message`.
1164    #[must_use]
1165    pub fn max_pending_chunked_message(mut self, max: usize) -> Self {
1166        self.max_pending_chunked_message = Some(max);
1167        self
1168    }
1169
1170    /// Mirrors `ConsumerBuilder::auto_ack_oldest_chunked_message_on_queue_full`.
1171    #[must_use]
1172    pub fn auto_ack_oldest_chunked_message_on_queue_full(mut self, auto_ack: bool) -> Self {
1173        self.auto_ack_oldest_chunked_message_on_queue_full = Some(auto_ack);
1174        self
1175    }
1176
1177    /// Mirrors `ConsumerBuilder::expire_time_of_incomplete_chunked_message`.
1178    #[must_use]
1179    pub fn expire_time_of_incomplete_chunked_message(
1180        mut self,
1181        expire: std::time::Duration,
1182    ) -> Self {
1183        self.expire_time_of_incomplete_chunked_message = Some(expire);
1184        self
1185    }
1186
1187    /// Mirrors `ConsumerBuilder::read_compacted`.
1188    #[must_use]
1189    pub fn read_compacted(mut self, on: bool) -> Self {
1190        self.read_compacted = on;
1191        self
1192    }
1193
1194    /// Mirrors `ConsumerBuilder::priority_level`.
1195    #[must_use]
1196    pub fn priority_level(mut self, level: i32) -> Self {
1197        self.priority_level = Some(level);
1198        self
1199    }
1200
1201    /// Mirrors `ConsumerBuilder::subscription_property` — appends a (key, value) pair to
1202    /// every per-topic child's subscription metadata.
1203    #[must_use]
1204    pub fn subscription_property(
1205        mut self,
1206        key: impl Into<String>,
1207        value: impl Into<String>,
1208    ) -> Self {
1209        self.subscription_properties
1210            .push((key.into(), value.into()));
1211        self
1212    }
1213
1214    /// Mirrors `ConsumerBuilder::key_shared_policy`.
1215    #[must_use]
1216    pub fn key_shared_policy(mut self, cfg: magnetar_proto::KeySharedConfig) -> Self {
1217        self.key_shared = Some(cfg);
1218        self
1219    }
1220
1221    /// Mirrors `ConsumerBuilder::replicate_subscription_state`.
1222    #[must_use]
1223    pub fn replicate_subscription_state(mut self, on: bool) -> Self {
1224        self.replicate_subscription_state = Some(on);
1225        self
1226    }
1227
1228    /// Mirrors `ConsumerBuilder::force_topic_creation`.
1229    #[must_use]
1230    pub fn force_topic_creation(mut self, on: bool) -> Self {
1231        self.force_topic_creation = Some(on);
1232        self
1233    }
1234
1235    /// Mirrors `ConsumerBuilder::start_message_rollback_duration`.
1236    #[must_use]
1237    pub fn start_message_rollback_duration(mut self, seconds: u64) -> Self {
1238        self.start_message_rollback_duration_sec = Some(seconds);
1239        self
1240    }
1241
1242    /// Enable a background timer that signals every `interval`, intended to drive
1243    /// re-checks of the topic's partition count. Mirrors Java
1244    /// `ConsumerBuilder#autoUpdatePartitionsInterval`.
1245    ///
1246    /// The internal timer task signals
1247    /// [`MultiTopicsConsumer::partitions_changed_notify`] on every tick. Callers
1248    /// run [`MultiTopicsConsumer::refresh_partitions`] in response to the signal
1249    /// (or on their own cadence) to actually call
1250    /// [`PulsarClient::partitions_for_topic`].
1251    ///
1252    /// Default `None` — no timer is spawned. Pass a non-zero `Duration` to opt
1253    /// in. The timer is aborted when the [`MultiTopicsConsumer`] (and every
1254    /// clone) is dropped.
1255    ///
1256    /// Setting a zero `interval` is treated as "disable" — same as the default.
1257    ///
1258    /// **Note**: for direct multi-topic use, the watcher polls the *first* topic
1259    /// supplied to the builder (single watched topic is sufficient for the
1260    /// partitioned-consumer case which is the main use site).
1261    #[must_use]
1262    pub fn auto_update_partitions_interval(mut self, interval: Duration) -> Self {
1263        self.auto_update_partitions_interval = if interval.is_zero() {
1264            None
1265        } else {
1266            Some(interval)
1267        };
1268        self
1269    }
1270
1271    /// Record the base topic the partition watcher should poll. Called by
1272    /// [`crate::PartitionedConsumerBuilder::subscribe`] so the watcher polls the
1273    /// base topic (e.g. `persistent://t`) rather than the first partition topic
1274    /// (`persistent://t-partition-0`). Crate-internal — direct multi-topic users
1275    /// don't need this knob; the watcher falls back to the first topic.
1276    #[must_use]
1277    pub(crate) fn auto_update_base_topic(mut self, topic: String) -> Self {
1278        self.auto_update_base_topic = Some(topic);
1279        self
1280    }
1281}
1282
1283impl<E> MultiTopicsConsumerBuilder<'_, E>
1284where
1285    E: Engine,
1286    E::ClientState: SubscribeApi,
1287    <E::ClientState as SubscribeApi>::Consumer: Clone,
1288{
1289    /// Open every per-topic subscription concurrently. If any subscribe fails the others
1290    /// that already succeeded are torn down before the error is returned.
1291    pub async fn subscribe(
1292        self,
1293    ) -> Result<MultiTopicsConsumer<<E::ClientState as SubscribeApi>::Consumer>, PulsarError> {
1294        let mut deadline =
1295            crate::SubscribeApi::new_subscribe_operation_deadline(&self.client.inner);
1296        self.subscribe_with_deadline(&mut deadline).await
1297    }
1298
1299    pub(crate) async fn subscribe_with_deadline(
1300        self,
1301        deadline: &mut crate::OperationDeadline,
1302    ) -> Result<MultiTopicsConsumer<<E::ClientState as SubscribeApi>::Consumer>, PulsarError> {
1303        let subscription = self
1304            .subscription
1305            .ok_or_else(|| PulsarError::Config("subscription name is required".to_owned()))?;
1306        if self.topics.is_empty() {
1307            return Err(PulsarError::Config(
1308                "at least one topic is required".to_owned(),
1309            ));
1310        }
1311
1312        let template = ConsumerTemplate {
1313            subscription,
1314            consumer_name: self.consumer_name,
1315            sub_type: self.sub_type,
1316            receiver_queue_size: self.receiver_queue_size,
1317            receiver_queue_policy: self.receiver_queue_policy,
1318            receiver_queue_adjust_interval: self.receiver_queue_adjust_interval,
1319            initial_position: self.initial_position,
1320            durable: self.durable,
1321            properties: self.properties,
1322            negative_ack_redelivery_delay: self.negative_ack_redelivery_delay,
1323            ack_timeout: self.ack_timeout,
1324            ack_group_time: self.ack_group_time,
1325            dlq_policy: self.dlq_policy,
1326            max_pending_chunked_message: self.max_pending_chunked_message,
1327            auto_ack_oldest_chunked_message_on_queue_full: self
1328                .auto_ack_oldest_chunked_message_on_queue_full,
1329            expire_time_of_incomplete_chunked_message: self
1330                .expire_time_of_incomplete_chunked_message,
1331            read_compacted: self.read_compacted,
1332            priority_level: self.priority_level,
1333            subscription_properties: self.subscription_properties,
1334            key_shared: self.key_shared,
1335            replicate_subscription_state: self.replicate_subscription_state,
1336            force_topic_creation: self.force_topic_creation,
1337            start_message_rollback_duration_sec: self.start_message_rollback_duration_sec,
1338        };
1339
1340        // Subscribe sequentially — the first failure short-circuits, and on failure we close
1341        // the consumers we already opened.
1342        let mut consumers: Vec<NamedConsumer<<E::ClientState as SubscribeApi>::Consumer>> =
1343            Vec::with_capacity(self.topics.len());
1344        for topic in &self.topics {
1345            let builder = template.apply(self.client.consumer(topic.clone()));
1346            let result = builder.subscribe_with_deadline(deadline).await;
1347            match result {
1348                Ok(c) => consumers.push(NamedConsumer {
1349                    topic: topic.clone(),
1350                    consumer: c,
1351                }),
1352                Err(e) => {
1353                    // Best-effort teardown of previously-opened consumers.
1354                    for nc in consumers {
1355                        let _ = ConsumerApi::close_owned(nc.consumer).await;
1356                    }
1357                    return Err(e);
1358                }
1359            }
1360        }
1361
1362        // Spawn the partition-watcher timer iff the builder configured a non-zero
1363        // interval. The timer itself only emits ticks via `Notify`; callers drive
1364        // the actual `partitions_for_topic` call via
1365        // [`MultiTopicsConsumer::refresh_partitions`].
1366        let auto_update = self.auto_update_partitions_interval.map(|interval| {
1367            let watched_topic = self
1368                .auto_update_base_topic
1369                .unwrap_or_else(|| self.topics[0].clone());
1370            // We do not have an initial partition count for the direct multi-topic
1371            // case (each topic was passed explicitly); seed with 0 so the first
1372            // refresh always logs a change.
1373            spawn_auto_update_task(watched_topic, interval, 0)
1374        });
1375
1376        Ok(MultiTopicsConsumer {
1377            inner: Arc::new(Inner {
1378                consumers: Mutex::new(Arc::new(consumers)),
1379                membership_changed: Arc::new(Notify::new()),
1380                cursor: std::sync::atomic::AtomicUsize::new(0),
1381                template,
1382                auto_update,
1383            }),
1384        })
1385    }
1386}
1387
1388impl<E> MultiTopicsConsumerBuilder<'_, E>
1389where
1390    E: Engine,
1391    E::ClientState: SubscribeApi,
1392    <E::ClientState as SubscribeApi>::Consumer: Clone + Send + Sync + 'static,
1393{
1394    /// Subscribe every per-topic child and start a push-delivery poller over the
1395    /// resulting [`MultiTopicsConsumer`], returning the owning
1396    /// [`crate::MessageListenerHandle`]. Mirrors Java's
1397    /// `ConsumerBuilder#messageListener(...)` + `subscribe()` at the multi-topic /
1398    /// partitioned scope.
1399    ///
1400    /// The poller delivers messages sequentially and in order across every
1401    /// subscribed topic, handing the callback the originating topic and message,
1402    /// and does **not** auto-ack (the callback acks explicitly via
1403    /// [`MultiTopicsConsumer::ack`]). It stops cleanly when the consumer set is
1404    /// drained or the returned handle is dropped. Topics added later (via
1405    /// [`MultiTopicsConsumer::add_topic`]) are picked up automatically.
1406    ///
1407    /// Because the consumer is moved into the poller, there is no handle left to
1408    /// call `receive()` on — the listener owns delivery (Java's "no `receive()`
1409    /// with a `messageListener`" rule).
1410    ///
1411    /// # Errors
1412    /// - [`PulsarError::Config`] if no listener was set via [`Self::message_listener`].
1413    /// - any subscribe error from [`Self::subscribe`].
1414    pub async fn subscribe_with_listener(
1415        self,
1416    ) -> Result<crate::MessageListenerHandle, PulsarError> {
1417        let Some(listener) = self.listener.clone() else {
1418            return Err(PulsarError::Config(
1419                "subscribe_with_listener() requires a listener — \
1420                 call message_listener(...) first (or use subscribe() for pull mode)"
1421                    .to_owned(),
1422            ));
1423        };
1424        let consumer = self.subscribe().await?;
1425        Ok(crate::consumer_listener::spawn_wrapper_message_listener(
1426            consumer, listener,
1427        ))
1428    }
1429}
1430
1431#[cfg(test)]
1432mod tests {
1433    use std::cell::RefCell;
1434    use std::sync::atomic::{AtomicBool, AtomicUsize};
1435
1436    use magnetar_proto::MessageId;
1437    use tokio::sync::Barrier;
1438
1439    use super::*;
1440    use crate::SeekTarget;
1441
1442    fn empty_template() -> ConsumerTemplate {
1443        ConsumerTemplate {
1444            subscription: "sub".to_owned(),
1445            consumer_name: None,
1446            sub_type: magnetar_proto::pb::command_subscribe::SubType::Exclusive,
1447            receiver_queue_size: 1000,
1448            receiver_queue_policy: None,
1449            receiver_queue_adjust_interval: None,
1450            initial_position: magnetar_proto::pb::command_subscribe::InitialPosition::Latest,
1451            durable: true,
1452            properties: Vec::new(),
1453            negative_ack_redelivery_delay: None,
1454            ack_timeout: None,
1455            ack_group_time: None,
1456            dlq_policy: None,
1457            max_pending_chunked_message: None,
1458            auto_ack_oldest_chunked_message_on_queue_full: None,
1459            expire_time_of_incomplete_chunked_message: None,
1460            read_compacted: false,
1461            priority_level: None,
1462            subscription_properties: Vec::new(),
1463            key_shared: None,
1464            replicate_subscription_state: None,
1465            force_topic_creation: None,
1466            start_message_rollback_duration_sec: None,
1467        }
1468    }
1469
1470    /// Mutex round-trip: build an `Inner` with no consumers and verify the dynamic-membership
1471    /// helpers (`topics`, `len`, `is_empty`, `lookup`) operate consistently against the
1472    /// `Mutex<Vec<NamedConsumer>>` and that the template-stored subscription name is
1473    /// reachable via [`MultiTopicsConsumer::subscription`] even with an empty set.
1474    #[test]
1475    fn empty_inner_is_consistent() {
1476        let inner: Arc<Inner<magnetar_runtime_tokio::Consumer>> = Arc::new(Inner {
1477            consumers: Mutex::new(Arc::new(Vec::new())),
1478            membership_changed: Arc::new(Notify::new()),
1479            cursor: std::sync::atomic::AtomicUsize::new(0),
1480            template: empty_template(),
1481            auto_update: None,
1482        });
1483        let consumer: MultiTopicsConsumer<magnetar_runtime_tokio::Consumer> = MultiTopicsConsumer {
1484            inner: inner.clone(),
1485        };
1486        assert_eq!(consumer.len(), 0);
1487        assert!(consumer.is_empty());
1488        assert!(consumer.topics().is_empty());
1489        assert_eq!(consumer.subscription(), "sub");
1490        let lookup = consumer.lookup("missing");
1491        assert!(lookup.is_err());
1492        // Cloning the handle shares the same Inner.
1493        let cloned = consumer.clone();
1494        assert!(cloned.is_empty());
1495        assert_eq!(cloned.subscription(), "sub");
1496    }
1497
1498    #[test]
1499    fn template_clone_preserves_settings() {
1500        let mut t = empty_template();
1501        t.properties.push(("k".to_owned(), "v".to_owned()));
1502        t.subscription_properties
1503            .push(("sk".to_owned(), "sv".to_owned()));
1504        let clone = t.clone();
1505        assert_eq!(clone.subscription, "sub");
1506        assert_eq!(clone.properties, vec![("k".to_owned(), "v".to_owned())]);
1507        assert_eq!(
1508            clone.subscription_properties,
1509            vec![("sk".to_owned(), "sv".to_owned())]
1510        );
1511    }
1512
1513    #[derive(Clone)]
1514    struct TestRepublish {
1515        topic: &'static str,
1516        count: usize,
1517    }
1518
1519    #[tokio::test]
1520    async fn republish_stops_at_first_error_with_partial_progress_and_topic_context() {
1521        let members = Mutex::new(Arc::new(vec![
1522            TestRepublish {
1523                topic: "orders-0",
1524                count: 3,
1525            },
1526            TestRepublish {
1527                topic: "orders-1",
1528                count: 5,
1529            },
1530            TestRepublish {
1531                topic: "orders-2",
1532                count: 7,
1533            },
1534        ]));
1535        let started = Arc::new(Mutex::new(Vec::new()));
1536        let completed = Arc::new(Mutex::new(Vec::new()));
1537
1538        let error = republish_snapshot(
1539            &members,
1540            |child| child.topic,
1541            |child| {
1542                let started = started.clone();
1543                let completed = completed.clone();
1544                async move {
1545                    started.lock().push(child.topic);
1546                    if child.topic == "orders-1" {
1547                        return Err("broker rejected publish");
1548                    }
1549                    completed.lock().push((child.topic, child.count));
1550                    Ok(child.count)
1551                }
1552            },
1553        )
1554        .await
1555        .expect_err("the second child must stop orchestration");
1556
1557        let PulsarError::Other(message) = error else {
1558            panic!("child failure must surface as an engine error");
1559        };
1560        assert_eq!(
1561            message,
1562            "republish_dead_letters for topic orders-1: broker rejected publish"
1563        );
1564        assert_eq!(*started.lock(), vec!["orders-0", "orders-1"]);
1565        assert_eq!(*completed.lock(), vec![("orders-0", 3)]);
1566    }
1567
1568    #[tokio::test]
1569    async fn republish_count_saturates_at_usize_max() {
1570        let members = Mutex::new(Arc::new(vec![
1571            TestRepublish {
1572                topic: "orders-0",
1573                count: usize::MAX,
1574            },
1575            TestRepublish {
1576                topic: "orders-1",
1577                count: 1,
1578            },
1579        ]));
1580
1581        let republished = republish_snapshot(
1582            &members,
1583            |child| child.topic,
1584            |child| async move { Ok::<_, &'static str>(child.count) },
1585        )
1586        .await
1587        .expect("republish must succeed");
1588
1589        assert_eq!(republished, usize::MAX);
1590    }
1591
1592    #[tokio::test]
1593    async fn republish_uses_membership_snapshot_taken_before_first_await() {
1594        let members = Arc::new(Mutex::new(Arc::new(vec![
1595            TestRepublish {
1596                topic: "orders-0",
1597                count: 2,
1598            },
1599            TestRepublish {
1600                topic: "orders-1",
1601                count: 3,
1602            },
1603        ])));
1604        let first_started = Arc::new(Notify::new());
1605        let release_first = Arc::new(Notify::new());
1606        let completed = Arc::new(Mutex::new(Vec::new()));
1607
1608        let task = tokio::spawn({
1609            let members = members.clone();
1610            let first_started = first_started.clone();
1611            let release_first = release_first.clone();
1612            let completed = completed.clone();
1613            async move {
1614                republish_snapshot(
1615                    &members,
1616                    |child| child.topic,
1617                    |child| {
1618                        let first_started = first_started.clone();
1619                        let release_first = release_first.clone();
1620                        let completed = completed.clone();
1621                        async move {
1622                            if child.topic == "orders-0" {
1623                                first_started.notify_one();
1624                                release_first.notified().await;
1625                            }
1626                            completed.lock().push(child.topic);
1627                            Ok::<_, &'static str>(child.count)
1628                        }
1629                    },
1630                )
1631                .await
1632            }
1633        });
1634
1635        first_started.notified().await;
1636        {
1637            let mut current = members
1638                .try_lock()
1639                .expect("membership lock must not be held across child work");
1640            let current = Arc::make_mut(&mut current);
1641            current.remove(1);
1642            current.push(TestRepublish {
1643                topic: "orders-2",
1644                count: 11,
1645            });
1646        }
1647        release_first.notify_one();
1648
1649        assert_eq!(
1650            task.await
1651                .expect("task must not panic")
1652                .expect("snapshot republish must succeed"),
1653            5
1654        );
1655        assert_eq!(*completed.lock(), vec!["orders-0", "orders-1"]);
1656    }
1657
1658    struct CancellationProbe(Arc<AtomicBool>);
1659
1660    impl Drop for CancellationProbe {
1661        fn drop(&mut self) {
1662            self.0.store(true, Ordering::SeqCst);
1663        }
1664    }
1665
1666    #[tokio::test]
1667    async fn cancelling_republish_preserves_completed_children_and_stops_later_work() {
1668        let members = Arc::new(Mutex::new(Arc::new(vec![
1669            TestRepublish {
1670                topic: "orders-0",
1671                count: 2,
1672            },
1673            TestRepublish {
1674                topic: "orders-1",
1675                count: 3,
1676            },
1677            TestRepublish {
1678                topic: "orders-2",
1679                count: 5,
1680            },
1681        ])));
1682        let second_started = Arc::new(Notify::new());
1683        let never_release = Arc::new(Notify::new());
1684        let completed = Arc::new(Mutex::new(Vec::new()));
1685        let in_flight_dropped = Arc::new(AtomicBool::new(false));
1686
1687        let task = tokio::spawn({
1688            let members = members.clone();
1689            let second_started = second_started.clone();
1690            let never_release = never_release.clone();
1691            let completed = completed.clone();
1692            let in_flight_dropped = in_flight_dropped.clone();
1693            async move {
1694                republish_snapshot(
1695                    &members,
1696                    |child| child.topic,
1697                    |child| {
1698                        let second_started = second_started.clone();
1699                        let never_release = never_release.clone();
1700                        let completed = completed.clone();
1701                        let in_flight_dropped = in_flight_dropped.clone();
1702                        async move {
1703                            if child.topic == "orders-1" {
1704                                let _probe = CancellationProbe(in_flight_dropped);
1705                                second_started.notify_one();
1706                                never_release.notified().await;
1707                            }
1708                            completed.lock().push(child.topic);
1709                            Ok::<_, &'static str>(child.count)
1710                        }
1711                    },
1712                )
1713                .await
1714            }
1715        });
1716
1717        second_started.notified().await;
1718        task.abort();
1719        let join_error = task
1720            .await
1721            .expect_err("aborted orchestration must be cancelled");
1722
1723        assert!(join_error.is_cancelled());
1724        assert!(in_flight_dropped.load(Ordering::SeqCst));
1725        assert_eq!(*completed.lock(), vec!["orders-0"]);
1726    }
1727
1728    #[tokio::test]
1729    async fn concurrent_republish_calls_overlap_on_independent_snapshots() {
1730        let members = Arc::new(Mutex::new(Arc::new(vec![
1731            TestRepublish {
1732                topic: "orders-0",
1733                count: 2,
1734            },
1735            TestRepublish {
1736                topic: "orders-1",
1737                count: 3,
1738            },
1739        ])));
1740        let overlap = Arc::new(Barrier::new(2));
1741        let first_calls = Arc::new(AtomicUsize::new(0));
1742        let second_calls = Arc::new(AtomicUsize::new(0));
1743
1744        let run = || {
1745            let members = members.clone();
1746            let overlap = overlap.clone();
1747            let first_calls = first_calls.clone();
1748            let second_calls = second_calls.clone();
1749            tokio::spawn(async move {
1750                republish_snapshot(
1751                    &members,
1752                    |child| child.topic,
1753                    |child| {
1754                        let overlap = overlap.clone();
1755                        let first_calls = first_calls.clone();
1756                        let second_calls = second_calls.clone();
1757                        async move {
1758                            if child.topic == "orders-0" {
1759                                first_calls.fetch_add(1, Ordering::SeqCst);
1760                                overlap.wait().await;
1761                            } else {
1762                                second_calls.fetch_add(1, Ordering::SeqCst);
1763                            }
1764                            Ok::<_, &'static str>(child.count)
1765                        }
1766                    },
1767                )
1768                .await
1769            })
1770        };
1771
1772        let first = run();
1773        let second = run();
1774        let (first, second) = tokio::time::timeout(Duration::from_secs(2), async {
1775            tokio::join!(first, second)
1776        })
1777        .await
1778        .expect("concurrent calls must not serialize behind the membership lock");
1779
1780        let first = first
1781            .expect("first task must not panic")
1782            .expect("first republish must succeed");
1783        let second = second
1784            .expect("second task must not panic")
1785            .expect("second republish must succeed");
1786        assert_eq!(first, 5);
1787        assert_eq!(second, 5);
1788        assert_eq!(first_calls.load(Ordering::SeqCst), 2);
1789        assert_eq!(second_calls.load(Ordering::SeqCst), 2);
1790    }
1791
1792    /// Mirror of the dispatch arm inside [`super::MultiTopicsConsumer::seek_per_partition`].
1793    /// Records the routing decision per topic instead of issuing a real seek so the routing
1794    /// logic can be exercised without spinning up a broker.
1795    fn dispatch<F>(topics: &[&str], mut f: F) -> Vec<(String, DispatchKind)>
1796    where
1797        F: FnMut(&str) -> SeekTarget,
1798    {
1799        topics
1800            .iter()
1801            .map(|t| {
1802                let kind = match f(t) {
1803                    SeekTarget::MessageId(id) => DispatchKind::MessageId(id),
1804                    SeekTarget::PublishTimeMs(ts) => DispatchKind::PublishTimeMs(ts),
1805                };
1806                ((*t).to_owned(), kind)
1807            })
1808            .collect()
1809    }
1810
1811    #[derive(Debug, PartialEq, Eq)]
1812    enum DispatchKind {
1813        MessageId(MessageId),
1814        PublishTimeMs(u64),
1815    }
1816
1817    #[test]
1818    fn seek_per_partition_routes_each_topic_via_closure() {
1819        let topics = [
1820            "persistent://public/default/orders-partition-0",
1821            "persistent://public/default/orders-partition-1",
1822            "persistent://public/default/orders-partition-2",
1823        ];
1824
1825        // Track what topics the closure was called with — mirrors Java's
1826        // Function<String, Object> semantics where each partition gets its own decision.
1827        let seen = RefCell::new(Vec::<String>::new());
1828        let mid = MessageId::EARLIEST;
1829        let decisions = dispatch(&topics, |t| {
1830            seen.borrow_mut().push(t.to_owned());
1831            if t.ends_with("-partition-1") {
1832                SeekTarget::PublishTimeMs(1_700_000_000_000)
1833            } else {
1834                SeekTarget::MessageId(mid)
1835            }
1836        });
1837
1838        // Closure was called exactly once per topic, in builder order.
1839        assert_eq!(seen.borrow().len(), 3);
1840        assert_eq!(seen.borrow()[0], topics[0]);
1841        assert_eq!(seen.borrow()[1], topics[1]);
1842        assert_eq!(seen.borrow()[2], topics[2]);
1843
1844        // Routing: partition-0 / partition-2 -> MessageId seek; partition-1 -> timestamp seek.
1845        assert_eq!(decisions.len(), 3);
1846        assert_eq!(decisions[0].1, DispatchKind::MessageId(mid));
1847        assert_eq!(
1848            decisions[1].1,
1849            DispatchKind::PublishTimeMs(1_700_000_000_000)
1850        );
1851        assert_eq!(decisions[2].1, DispatchKind::MessageId(mid));
1852    }
1853
1854    #[test]
1855    fn seek_target_enum_variants_are_constructible() {
1856        let by_id = SeekTarget::MessageId(MessageId::LATEST);
1857        let by_ts = SeekTarget::PublishTimeMs(42);
1858        // PartialEq + Copy: derived impls round-trip without surprises.
1859        assert_eq!(by_id, SeekTarget::MessageId(MessageId::LATEST));
1860        assert_ne!(by_id, by_ts);
1861    }
1862
1863    /// F4 — pure rotation math used by [`MultiTopicsConsumer::receive`].
1864    /// The production code computes `(start + i) % len` for `i in 0..len`,
1865    /// where `start = cursor % len`. The cursor is then advanced past
1866    /// the winning index. This helper mirrors the same shape so we can
1867    /// exercise the starvation-defence guarantee without spinning up
1868    /// real consumers.
1869    fn rotated(start: usize, len: usize) -> Vec<usize> {
1870        (0..len).map(|i| (start + i) % len).collect()
1871    }
1872
1873    /// F4 — every starting cursor value yields a permutation of
1874    /// `0..len` (each topic appears exactly once). This is the
1875    /// non-starvation invariant: even if `select_all` always returns
1876    /// the first ready future, no topic is permanently locked out;
1877    /// every topic gets first crack on `len` consecutive `receive()`
1878    /// calls.
1879    #[test]
1880    fn rotated_indices_are_a_permutation_of_all_topics() {
1881        for len in 1..=8 {
1882            for start in 0..len {
1883                let v = rotated(start, len);
1884                assert_eq!(v.len(), len);
1885                let mut sorted = v.clone();
1886                sorted.sort_unstable();
1887                assert_eq!(
1888                    sorted,
1889                    (0..len).collect::<Vec<_>>(),
1890                    "rotated(start={start}, len={len}) must be a permutation; got {v:?}"
1891                );
1892            }
1893        }
1894    }
1895
1896    /// F4 — the rotation puts the cursor's topic first. With the cursor
1897    /// advancing past the winner, a steady stream of "every topic
1898    /// always ready" produces a uniform sweep across topics rather
1899    /// than starving the topics with higher indices.
1900    #[test]
1901    fn cursor_advance_visits_every_topic_in_round_robin() {
1902        // Simulate the steady-state: every topic always has a message
1903        // ready, so `select_all` returns index 0 of the rotated list,
1904        // which is the cursor's current head. Each call advances the
1905        // cursor by one.
1906        let len = 3_usize;
1907        let mut cursor = 0_usize;
1908        let mut winners = Vec::with_capacity(len * 2);
1909        for _ in 0..len * 2 {
1910            let start = cursor % len;
1911            let order = rotated(start, len);
1912            // Steady state: first in the rotated list always wins (it's
1913            // always ready). The production code maps that back to the
1914            // original index via `rotated_indices[rotated_idx]`.
1915            let original_idx = order[0];
1916            winners.push(original_idx);
1917            cursor = (original_idx + 1) % len;
1918        }
1919        // Two full sweeps in order: 0, 1, 2, 0, 1, 2.
1920        assert_eq!(winners, vec![0, 1, 2, 0, 1, 2]);
1921    }
1922
1923    /// F4 — counter-test for the regression. Without the rotation,
1924    /// `select_all` always returns index 0 of a fixed-order snapshot,
1925    /// so the steady-state winner is always topic 0 and topics 1..N
1926    /// starve. This simulates the buggy behaviour and asserts it would
1927    /// have failed the round-robin expectation — locking in the
1928    /// regression.
1929    #[test]
1930    fn without_rotation_first_topic_starves_the_rest() {
1931        // Buggy: ignore the cursor, always take index 0 in builder
1932        // order. The cursor is updated to the winner+1 but never read,
1933        // so it has no effect.
1934        let len = 3_usize;
1935        // Bug shape: every call picks index 0 (the snapshot order is
1936        // fixed; `select_all` always returns the first ready future).
1937        let winners: Vec<usize> = vec![0; len * 2];
1938        assert_eq!(
1939            winners,
1940            vec![0, 0, 0, 0, 0, 0],
1941            "regression witness: pre-F4 behaviour starved topics 1 and 2"
1942        );
1943    }
1944}