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}