Skip to main content

magnetar/engine/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! `Engine` trait — the abstraction the public [`crate::PulsarClient`] is
4//! generic over.
5//!
6//! `Engine` is a marker trait with a single associated type
7//! ([`Engine::ClientState`]) that selects the per-engine storage backing
8//! [`crate::PulsarClient<E>`]. Today the two implementations are
9//! [`TokioEngine`] (production, default) and [`MoonpoolEngine<P>`]
10//! (deterministic simulation; `P` is the
11//! [`moonpool_core::Providers`](moonpool_core::Providers) bundle).
12//!
13//! Engine-specific methods (`producer`, `consumer`, partitioned, …) live in
14//! dedicated `impl PulsarClient<ConcreteEngine>` blocks rather than on the
15//! trait — production engines have wildly different connect signatures
16//! (tokio takes a URL, moonpool takes `host:port` + a `Providers` bundle)
17//! and trying to surface those through a single trait would either lose
18//! typing or reintroduce the per-engine façade duplication
19//! [ADR-0019](../../specs/adr/0019-engine-scope-and-moonpool-parity.md)
20//! rejected as Option B.
21//!
22//! Instead, moonpool callers that reach for a tokio-only method get a
23//! clean trait-bound error rather than a silent fallback — exactly the
24//! ADR-0019 §Decision contract.
25//!
26//! See ADR-0019 gate (e) — "Option A: generic `PulsarClient<E: Engine>`
27//! with default `E = TokioEngine`" — for the rationale.
28//!
29//! # Module layout
30//!
31//! - `mod.rs` (this file) — the [`Engine`] trait, the per-surface extension traits
32//!   (`TransactionApi`, `ProducerApi`, `ConsumerApi`, `BrokerMetadataApi`, `SubscribeApi`,
33//!   `CreateProducerApi`), the shared type aliases (`SubscribeFut`, `ReceiveOptFut`,
34//!   `ReceiveBatchFut`, `WatchTopicListFut`, `OpenProducerFut`), and the [`TopicListChange`] data
35//!   struct.
36//! - [`tokio`] — the [`TokioEngine`] marker + every `impl … for magnetar_runtime_tokio::*` block.
37//! - [`moonpool`] — the [`MoonpoolEngine`] marker + every `impl<P> … for
38//!   magnetar_runtime_moonpool::*` block.
39
40use std::fmt::Debug;
41use std::future::Future;
42use std::pin::Pin;
43use std::time::Duration;
44
45#[cfg(feature = "moonpool")]
46pub(crate) mod moonpool;
47#[cfg(feature = "tokio")]
48pub(crate) mod tokio;
49
50#[cfg(feature = "moonpool")]
51pub use moonpool::MoonpoolEngine;
52#[cfg(feature = "tokio")]
53pub use tokio::TokioEngine;
54
55/// Marker trait labelling a runtime engine. Implementations select the
56/// concrete storage type ([`Self::ClientState`]) that backs the engine's
57/// branch of [`crate::PulsarClient<E>`].
58///
59/// `'static + Send + Sync` mirrors what we already require of producers and
60/// consumers; downstream users that hand `PulsarClient<E>` to a tokio
61/// `spawn` (or moonpool `spawn`) need at least that.
62///
63/// # Task and timer primitives (ADR-0025 phase 1)
64///
65/// The associated [`Self::TaskHandle`] and [`Self::Interval`] types plus the
66/// [`Self::spawn`] / [`Self::abort_task`] / [`Self::new_interval`] /
67/// [`Self::interval_tick`] methods give the façade an engine-agnostic way to
68/// spawn background tasks and drive periodic timers. They are the
69/// prerequisite for moving `PartitionedProducer::health_loop`,
70/// `TableView::drain_task`, `MultiTopicsConsumer::auto_update`, and the
71/// other surface lifts off `impl PulsarClient<TokioEngine>`. See
72/// [ADR-0025](../../specs/adr/0025-engine-trait-task-and-timer-primitives.md).
73pub trait Engine:
74    'static + Send + Sync + Debug + MessageEncryptorApi + MessageDecryptorApi
75{
76    /// Per-engine state stored inside [`crate::PulsarClient<E>`]. The tokio
77    /// engine plugs in [`magnetar_runtime_tokio::Client`]; the moonpool
78    /// engine plugs in `(Arc<moonpool::ConnectionShared>,
79    /// moonpool::DriverHandle)`. Both bundles are `'static + Send + Sync`
80    /// so the façade can be moved across spawn boundaries unchanged.
81    type ClientState: 'static + Send + Sync;
82
83    /// Opaque, cancel-safe handle to a background task spawned via
84    /// [`Self::spawn`]. Dropping the handle aborts the task on the tokio
85    /// engine; explicit [`Self::abort_task`] is the happens-before-Drop
86    /// path the façade uses on shutdown.
87    type TaskHandle: 'static + Send;
88
89    /// Opaque periodic timer created via [`Self::new_interval`]. The
90    /// façade drives ticks via [`Self::interval_tick`].
91    type Interval: 'static + Send;
92
93    /// Human-readable engine name, surfaced in logs / panics / errors.
94    /// Default returns the Rust type name — engines override to e.g.
95    /// `"tokio"` / `"moonpool"`.
96    fn name() -> &'static str
97    where
98        Self: Sized,
99    {
100        std::any::type_name::<Self>()
101    }
102
103    /// Spawn an async future on the engine's executor. Returns a cancel-
104    /// safe [`Self::TaskHandle`]. Tokio wraps [`::tokio::spawn`]; moonpool
105    /// delegates through its `Providers::TaskProvider` (`moonpool_core`).
106    fn spawn<F>(fut: F) -> Self::TaskHandle
107    where
108        F: Future<Output = ()> + Send + 'static;
109
110    /// Abort a spawned task. Idempotent: calling on an already-completed
111    /// or already-aborted handle is a no-op.
112    fn abort_task(handle: &mut Self::TaskHandle);
113
114    /// Create a periodic timer with `period` between ticks. The first
115    /// tick fires immediately (matches `tokio::time::interval`).
116    fn new_interval(period: Duration) -> Self::Interval;
117
118    /// Await the next tick. The returned future is `Send` and boxed so
119    /// the caller can `.await` from a generic context without exposing
120    /// the engine-specific timer shape.
121    fn interval_tick<'a>(
122        interval: &'a mut Self::Interval,
123    ) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>>;
124
125    /// Engine-injected id provider for the façade's auto-generated
126    /// subscription names (`Reader`, `TableView`) and for the opt-in
127    /// [`ProducerBuilder::unique_name_suffix`](crate::ProducerBuilder::unique_name_suffix)
128    /// policy (issue #406). Tokio plugs in
129    /// `Uuid::new_v4().simple()` (RFC 4122 random); moonpool plugs in
130    /// a process-global atomic counter so deterministic-simulation runs
131    /// produce stable, reproducible names. Callers that need fully
132    /// deterministic names across processes should always pass an
133    /// explicit subscription / reader / producer name through the builder and
134    /// leave the suffix policy off.
135    fn random_subscription_suffix() -> String
136    where
137        Self: Sized;
138
139    /// Engine-provided `OAuth2` [`magnetar_auth_oauth2::Clock`]. Used by
140    /// callers that build a `ClientCredentialsFlow` from generic-engine
141    /// code so the `OAuth2` cache deadlines flow through the same clock
142    /// the engine uses everywhere else, instead of always landing on
143    /// `Arc::new(SystemClock)` at the `OAuth2` builder boundary.
144    ///
145    /// Default is `Arc::new(magnetar_auth_oauth2::SystemClock)` —
146    /// matches the `OAuth2` builder's own default. Engines wired into a
147    /// virtual-time substrate (e.g. moonpool with `SimProviders`)
148    /// override this to return a clock that reads the simulated time.
149    #[cfg(feature = "auth-oauth2")]
150    fn oauth2_clock() -> std::sync::Arc<dyn magnetar_auth_oauth2::Clock>
151    where
152        Self: Sized,
153    {
154        std::sync::Arc::new(magnetar_auth_oauth2::SystemClock)
155    }
156}
157
158// ---------------------------------------------------------------------------
159// Per-surface extension traits — ADR-0026 §D1.
160//
161// The Engine trait stays at ADR-0025 phase 1 (task + timer primitives).
162// Each Pulsar surface family (transactions, reader, typed schemas, …)
163// instead defines its own extension trait implemented by each runtime
164// on its `Client` type. The façade then writes
165//   `impl<E: Engine> PulsarClient<E> where E::ClientState: TransactionApi`
166// and dispatches via `<E::ClientState as TransactionApi>::method(...)`.
167//
168// Why an extension trait, not a method on `Engine`:
169//   - Engine primitives are bounded (spawn / timer / clock).
170//   - Surface families grow with each PIP — putting them on `Engine` would mean every engine grows
171//     with the Pulsar wire surface.
172//   - Each engine implements only the families it supports. Moonpool can land Transaction before
173//     TableView without the trait fattening.
174//
175// Sans-io: every trait method here returns a `Future` that resolves into
176// a broker round-trip; the I/O lives in the runtime crates that
177// implement these traits. `magnetar-proto` carries no `TransactionApi`
178// dep — the protocol-level handshakes (`CommandNewTxn` →
179// `CommandNewTxnResponse`, etc.) already live on `Connection` and are
180// called via `shared.inner.lock(); conn.new_txn(...)` from inside the
181// runtime impl. The trait surface stays free of tokio / mio / socket
182// types. See [ADR-0004](../../specs/adr/0004-sans-io-protocol-core.md).
183// ---------------------------------------------------------------------------
184
185/// Pulsar transactions (PIP-31) — implemented by each runtime on its
186/// `Client` type. Phase 1 of the D1 lift train.
187///
188/// The façade's [`crate::PulsarClient::new_transaction`] +
189/// `commit_transaction` / `abort_transaction` + the two `register_*`
190/// methods dispatch through this trait once
191/// [`crate::PulsarClient<E>`]'s impl block carries the
192/// `where E::ClientState: TransactionApi` bound. Subsequent surface
193/// lifts (`Reader`, `TypedSchemas`, `TableView`, …) follow the same
194/// template — one extension trait per family.
195///
196/// **Sans-io.** Methods are `async fn` returning `impl Future + Send +
197/// '_`; no tokio / mio / socket types appear in the trait surface. The
198/// runtime impl is responsible for driving the
199/// [`magnetar_proto::Connection`] state machine and waking its driver.
200///
201/// See [ADR-0026](../../specs/adr/0026-design-decisions-d1-d4-from-fdb-pulsar-codex-review.md)
202/// §D1 for the rationale (concrete-generic surfaces over GATs).
203pub trait TransactionApi {
204    /// Error surfaced by the runtime when a TC round-trip fails.
205    /// Each runtime maps this onto its own client-error variant.
206    type Error: std::error::Error + Send + Sync + 'static;
207
208    /// Open a new transaction at the broker-side transaction coordinator
209    /// (`CommandNewTxn` → `CommandNewTxnResponse`). Returns the TC-assigned
210    /// [`magnetar_proto::TxnId`] on success.
211    fn new_txn(
212        &self,
213        timeout: Duration,
214    ) -> Pin<Box<dyn Future<Output = Result<magnetar_proto::TxnId, Self::Error>> + Send + '_>>;
215
216    /// Register a partition that the given transaction will write to
217    /// (`CommandAddPartitionToTxn` → `CommandAddPartitionToTxnResponse`).
218    fn add_partition_to_txn(
219        &self,
220        txn: magnetar_proto::TxnId,
221        topic: String,
222    ) -> Pin<Box<dyn Future<Output = Result<(), Self::Error>> + Send + '_>>;
223
224    /// Register a subscription that the given transaction will
225    /// acknowledge on
226    /// (`CommandAddSubscriptionToTxn` → `CommandAddSubscriptionToTxnResponse`).
227    fn add_subscription_to_txn(
228        &self,
229        txn: magnetar_proto::TxnId,
230        topic: String,
231        subscription: String,
232    ) -> Pin<Box<dyn Future<Output = Result<(), Self::Error>> + Send + '_>>;
233
234    /// Commit or abort an open transaction
235    /// (`CommandEndTxn` → `CommandEndTxnResponse`). Returns the final
236    /// transaction state reported by the TC.
237    fn end_txn(
238        &self,
239        txn: magnetar_proto::TxnId,
240        action: magnetar_proto::TxnAction,
241    ) -> Pin<Box<dyn Future<Output = Result<magnetar_proto::TxnState, Self::Error>> + Send + '_>>;
242}
243
244// `OutgoingMessage` + `IncomingMessage` currently live in
245// `client.rs` (tokio-gated). Until those move to a feature-
246// independent module, the `ProducerApi` / `ConsumerApi` traits also
247// gate on the same set of features. Phase 4 of the façade lift will
248// move the message types out of `client.rs` to drop this gate.
249
250/// Pulsar producer wire surface — implemented by each runtime on its
251/// `Producer` type. Foundational for the seven dependent façade lifts
252/// (`Reader`, `TypedSchemas`, `MultiTopicsConsumer`, `PartitionedProducer`,
253/// `PartitionedConsumer`, `PatternConsumer`, `TableView`) per ADR-0026 §D1.
254///
255/// **Sans-io.** Async methods return `Pin<Box<dyn Future + Send + '_>>`;
256/// no tokio / mio / socket types appear in the surface. Each impl drives
257/// the [`magnetar_proto::Connection`] state machine and wakes its
258/// runtime-specific driver.
259///
260/// The method set here is **wire-level**: `send` (the only wire-bound
261/// publish path), `flush` (drain pending), `is_closed`, `topic`, `name`,
262/// `last_sequence_id`. Higher-level helpers (`send_bytes`, `stats`,
263/// `batch_len`, `pending_count`, `get_schema`, `access_mode`) stay
264/// engine-specific until a façade caller needs them — extending this
265/// trait is a non-breaking change so the additive growth pattern is
266/// safe.
267#[cfg(feature = "tokio")]
268pub trait ProducerApi: 'static + Send + Sync {
269    /// Per-runtime client error type used by the wire calls.
270    type Error: std::error::Error + Send + Sync + 'static;
271
272    /// Send a message. Resolves with the broker-assigned
273    /// [`magnetar_proto::MessageId`].
274    fn send(
275        &self,
276        msg: crate::OutgoingMessage,
277    ) -> Pin<Box<dyn Future<Output = Result<magnetar_proto::MessageId, Self::Error>> + Send + '_>>;
278
279    /// Wait for every previously-queued send to be acknowledged or
280    /// fail. Mirrors Java `Producer#flush()`.
281    fn flush(&self) -> Pin<Box<dyn Future<Output = Result<(), Self::Error>> + Send + '_>>;
282
283    /// `true` once the producer has entered a terminal state.
284    fn is_closed(&self) -> bool;
285
286    /// `true` while the broker connection is up. Mirrors Java
287    /// `Producer#isConnected`.
288    fn is_connected(&self) -> bool;
289
290    /// Topic this producer publishes to.
291    fn topic(&self) -> String;
292
293    /// Producer name advertised to the broker (broker-assigned if
294    /// the user didn't set one).
295    fn name(&self) -> String;
296
297    /// Latest sequence id the producer assigned. Mirrors Java
298    /// `Producer#getLastSequenceId`.
299    fn last_sequence_id(&self) -> i64;
300
301    /// Look up the broker-registered schema for the producer's topic
302    /// (PIP-87). Used by
303    /// `magnetar_proto::schema::AutoProduceBytesSchema` to warm its
304    /// cache on first send. `version = None` asks for the current
305    /// schema; pass `Some(schema_version_bytes)` to re-resolve.
306    fn get_schema(
307        &self,
308        version: Option<bytes::Bytes>,
309    ) -> Pin<Box<dyn Future<Output = Result<magnetar_proto::pb::Schema, Self::Error>> + Send + '_>>;
310
311    /// Cumulative producer-side counters. Mirrors Java
312    /// `Producer#getStats`. Returns a zeroed snapshot if the
313    /// producer handle is no longer registered.
314    fn stats(&self) -> magnetar_proto::producer::ProducerStats;
315
316    /// Clone of this producer's live send-latency histogram (issue #347).
317    /// `None` if the producer handle is no longer registered or the
318    /// histogram was never initialised. Used by
319    /// [`crate::PartitionedProducer::aggregate_stats`] to merge several
320    /// producers' distributions via
321    /// [`magnetar_proto::producer::ProducerStats::fold`] — `stats()` alone
322    /// only carries the three pre-computed percentiles, not the
323    /// distribution a sound merge needs.
324    fn send_latency_histogram(&self) -> Option<hdrhistogram::Histogram<u64>>;
325
326    /// Consume the producer and tear down the broker-side resource
327    /// (`CommandCloseProducer`). Mirrors Java `Producer#close`.
328    /// Both runtime types implement close by consuming `self`; the
329    /// trait exposes the same shape so generic façade surfaces (e.g.
330    /// `PartitionedProducer<P>::close`) can fan out closes over a
331    /// `Vec<P>`.
332    fn close_owned(self) -> Pin<Box<dyn Future<Output = Result<(), Self::Error>> + Send>>
333    where
334        Self: Sized;
335
336    /// Wall-clock timestamp of the last broker disconnection observed
337    /// by this producer's connection, or `None` if no disconnect has
338    /// happened yet. Mirrors Java
339    /// `Producer#getLastDisconnectedTimestamp`.
340    fn last_disconnected_timestamp(&self) -> Option<std::time::SystemTime>;
341
342    /// Compression codec this producer was opened with. Mirrors Java
343    /// `ProducerImpl#conf.getCompressionType()`. Returns
344    /// `CompressionKind::None` when the producer was opened without
345    /// explicit compression.
346    fn compression(&self) -> magnetar_proto::types::CompressionKind;
347
348    /// Last sequence id the broker has acknowledged via
349    /// `CommandSendReceipt`. Returns `-1` if no sends have been acked
350    /// yet. Mirrors Java
351    /// `Producer#getLastSequenceIdPublished`. Useful for
352    /// resume-from-checkpoint flows.
353    fn last_sequence_id_published(&self) -> i64;
354
355    /// Number of in-flight sends (queued and not yet acked by the
356    /// broker). Mirrors the un-batched view of Java
357    /// `ProducerStats#getPendingQueueSize`. Equivalent to
358    /// `self.stats().pending_queue_size as usize` but spares the full
359    /// stats snapshot.
360    fn pending_count(&self) -> usize;
361
362    /// Number of messages currently buffered in the batch container,
363    /// waiting for the next flush cycle. Returns `0` when batching is
364    /// disabled or the batch is empty.
365    fn batch_len(&self) -> usize;
366
367    /// Sum of payload bytes currently buffered in the batch container.
368    fn batch_bytes(&self) -> usize;
369}
370
371/// Pulsar consumer wire surface — implemented by each runtime on its
372/// `Consumer` type. Foundational alongside [`ProducerApi`] per
373/// ADR-0026 §D1.
374///
375/// Same sans-io contract as [`ProducerApi`]. The method set covers
376/// the wire-level subscription lifecycle: `receive`, the ack family
377/// (`ack`, `ack_cumulative`, `ack_with_txn`, `ack_cumulative_with_txn`),
378/// `negative_ack`, plus topic / subscription / `is_closed` accessors.
379///
380/// Pass-2 of the `MultiTopicsConsumer` / `PatternConsumer` lift extends
381/// this trait with the queue/permits getters (`available_in_queue`,
382/// `available_permits`, `has_received_any_message`, `has_reached_end_of_topic`,
383/// `is_paused`, `is_inactive`), the DLQ helpers (`drain_dead_letter`,
384/// `republish_dead_letters`, `reconsume_later`,
385/// `reconsume_later_with_properties`), the receive-batch family
386/// (`receive_with_timeout`, `receive_batch`, `receive_batch_with_bytes_cap`),
387/// flow control (`pause`, `resume`), and the remaining seek primitives
388/// (`seek_to_message`, `seek_to_timestamp`). The `unsubscribe` method now
389/// carries the PIP-313 `force: bool` flag so the trait matches the runtime
390/// signatures verbatim.
391///
392/// The associated [`Self::Producer`] type ties each engine's `Consumer` to
393/// its matching `Producer`, letting [`Self::republish_dead_letters`] and the
394/// [`Self::reconsume_later`] family accept a runtime-typed producer reference
395/// at the trait level without re-introducing a tokio-only carve-out.
396#[cfg(feature = "tokio")]
397pub trait ConsumerApi: 'static + Send + Sync {
398    /// Per-runtime client error type used by the wire calls.
399    type Error: std::error::Error + Send + Sync + 'static;
400
401    /// Matched runtime producer used by the DLQ + retry helpers.
402    /// Each runtime ties this to its own `Producer` (tokio →
403    /// [`magnetar_runtime_tokio::Producer`]; moonpool →
404    /// `magnetar_runtime_moonpool::Producer<P>`) so
405    /// [`Self::republish_dead_letters`] /
406    /// [`Self::reconsume_later`] /
407    /// [`Self::reconsume_later_with_properties`] dispatch through the
408    /// trait without a tokio-only carve-out.
409    type Producer: ProducerApi<Error = Self::Error>;
410
411    /// Receive the next message. Resolves once the broker has
412    /// delivered an entry. Returns the
413    /// [`magnetar_proto::IncomingMessage`] surfaced by the state
414    /// machine; callers that prefer the façade-side
415    /// [`crate::IncomingMessage`] (with computed accessors) can call
416    /// `.into()` on the result.
417    fn receive(
418        &self,
419    ) -> Pin<
420        Box<dyn Future<Output = Result<magnetar_proto::IncomingMessage, Self::Error>> + Send + '_>,
421    >;
422
423    /// Acknowledge `message_id` individually. Mirrors Java
424    /// `Consumer#acknowledge(MessageId)`.
425    fn ack(
426        &self,
427        message_id: magnetar_proto::MessageId,
428    ) -> Pin<Box<dyn Future<Output = Result<(), Self::Error>> + Send + '_>>;
429
430    /// Acknowledge all messages up to and including `message_id`.
431    /// Mirrors Java `Consumer#acknowledgeCumulative(MessageId)`.
432    fn ack_cumulative(
433        &self,
434        message_id: magnetar_proto::MessageId,
435    ) -> Pin<Box<dyn Future<Output = Result<(), Self::Error>> + Send + '_>>;
436
437    /// Negatively acknowledge `message_id`. Triggers a redelivery
438    /// after the configured `nackRedeliveryBackoff`. Mirrors Java
439    /// `Consumer#negativeAcknowledge`.
440    fn negative_ack(&self, message_id: magnetar_proto::MessageId);
441
442    /// Ask the broker for the topic's last-published message id.
443    /// Mirrors Java `Consumer#getLastMessageId`.
444    fn last_message_id(
445        &self,
446    ) -> Pin<Box<dyn Future<Output = Result<magnetar_proto::MessageId, Self::Error>> + Send + '_>>;
447
448    /// `true` if the broker has at least one message strictly past
449    /// `cursor`. Mirrors Java `Consumer#hasMessageAvailable` (with a
450    /// caller-supplied cursor variant).
451    fn has_message_after(
452        &self,
453        cursor: magnetar_proto::MessageId,
454    ) -> Pin<Box<dyn Future<Output = Result<bool, Self::Error>> + Send + '_>>;
455
456    /// Look up the broker-registered schema for the consumer's topic
457    /// (PIP-87). Used by
458    /// `magnetar_proto::schema::AutoConsumeSchema` to warm its cache
459    /// on first receive. `version = None` asks for the current schema;
460    /// pass `Some(schema_version_bytes)` to re-resolve.
461    fn get_schema(
462        &self,
463        version: Option<bytes::Bytes>,
464    ) -> Pin<Box<dyn Future<Output = Result<magnetar_proto::pb::Schema, Self::Error>> + Send + '_>>;
465
466    /// Topic this consumer is subscribed to.
467    fn topic(&self) -> String;
468
469    /// Subscription name this consumer holds.
470    fn subscription(&self) -> String;
471
472    /// Broker-assigned consumer name. Empty string when not yet known.
473    /// Mirrors Java `Consumer#getConsumerName`.
474    fn name(&self) -> String;
475
476    /// `true` once the consumer has entered a terminal state.
477    fn is_closed(&self) -> bool;
478
479    /// `true` while the broker connection is up. Mirrors Java
480    /// `Consumer#isConnected`.
481    fn is_connected(&self) -> bool;
482
483    /// Cumulative consumer-side counters. Mirrors Java
484    /// `Consumer#getStats`. Returns a zeroed snapshot if the consumer
485    /// handle is no longer registered.
486    fn stats(&self) -> magnetar_proto::consumer::ConsumerStats;
487
488    /// Clone of this consumer's live receive-latency histogram (issue
489    /// #347). `None` if the consumer handle is no longer registered or the
490    /// histogram was never initialised. Used by
491    /// [`crate::MultiTopicsConsumer::aggregate_stats`] (and, via the
492    /// `PartitionedConsumer` alias, `PartitionedConsumer::aggregate_stats`)
493    /// to merge several consumers' distributions via
494    /// [`magnetar_proto::consumer::ConsumerStats::fold`] — `stats()` alone
495    /// only carries the three pre-computed percentiles, not the
496    /// distribution a sound merge needs.
497    fn receive_latency_histogram(&self) -> Option<hdrhistogram::Histogram<u64>>;
498
499    /// Last broker-reported Failover active/standby state (issue #348).
500    /// `None` until the first `CommandActiveConsumerChange` lands for this
501    /// consumer (e.g. a `Shared` / `Exclusive` subscription never receives
502    /// it). Mirrors the implicit state Java's `ConsumerEventListener`
503    /// callbacks track.
504    fn is_active(&self) -> Option<bool>;
505
506    /// Resolve the next not-yet-observed Failover active/standby transition.
507    /// Backs [`crate::spawn_consumer_event_listener`] — the poller awaits
508    /// this in a loop and turns each `Ok(bool)` into a
509    /// [`crate::ConsumerEvent::BecameActive`] /
510    /// [`crate::ConsumerEvent::BecameInactive`] callback. Resolves the same
511    /// error [`Self::receive`] does once the consumer reaches a terminal
512    /// state with no unobserved transition buffered.
513    fn next_active_change(
514        &self,
515    ) -> Pin<Box<dyn Future<Output = Result<bool, Self::Error>> + Send + '_>>;
516
517    /// Wall-clock timestamp of the last broker disconnection observed
518    /// by this consumer's connection, or `None` if no disconnect has
519    /// happened yet. Mirrors Java
520    /// `Consumer#getLastDisconnectedTimestamp`.
521    fn last_disconnected_timestamp(&self) -> Option<std::time::SystemTime>;
522
523    /// Ask the broker to redeliver every unacknowledged message on
524    /// this consumer. Mirrors Java
525    /// `Consumer#redeliverUnacknowledgedMessages`.
526    fn redeliver_unacked(&self);
527
528    /// Negatively acknowledge a single message with an explicit
529    /// per-message redelivery delay. PIP-37 backoff variant.
530    fn negative_ack_with_delay(
531        &self,
532        message_id: magnetar_proto::MessageId,
533        delay: std::time::Duration,
534    );
535
536    /// Tear down this consumer's subscription on the broker. Mirrors
537    /// Java `Consumer#unsubscribe`. `force=true` selects the PIP-313
538    /// destructive variant that detaches every other attached consumer
539    /// on the same subscription; `force=false` (Java default) keeps the
540    /// cursor in place when other consumers are still attached.
541    fn unsubscribe(
542        &self,
543        force: bool,
544    ) -> Pin<Box<dyn Future<Output = Result<(), Self::Error>> + Send + '_>>;
545
546    /// Seek to the earliest available message. Mirrors Java
547    /// `Consumer#seek(MessageId.earliest)`.
548    fn seek_to_earliest(
549        &self,
550    ) -> Pin<Box<dyn Future<Output = Result<(), Self::Error>> + Send + '_>>;
551
552    /// Seek to the latest available message. Mirrors Java
553    /// `Consumer#seek(MessageId.latest)`.
554    fn seek_to_latest(&self) -> Pin<Box<dyn Future<Output = Result<(), Self::Error>> + Send + '_>>;
555
556    /// Seek to an explicit message id. Mirrors Java
557    /// `Consumer#seek(MessageId)`.
558    fn seek_to_message(
559        &self,
560        message_id: magnetar_proto::MessageId,
561    ) -> Pin<Box<dyn Future<Output = Result<(), Self::Error>> + Send + '_>>;
562
563    /// Seek to a publish-time deadline (broker-side wall clock, ms
564    /// since epoch). Mirrors Java `Consumer#seek(long)`.
565    fn seek_to_timestamp(
566        &self,
567        publish_time_ms: u64,
568    ) -> Pin<Box<dyn Future<Output = Result<(), Self::Error>> + Send + '_>>;
569
570    /// Stop automatic flow refills. Mirrors Java `Consumer#pause` —
571    /// already-issued permits keep draining, no new FLOW frames are
572    /// emitted until [`Self::resume`].
573    fn pause(&self);
574
575    /// Re-enable automatic flow refills. Mirrors Java `Consumer#resume`.
576    fn resume(&self);
577
578    /// Number of messages currently buffered in the per-consumer
579    /// receiver queue, waiting for a `receive()` call. Mirrors Java
580    /// `Consumer#getNumMessagesInQueue`.
581    fn available_in_queue(&self) -> usize;
582
583    /// Outstanding dispatch permits the broker still holds un-spent for this
584    /// consumer — the grants it has been given, minus one per dispatch unit
585    /// that has actually arrived. Mirrors Java
586    /// `ConsumerBase#getAvailablePermits`.
587    ///
588    /// Issue #414 re-pointed this from the purely-additive grant mirror to
589    /// the real decrementing balance, so a value pinned high while messages
590    /// stop arriving is now a usable stall signal (ADR-0101 amending
591    /// ADR-0082).
592    fn available_permits(&self) -> u32;
593
594    /// `true` once the consumer has received at least one message since
595    /// opening. Mirrors Java `Consumer#hasReceivedAnyMessage`.
596    fn has_received_any_message(&self) -> bool;
597
598    /// `true` once the broker has indicated end-of-topic for this
599    /// consumer (no more messages will be dispatched). Mirrors Java
600    /// `Consumer#hasReachedEndOfTopic`.
601    fn has_reached_end_of_topic(&self) -> bool;
602
603    /// `true` while [`Self::pause`] has flipped the consumer's flow
604    /// refills off. Mirrors Java `Consumer#isPaused`.
605    fn is_paused(&self) -> bool;
606
607    /// Mirrors Java `Consumer#isInactive`. Returns `true` once the
608    /// consumer has reached end-of-topic (no more messages will be
609    /// dispatched). Note: a closed consumer is not represented as
610    /// "inactive" here.
611    fn is_inactive(&self) -> bool;
612
613    /// Drain every message the state machine has flagged as dead-letter
614    /// (redelivery count greater than the configured
615    /// `max_redeliver_count`). The caller is responsible for
616    /// republishing them to the DLQ topic (or using
617    /// [`Self::republish_dead_letters`] for the transparent path).
618    fn drain_dead_letter(&self) -> Vec<magnetar_proto::IncomingMessage>;
619
620    /// Receive the next message bounded by `timeout`. Resolves with
621    /// `Ok(None)` when the deadline elapses with no message. Mirrors
622    /// Java `Consumer#receive(int, TimeUnit)`.
623    fn receive_with_timeout(&self, timeout: Duration) -> ReceiveOptFut<'_, Self>;
624
625    /// Receive up to `max_messages` messages in one call. Waits up to
626    /// `max_wait` for the first message, then drains additional
627    /// already-buffered messages without further waiting. Mirrors Java
628    /// `Consumer#batchReceive`.
629    fn receive_batch(&self, max_messages: usize, max_wait: Duration) -> ReceiveBatchFut<'_, Self>;
630
631    /// Same as [`Self::receive_batch`] but stops once the accumulated
632    /// payload size would exceed `max_bytes`. Mirrors Java
633    /// `BatchReceivePolicy` with all three caps (count, bytes, wait).
634    fn receive_batch_with_bytes_cap(
635        &self,
636        max_messages: usize,
637        max_bytes: usize,
638        max_wait: Duration,
639    ) -> ReceiveBatchFut<'_, Self>;
640
641    /// Drain the per-consumer dead-letter queue and republish every
642    /// entry via `dlq_producer`, preserving the message's metadata.
643    /// Acks each original after a successful republish. Returns the
644    /// number of messages republished.
645    fn republish_dead_letters<'a>(
646        &'a self,
647        dlq_producer: &'a Self::Producer,
648    ) -> Pin<Box<dyn Future<Output = Result<usize, Self::Error>> + Send + 'a>>;
649
650    /// Republish a single message via `retry_producer` with a
651    /// `delay`-bounded deadline, then ack the original. Mirrors Java
652    /// `Consumer#reconsumeLater(Message, long, TimeUnit)`.
653    fn reconsume_later<'a>(
654        &'a self,
655        retry_producer: &'a Self::Producer,
656        msg: magnetar_proto::IncomingMessage,
657        delay: Duration,
658    ) -> Pin<Box<dyn Future<Output = Result<(), Self::Error>> + Send + 'a>>;
659
660    /// Same as [`Self::reconsume_later`] but lets the caller stamp
661    /// additional custom properties on the republished message. Mirrors
662    /// Java's properties-aware reconsumeLater overload.
663    fn reconsume_later_with_properties<'a>(
664        &'a self,
665        retry_producer: &'a Self::Producer,
666        msg: magnetar_proto::IncomingMessage,
667        custom_properties: Vec<(String, String)>,
668        delay: Duration,
669    ) -> Pin<Box<dyn Future<Output = Result<(), Self::Error>> + Send + 'a>>;
670
671    /// Consume the consumer and reliably tear down the broker-side resource
672    /// (`CommandCloseConsumer`). Mirrors Java `Consumer#close`: both runtime
673    /// implementations await the broker acknowledgement and return close
674    /// errors through the future.
675    ///
676    /// Dropping the final runtime consumer clone is a distinct best-effort
677    /// safety net that cannot report acknowledgement or failure. Generic
678    /// code that requires confirmed release must call and await this method.
679    fn close_owned(self) -> Pin<Box<dyn Future<Output = Result<(), Self::Error>> + Send>>
680    where
681        Self: Sized;
682
683    /// Fire-and-forget individual ack into the consumer's
684    /// ack-grouping tracker (opt-in via
685    /// `ConsumerBuilder::ack_group_time`). The state machine flushes
686    /// the tracker after `ack_group_time` elapses, emitting one
687    /// coalesced `CommandAck`. With no tracker configured, the proto
688    /// layer falls back to a synchronous immediate `CommandAck` so the
689    /// message is never silently dropped. Mirrors Java's
690    /// `acknowledgmentGroupTime` path.
691    fn ack_grouped(&self, message_id: magnetar_proto::MessageId);
692
693    /// Fire-and-forget cumulative ack into the consumer's ack-grouping
694    /// tracker. See [`Self::ack_grouped`] for the semantics.
695    fn ack_grouped_cumulative(&self, message_id: magnetar_proto::MessageId);
696
697    /// Acknowledge `message_id` as part of a Pulsar transaction
698    /// (PIP-31). The ack only takes effect once the transaction
699    /// commits. Mirrors Java
700    /// `Consumer#acknowledgeAsync(MessageId, Transaction)`.
701    fn ack_with_txn(
702        &self,
703        message_id: magnetar_proto::MessageId,
704        txn_id: magnetar_proto::TxnId,
705    ) -> Pin<Box<dyn Future<Output = Result<(), Self::Error>> + Send + '_>>;
706
707    /// Cumulative ack as part of a Pulsar transaction (PIP-31).
708    /// Mirrors Java
709    /// `Consumer#acknowledgeCumulativeAsync(MessageId, Transaction)`.
710    fn ack_cumulative_with_txn(
711        &self,
712        message_id: magnetar_proto::MessageId,
713        txn_id: magnetar_proto::TxnId,
714    ) -> Pin<Box<dyn Future<Output = Result<(), Self::Error>> + Send + '_>>;
715}
716
717/// PIP-145 `TopicListChanged` delta surfaced through
718/// [`BrokerMetadataApi::poll_topic_list_change`]. Façade-side analogue
719/// of the per-runtime `TopicListChange` structs — each runtime impl
720/// converts its own delta into this engine-agnostic shape so generic
721/// surfaces (`PatternConsumer<C>::update`) can reconcile without
722/// touching runtime-specific types.
723#[cfg(feature = "tokio")]
724#[derive(Debug, Clone)]
725pub struct TopicListChange {
726    /// Topics that newly match the pattern.
727    pub added: Vec<String>,
728    /// Topics that no longer match the pattern.
729    pub removed: Vec<String>,
730}
731
732/// One setup-operation context shared across every caller-visible stage.
733///
734/// The pinned timer enforces the total deadline, while `last_broker_error`
735/// preserves the newest broker diagnostic across metadata, lookup, routing,
736/// and attachment so a later timeout does not replace it with a generic error.
737#[cfg(feature = "tokio")]
738#[doc(hidden)]
739pub struct OperationDeadline {
740    timer: Pin<Box<dyn Future<Output = ()> + Send + 'static>>,
741    last_broker_error: Option<(i32, String)>,
742}
743
744type OperationDeadlineParts<'a> = (
745    Pin<&'a mut (dyn Future<Output = ()> + Send)>,
746    &'a mut Option<(i32, String)>,
747);
748
749#[cfg(feature = "tokio")]
750impl core::fmt::Debug for OperationDeadline {
751    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
752        f.debug_struct("OperationDeadline").finish_non_exhaustive()
753    }
754}
755
756#[cfg(feature = "tokio")]
757impl OperationDeadline {
758    /// Build a façade deadline from an engine-provided timer.
759    #[doc(hidden)]
760    pub fn new(timer: Pin<Box<dyn Future<Output = ()> + Send + 'static>>) -> Self {
761        Self {
762            timer,
763            last_broker_error: None,
764        }
765    }
766
767    fn never() -> Self {
768        Self::new(Box::pin(std::future::pending()))
769    }
770
771    /// Reborrow the pinned engine timer.
772    #[doc(hidden)]
773    pub fn timer(&mut self) -> Pin<&mut (dyn Future<Output = ()> + Send)> {
774        self.timer.as_mut()
775    }
776
777    /// Reborrow both parts for a built-in runtime operation.
778    pub(crate) fn parts(&mut self) -> OperationDeadlineParts<'_> {
779        (self.timer.as_mut(), &mut self.last_broker_error)
780    }
781}
782
783/// Engine-side broker metadata lookups used by
784/// [`crate::PartitionedConsumerBuilder`] and
785/// [`crate::PatternConsumerBuilder`] (alongside other partition-aware
786/// surfaces). Each runtime implements this on its concrete `Client`
787/// type.
788///
789/// Same sans-io contract as [`SubscribeApi`] — async methods return
790/// `Pin<Box<dyn Future + Send + '_>>`; the impl drives the
791/// `magnetar_proto::Connection` state machine.
792#[cfg(feature = "tokio")]
793pub trait BrokerMetadataApi: 'static + Send + Sync {
794    /// Per-runtime client error type.
795    type Error: std::error::Error + Send + Sync + 'static;
796
797    /// Query the broker for the partition count of `topic`. Returns
798    /// `0` for non-partitioned topics. Mirrors Java
799    /// `PulsarClient#getPartitionsForTopic`.
800    fn partitioned_topic_metadata<'a>(
801        &'a self,
802        topic: &'a str,
803    ) -> Pin<Box<dyn Future<Output = Result<u32, Self::Error>> + Send + 'a>>;
804
805    /// Create a provider-correct setup timer.
806    ///
807    /// The default preserves compatibility for downstream custom engines:
808    /// their established operation method remains authoritative until they
809    /// opt into the deadline-aware companion below.
810    #[doc(hidden)]
811    fn new_metadata_operation_deadline(&self) -> OperationDeadline {
812        OperationDeadline::never()
813    }
814
815    /// Deadline-aware metadata lookup used by built-in composite builders.
816    #[doc(hidden)]
817    fn partitioned_topic_metadata_with_deadline<'a>(
818        &'a self,
819        topic: &'a str,
820        _deadline: &'a mut OperationDeadline,
821    ) -> Pin<Box<dyn Future<Output = Result<u32, Self::Error>> + Send + 'a>> {
822        self.partitioned_topic_metadata(topic)
823    }
824
825    /// Subscribe to a topic-list watcher and return the initial topic
826    /// snapshot for the given namespace + regex pattern (PIP-145).
827    fn watch_topic_list<'a>(
828        &'a self,
829        namespace: &'a str,
830        pattern: &'a str,
831    ) -> WatchTopicListFut<'a, Self>;
832
833    /// Deadline-aware topic-list snapshot used by built-in composite builders.
834    #[doc(hidden)]
835    fn watch_topic_list_with_deadline<'a>(
836        &'a self,
837        namespace: &'a str,
838        pattern: &'a str,
839        _deadline: &'a mut OperationDeadline,
840    ) -> WatchTopicListFut<'a, Self> {
841        self.watch_topic_list(namespace, pattern)
842    }
843
844    /// Drain the next pending `TopicListChanged` delta from the
845    /// connection's PIP-145 buffer, if any. Returns `None` when no
846    /// deltas are pending. Used by `PatternConsumer::update` to
847    /// reconcile its child set.
848    fn poll_topic_list_change(&self) -> Option<TopicListChange>;
849}
850
851/// Engine-side subscribe surface used by `ConsumerBuilder<E>` and the
852/// other consumer-spawning façade surfaces (`MultiTopicsConsumer`,
853/// `PatternConsumer`, `Reader`). Each runtime implements this on its
854/// concrete `Client` type with the runtime-specific `Consumer` type
855/// surfaced via the associated `Consumer` type.
856///
857/// Per ADR-0026 §D1: this is the next sub-PR after the per-surface
858/// lifts. Lifting `ConsumerBuilder<E>` to dispatch through this
859/// trait unblocks the impl-body lifts on the four phantom-lifted
860/// surfaces (`TypedSchemas`, `MultiTopicsConsumer` /
861/// `PartitionedConsumer`, `PatternConsumer`).
862#[cfg(feature = "tokio")]
863pub trait SubscribeApi: 'static + Send + Sync {
864    /// Concrete consumer type each runtime returns. Required to
865    /// implement [`ConsumerApi`] so generic surfaces can dispatch
866    /// further methods through that trait.
867    type Consumer: ConsumerApi;
868    /// Runtime client error.
869    type Error: std::error::Error + Send + Sync + 'static;
870
871    /// Issue a `CommandSubscribe` and resolve with the broker-side
872    /// `CommandSuccess` correlated with the request id (subscribe
873    /// ack). After this resolves the state machine has a fresh
874    /// per-consumer queue and the initial FLOW frame has been queued
875    /// for the driver.
876    fn subscribe(&self, req: magnetar_proto::SubscribeRequest) -> SubscribeFut<'_, Self>;
877
878    /// Create a provider-correct setup timer.
879    #[doc(hidden)]
880    fn new_subscribe_operation_deadline(&self) -> OperationDeadline {
881        OperationDeadline::never()
882    }
883
884    /// Deadline-aware subscribe used by built-in composite builders.
885    #[doc(hidden)]
886    fn subscribe_with_deadline<'a>(
887        &'a self,
888        req: magnetar_proto::SubscribeRequest,
889        _deadline: &'a mut OperationDeadline,
890    ) -> SubscribeFut<'a, Self> {
891        self.subscribe(req)
892    }
893}
894
895/// Helper alias: `SubscribeApi::subscribe` future return type.
896#[cfg(feature = "tokio")]
897pub type SubscribeFut<'a, S> = Pin<
898    Box<
899        dyn Future<Output = Result<<S as SubscribeApi>::Consumer, <S as SubscribeApi>::Error>>
900            + Send
901            + 'a,
902    >,
903>;
904
905/// Helper alias: `ConsumerApi::receive_with_timeout` future return type.
906#[cfg(feature = "tokio")]
907pub type ReceiveOptFut<'a, C> = Pin<
908    Box<
909        dyn Future<
910                Output = Result<Option<magnetar_proto::IncomingMessage>, <C as ConsumerApi>::Error>,
911            > + Send
912            + 'a,
913    >,
914>;
915
916/// Helper alias: `ConsumerApi::receive_batch` / `receive_batch_with_bytes_cap`
917/// future return type.
918#[cfg(feature = "tokio")]
919pub type ReceiveBatchFut<'a, C> = Pin<
920    Box<
921        dyn Future<Output = Result<Vec<magnetar_proto::IncomingMessage>, <C as ConsumerApi>::Error>>
922            + Send
923            + 'a,
924    >,
925>;
926
927/// Helper alias: `BrokerMetadataApi::watch_topic_list` future return type.
928#[cfg(feature = "tokio")]
929pub type WatchTopicListFut<'a, B> =
930    Pin<Box<dyn Future<Output = Result<Vec<String>, <B as BrokerMetadataApi>::Error>> + Send + 'a>>;
931
932/// Engine-side producer-creation surface used by `ProducerBuilder<E>`
933/// and `PartitionedProducer<E>`. Same shape as [`SubscribeApi`] for
934/// the producer side.
935#[cfg(feature = "tokio")]
936pub trait CreateProducerApi: 'static + Send + Sync {
937    /// Concrete producer type each runtime returns.
938    type Producer: ProducerApi;
939    /// Runtime client error.
940    type Error: std::error::Error + Send + Sync + 'static;
941
942    /// Issue a `CommandProducer` and resolve with
943    /// `CommandProducerSuccess` correlated with the request id.
944    fn open_producer(
945        &self,
946        req: magnetar_proto::CreateProducerRequest,
947    ) -> OpenProducerFut<'_, Self>;
948
949    /// Create a provider-correct setup timer.
950    #[doc(hidden)]
951    fn new_producer_operation_deadline(&self) -> OperationDeadline {
952        OperationDeadline::never()
953    }
954
955    /// Deadline-aware producer-open used by built-in composite builders.
956    #[doc(hidden)]
957    fn open_producer_with_deadline<'a>(
958        &'a self,
959        req: magnetar_proto::CreateProducerRequest,
960        _deadline: &'a mut OperationDeadline,
961    ) -> OpenProducerFut<'a, Self> {
962        self.open_producer(req)
963    }
964}
965
966/// Helper alias: `CreateProducerApi::open_producer` future return type.
967#[cfg(feature = "tokio")]
968pub type OpenProducerFut<'a, P> = Pin<
969    Box<
970        dyn Future<
971                Output = Result<
972                    <P as CreateProducerApi>::Producer,
973                    <P as CreateProducerApi>::Error,
974                >,
975            > + Send
976            + 'a,
977    >,
978>;
979
980// ---------------------------------------------------------------------------
981// PIP-4 per-engine encryption extension traits.
982//
983// Tokio defines `magnetar_runtime_tokio::MessageEncryptor` and
984// `magnetar_runtime_tokio::MessageDecryptor` for its own producer / consumer
985// surfaces. The façade builders historically stored
986// `Option<Arc<dyn magnetar_runtime_tokio::MessageEncryptor>>` directly,
987// hard-locking them to the tokio engine. The two extension traits below
988// lift that storage off tokio: each engine declares its own concrete
989// encryptor / decryptor type, the façade stores
990// `Option<<E as MessageEncryptorApi>::Encryptor>` instead. Both runtime
991// engines now ship the PIP-4 bridge, so each resolves the associated type
992// to its own `Arc<dyn …MessageEncryptor>` / `…MessageDecryptor`. The
993// zero-sized [`NoEncryption`] stub remains for any future engine that
994// genuinely cannot drive on-the-wire encryption.
995//
996// The traits live on the engine marker (not on `Client`) because the
997// encryptor identity is engine-global config rather than per-connection
998// state. The associated `Encryptor` / `Decryptor` types are `Clone +
999// Send + Sync + 'static` so the builders can pass them to the runtime's
1000// `open_producer_with` / `subscribe_with` without further bounds churn.
1001//
1002// Sans-io: the traits define types only. Real encryption happens in the
1003// runtime crates that supply the concrete types (`magnetar-runtime-tokio`
1004// and `magnetar-runtime-moonpool`).
1005// ---------------------------------------------------------------------------
1006
1007/// Engine-side message-encryptor selection. Each engine declares its own
1008/// concrete encryptor type; the façade's `ProducerBuilder` stores
1009/// `Option<E::Encryptor>` (engine-typed) instead of an
1010/// `Arc<dyn magnetar_runtime_tokio::MessageEncryptor>` (tokio-locked).
1011///
1012/// Implemented on the engine marker ([`TokioEngine`] / [`MoonpoolEngine<P>`]).
1013/// Tokio plugs in `Arc<dyn magnetar_runtime_tokio::MessageEncryptor>`;
1014/// moonpool plugs in `Arc<dyn magnetar_runtime_moonpool::MessageEncryptor>`.
1015/// The choice of `Encryptor: Clone` lets the façade fan out the encryptor
1016/// across child producers in `PartitionedProducer`.
1017pub trait MessageEncryptorApi {
1018    /// Concrete per-engine encryptor type. `Clone + Send + Sync + 'static`
1019    /// so it survives spawn boundaries and fan-out into child producers.
1020    type Encryptor: Clone + Send + Sync + 'static;
1021}
1022
1023/// Engine-side message-decryptor selection. Mirror of
1024/// [`MessageEncryptorApi`] for the consume path. Implemented on the
1025/// engine marker.
1026pub trait MessageDecryptorApi {
1027    /// Concrete per-engine decryptor type. `Clone + Send + Sync + 'static`.
1028    type Decryptor: Clone + Send + Sync + 'static;
1029}
1030
1031// ---------------------------------------------------------------------------
1032// PIP-460 scalable topics (ADR-0093, experimental). The `ScalableTopicsApi`
1033// extension trait follows the same ADR-0026 §D1 pattern as `TransactionApi`:
1034// defined here, implemented by each runtime on its `Client` type (which is the
1035// engine's `ClientState`), dispatched through
1036//   `impl<E: Engine> PulsarClient<E> where E::ClientState: ScalableTopicsApi`.
1037// Gated on `feature = "scalable-topics"` so the default surface is unchanged.
1038// ---------------------------------------------------------------------------
1039
1040/// **Experimental** (PIP-460, ADR-0093). Engine-side scalable-topic hooks —
1041/// implemented by each runtime on its `Client` type. The façade's
1042/// [`crate::scalable::StreamConsumer`] dispatches through this trait once
1043/// [`crate::PulsarClient<E>`] carries the
1044/// `where E::ClientState: ScalableTopicsApi` bound.
1045///
1046/// **Sans-io.** Async methods return `Pin<Box<dyn Future + Send + '_>>`; no
1047/// tokio / mio / socket types appear in the surface. Each impl drives the
1048/// [`magnetar_proto::Connection`] scalable entries
1049/// (`open_scalable_topic_session`, `close_scalable_topic_session`) and reads the
1050/// driver-drained events.
1051#[cfg(all(feature = "tokio", feature = "scalable-topics"))]
1052pub trait ScalableTopicsApi: 'static + Send + Sync {
1053    /// Per-runtime client error type.
1054    type Error: std::error::Error + Send + Sync + 'static;
1055
1056    /// Open a scalable-topic session and await its first layout. The session
1057    /// stays open, pushing later layouts through
1058    /// [`Self::next_scalable_event`], until it is closed.
1059    fn scalable_topic_lookup<'a>(
1060        &'a self,
1061        topic: &'a str,
1062    ) -> Pin<Box<dyn Future<Output = Result<ScalableLookup, Self::Error>> + Send + 'a>>;
1063
1064    /// Whether the connected broker advertised the PIP-460 capability.
1065    /// `false` against a Pulsar 4.x peer.
1066    fn broker_supports_scalable_topics(&self) -> bool;
1067
1068    /// Register as a scalable consumer with the controller leader and await the
1069    /// initial assignment — the `segment://` topics this consumer owns.
1070    fn scalable_topic_subscribe<'a>(
1071        &'a self,
1072        topic: &'a str,
1073        subscription: &'a str,
1074        consumer_name: &'a str,
1075        consumer_id: u64,
1076        consumer_type: magnetar_proto::ScalableConsumerType,
1077    ) -> Pin<
1078        Box<
1079            dyn Future<Output = Result<magnetar_proto::ConsumerAssignment, Self::Error>>
1080                + Send
1081                + 'a,
1082        >,
1083    >;
1084
1085    /// Open a namespace-level watch over the scalable topics matching
1086    /// `property_filters` (empty = every scalable topic in the namespace).
1087    fn watch_scalable_topics(
1088        &self,
1089        namespace: &str,
1090        property_filters: Vec<(String, String)>,
1091    ) -> Result<u64, Self::Error>;
1092
1093    /// Close a namespace-level scalable-topics watch.
1094    fn close_scalable_topics_watch(&self, watch_id: u64);
1095
1096    /// The current matching topic set for a namespace watch.
1097    fn scalable_topics_snapshot(&self, watch_id: u64) -> Option<Vec<String>>;
1098
1099    /// Whether the broker advertised metadata-driven transaction-coordinator
1100    /// discovery. Independent of `supports_scalable_topics` upstream.
1101    fn broker_supports_tc_metadata_discovery(&self) -> bool;
1102
1103    /// Open a transaction-coordinator discovery watch.
1104    fn watch_tc_assignments(&self) -> Result<u64, Self::Error>;
1105
1106    /// Close a transaction-coordinator discovery watch.
1107    fn close_tc_assignments_watch(&self, watch_id: u64);
1108
1109    /// Close a scalable-topic session.
1110    fn close_scalable_topic_session(&self, session_id: u64);
1111
1112    /// Await the next scalable-topic event (DAG update / drop-on-change /
1113    /// close). Resolves `None` once the connection closes.
1114    fn next_scalable_event(
1115        &self,
1116    ) -> Pin<Box<dyn Future<Output = Option<ScalableEvent>> + Send + '_>>;
1117}
1118
1119/// **Experimental** (PIP-460, ADR-0093). Engine-agnostic resolved
1120/// scalable-topic lookup surfaced through [`ScalableTopicsApi`]. Façade-side
1121/// analogue of each runtime's `ScalableLookup`.
1122#[cfg(all(feature = "tokio", feature = "scalable-topics"))]
1123#[derive(Debug, Clone)]
1124pub struct ScalableLookup {
1125    /// Client-allocated session id; the session stays open until closed.
1126    pub session_id: u64,
1127    /// Canonical `topic://...` identity the broker resolved the request to.
1128    pub resolved_topic_name: Option<String>,
1129    /// Controller broker serving this topic's layout, when advertised.
1130    pub controller_broker_url: Option<String>,
1131    /// Initial DAG snapshot for the topic.
1132    pub segments: Vec<magnetar_proto::SegmentDescriptor>,
1133    /// Layout epoch the snapshot was stamped with.
1134    pub epoch: u64,
1135}
1136
1137/// **Experimental** (PIP-460, ADR-0093). Engine-agnostic scalable-topic event
1138/// surfaced through [`ScalableTopicsApi::next_scalable_event`]. Façade-side
1139/// analogue of each runtime's `ScalableEvent`.
1140#[cfg(all(feature = "tokio", feature = "scalable-topics"))]
1141#[derive(Debug, Clone)]
1142pub enum ScalableEvent {
1143    /// A scalable-topic session resolved: its first layout landed.
1144    LookupResolved {
1145        /// Client-allocated session id.
1146        session_id: u64,
1147        /// Canonical `topic://...` identity the broker resolved to.
1148        resolved_topic_name: Option<String>,
1149        /// Controller broker serving this topic's layout, when advertised.
1150        controller_broker_url: Option<String>,
1151        /// Initial DAG snapshot.
1152        segments: Vec<magnetar_proto::SegmentDescriptor>,
1153        /// Layout epoch the snapshot was stamped with.
1154        epoch: u64,
1155    },
1156    /// An open session applied a subsequent layout.
1157    DagUpdated {
1158        /// Session id.
1159        session_id: u64,
1160        /// The applied delta.
1161        delta: magnetar_proto::DagDelta,
1162    },
1163    /// The segment DAG changed under a live consumer (drop-on-change).
1164    DagChangedDuringConsume {
1165        /// Session id whose DAG changed.
1166        session_id: u64,
1167        /// Why the DAG changed.
1168        reason: magnetar_proto::DagChangeReason,
1169    },
1170    /// The scalable-topic session closed.
1171    DagWatchClosed {
1172        /// Session id that closed.
1173        session_id: u64,
1174        /// Optional close reason.
1175        reason: Option<String>,
1176    },
1177    /// A scalable consumer's registration resolved with its initial share.
1178    ConsumerAssigned {
1179        /// Consumer id that registered.
1180        consumer_id: u64,
1181        /// The `segment://` topics this consumer owns.
1182        assignment: magnetar_proto::ConsumerAssignment,
1183    },
1184    /// The controller leader rebalanced a registered consumer's share.
1185    AssignmentChanged {
1186        /// Consumer id whose share changed.
1187        consumer_id: u64,
1188        /// What to attach to and detach from.
1189        delta: magnetar_proto::AssignmentDelta,
1190    },
1191    /// A scalable consumer's registration was rejected.
1192    ConsumerRejected {
1193        /// Consumer id whose registration failed.
1194        consumer_id: u64,
1195        /// Why the broker rejected it.
1196        reason: String,
1197    },
1198    /// A namespace-level scalable-topics watch delivered a snapshot or a diff.
1199    TopicsChanged {
1200        /// Watch id the update belongs to.
1201        watch_id: u64,
1202        /// The snapshot or diff the broker sent.
1203        change: magnetar_proto::TopicsChange,
1204    },
1205    /// A namespace-level scalable-topics watch ended.
1206    TopicsWatchClosed {
1207        /// Watch id that closed.
1208        watch_id: u64,
1209        /// Optional close reason.
1210        reason: Option<String>,
1211    },
1212    /// The metadata-driven transaction-coordinator assignment set changed.
1213    TcAssignmentsChanged {
1214        /// Watch id the update belongs to.
1215        watch_id: u64,
1216        /// Number of transaction-coordinator partitions.
1217        parallelism: u32,
1218        /// Which broker serves each coordinator.
1219        assignments: Vec<magnetar_proto::TcAssignment>,
1220    },
1221    /// A transaction-coordinator discovery watch ended.
1222    TcAssignmentsWatchClosed {
1223        /// Watch id that closed.
1224        watch_id: u64,
1225        /// Optional close reason.
1226        reason: Option<String>,
1227    },
1228}
1229
1230/// Zero-sized stub for any future engine that genuinely cannot wire real
1231/// encryption. Both shipped engines ([`TokioEngine`] and
1232/// [`MoonpoolEngine`]) now resolve their `MessageEncryptorApi::Encryptor`
1233/// / `MessageDecryptorApi::Decryptor` to their own runtime's
1234/// `Arc<dyn …MessageEncryptor>` / `…MessageDecryptor`, so `NoEncryption`
1235/// is no longer used by either. It is retained as the documented opt-out
1236/// type an engine can hand to the façade to signal "encryption not
1237/// supported on this engine" — the builders' generic `.create()` /
1238/// `.subscribe()` paths ignore the encryptor field regardless.
1239#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1240pub struct NoEncryption;
1241
1242// Per-engine storage for [`crate::PulsarClient<MoonpoolEngine<P>>`] is
1243// [`magnetar_runtime_moonpool::Client<P>`] directly — see
1244// `Engine::ClientState` above. This mirrors the tokio engine
1245// (`type ClientState = magnetar_runtime_tokio::Client`) so the existing
1246// `SubscribeApi` / `CreateProducerApi` / `ConsumerApi` / `ProducerApi`
1247// impls on the runtime `Client<P>` automatically satisfy the trait
1248// bounds the façade builders dispatch through, without a parallel
1249// state struct.
1250
1251#[cfg(test)]
1252mod tests {
1253    // NOTE: We import the trait + marker types explicitly rather than
1254    // via `use super::*;`. The parent module exposes sibling `tokio` /
1255    // `moonpool` submodules whose names would shadow the external
1256    // `tokio` crate inside this test scope and break the
1257    // `#[::tokio::test]` macro expansions below.
1258    use super::Engine;
1259    #[cfg(feature = "moonpool")]
1260    use super::MoonpoolEngine;
1261    #[cfg(feature = "tokio")]
1262    use super::TokioEngine;
1263
1264    #[cfg(feature = "tokio")]
1265    #[test]
1266    fn tokio_engine_implements_engine() {
1267        fn takes_engine<E: Engine>() -> &'static str {
1268            E::name()
1269        }
1270        assert_eq!(takes_engine::<TokioEngine>(), "tokio");
1271    }
1272
1273    #[cfg(feature = "tokio")]
1274    #[test]
1275    fn tokio_engine_is_send_sync() {
1276        fn assert_send_sync<T: Send + Sync>() {}
1277        assert_send_sync::<TokioEngine>();
1278    }
1279
1280    #[cfg(feature = "moonpool")]
1281    #[test]
1282    fn moonpool_engine_implements_engine() {
1283        use moonpool_core::TokioProviders;
1284        fn takes_engine<E: Engine>() -> &'static str {
1285            E::name()
1286        }
1287        assert_eq!(takes_engine::<MoonpoolEngine<TokioProviders>>(), "moonpool");
1288    }
1289
1290    #[cfg(feature = "moonpool")]
1291    #[test]
1292    fn moonpool_engine_is_send_sync() {
1293        use moonpool_core::TokioProviders;
1294        fn assert_send_sync<T: Send + Sync>() {}
1295        assert_send_sync::<MoonpoolEngine<TokioProviders>>();
1296    }
1297
1298    #[cfg(all(feature = "tokio", feature = "auth-oauth2"))]
1299    #[test]
1300    fn tokio_engine_oauth2_clock_is_monotonic() {
1301        let clock = <TokioEngine as Engine>::oauth2_clock();
1302        let a = clock.now();
1303        let b = clock.now();
1304        assert!(b >= a, "OAuth2 clock must be monotonic");
1305    }
1306
1307    #[cfg(all(feature = "moonpool", feature = "auth-oauth2"))]
1308    #[test]
1309    fn moonpool_engine_oauth2_clock_is_monotonic() {
1310        use moonpool_core::TokioProviders;
1311        let clock = <MoonpoolEngine<TokioProviders> as Engine>::oauth2_clock();
1312        let a = clock.now();
1313        let b = clock.now();
1314        assert!(b >= a, "OAuth2 clock must be monotonic");
1315    }
1316
1317    // -------------------------------------------------------------
1318    // ADR-0025 phase 1: task + timer primitive smoke tests. One pair
1319    // per engine — keeps the per-engine test count balanced even
1320    // though the new primitives don't yet have façade callers.
1321
1322    // Note: the tests below reference the external `tokio` crate via the
1323    // absolute `::tokio::` path because this module has a sibling
1324    // `tokio` submodule (carrying the `TokioEngine` impl) — the
1325    // unqualified `tokio` identifier would otherwise resolve to that
1326    // submodule rather than to the crate.
1327
1328    #[cfg(feature = "tokio")]
1329    #[::tokio::test(flavor = "current_thread", start_paused = true)]
1330    async fn tokio_engine_spawn_and_abort_round_trip() {
1331        use std::sync::Arc;
1332        use std::sync::atomic::{AtomicUsize, Ordering};
1333
1334        let counter = Arc::new(AtomicUsize::new(0));
1335        let c = counter.clone();
1336        let handle = <TokioEngine as Engine>::spawn(async move {
1337            c.fetch_add(1, Ordering::SeqCst);
1338        });
1339        // Drive the spawned task once.
1340        ::tokio::task::yield_now().await;
1341        // Awaiting the JoinHandle works on a non-aborted task.
1342        handle.await.expect("spawned task ran to completion");
1343        assert_eq!(counter.load(Ordering::SeqCst), 1);
1344
1345        // Spawn a second task that we abort before it can increment.
1346        let c2 = counter.clone();
1347        let mut handle2 = <TokioEngine as Engine>::spawn(async move {
1348            // Sleep forever — abort wins.
1349            ::tokio::time::sleep(std::time::Duration::from_hours(1)).await;
1350            c2.fetch_add(1, Ordering::SeqCst);
1351        });
1352        <TokioEngine as Engine>::abort_task(&mut handle2);
1353        // Second abort is a no-op.
1354        <TokioEngine as Engine>::abort_task(&mut handle2);
1355        assert_eq!(
1356            counter.load(Ordering::SeqCst),
1357            1,
1358            "aborted task must not run its body",
1359        );
1360    }
1361
1362    #[cfg(feature = "tokio")]
1363    #[::tokio::test(flavor = "current_thread", start_paused = true)]
1364    async fn tokio_engine_interval_first_tick_is_immediate() {
1365        use std::time::Duration;
1366
1367        let mut interval = <TokioEngine as Engine>::new_interval(Duration::from_secs(10));
1368        let start = ::tokio::time::Instant::now();
1369        <TokioEngine as Engine>::interval_tick(&mut interval).await;
1370        // First tick fires immediately per the tokio interval contract.
1371        assert_eq!(
1372            ::tokio::time::Instant::now().duration_since(start),
1373            Duration::ZERO,
1374            "first interval tick must fire immediately on tokio",
1375        );
1376        // Second tick waits for the period.
1377        <TokioEngine as Engine>::interval_tick(&mut interval).await;
1378        assert!(
1379            ::tokio::time::Instant::now().duration_since(start) >= Duration::from_secs(10),
1380            "second tick must wait one period",
1381        );
1382    }
1383
1384    #[cfg(feature = "moonpool")]
1385    #[::tokio::test(flavor = "current_thread", start_paused = true)]
1386    async fn moonpool_engine_spawn_and_abort_round_trip() {
1387        use std::sync::Arc;
1388        use std::sync::atomic::{AtomicUsize, Ordering};
1389
1390        use moonpool_core::TokioProviders;
1391
1392        type E = MoonpoolEngine<TokioProviders>;
1393
1394        let counter = Arc::new(AtomicUsize::new(0));
1395        let c = counter.clone();
1396        let handle = <E as Engine>::spawn(async move {
1397            c.fetch_add(1, Ordering::SeqCst);
1398        });
1399        ::tokio::task::yield_now().await;
1400        handle.await.expect("spawned task ran to completion");
1401        assert_eq!(counter.load(Ordering::SeqCst), 1);
1402
1403        let c2 = counter.clone();
1404        let mut handle2 = <E as Engine>::spawn(async move {
1405            ::tokio::time::sleep(std::time::Duration::from_hours(1)).await;
1406            c2.fetch_add(1, Ordering::SeqCst);
1407        });
1408        <E as Engine>::abort_task(&mut handle2);
1409        <E as Engine>::abort_task(&mut handle2);
1410        assert_eq!(
1411            counter.load(Ordering::SeqCst),
1412            1,
1413            "aborted task must not run its body",
1414        );
1415    }
1416
1417    #[cfg(feature = "moonpool")]
1418    #[::tokio::test(flavor = "current_thread", start_paused = true)]
1419    async fn moonpool_engine_interval_first_tick_is_immediate() {
1420        use std::time::Duration;
1421
1422        use moonpool_core::TokioProviders;
1423
1424        type E = MoonpoolEngine<TokioProviders>;
1425
1426        let mut interval = <E as Engine>::new_interval(Duration::from_secs(10));
1427        let start = ::tokio::time::Instant::now();
1428        <E as Engine>::interval_tick(&mut interval).await;
1429        assert_eq!(
1430            ::tokio::time::Instant::now().duration_since(start),
1431            Duration::ZERO,
1432            "first interval tick must fire immediately on moonpool",
1433        );
1434        <E as Engine>::interval_tick(&mut interval).await;
1435        assert!(
1436            ::tokio::time::Instant::now().duration_since(start) >= Duration::from_secs(10),
1437            "second tick must wait one period",
1438        );
1439    }
1440}