Skip to main content

magnetar/
builders.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Per-surface builders for [`crate::PulsarClient`] — extracted from
4//! `client.rs` so the central façade module stays focused on the
5//! `PulsarClient` surface, message types, interceptor traits, and the
6//! `Reader` impl. The builders here are
7//! [`ProducerBuilder`] / [`ConsumerBuilder`] / [`ReaderBuilder`],
8//! each carrying a phantom `E: Engine` parameter (default
9//! [`crate::TokioEngine`]) so `PulsarClient<E>::producer(...)` /
10//! `consumer(...)` / `reader(...)` dispatch through the
11//! engine-generic factory traits.
12//!
13//! **Engine-genericity.** The encryptor
14//! / decryptor storage is engine-typed via the per-engine
15//! [`crate::MessageEncryptorApi`] / [`crate::MessageDecryptorApi`]
16//! extension traits: tokio plugs in
17//! `Arc<dyn magnetar_runtime_tokio::MessageEncryptor>` and moonpool
18//! plugs in `Arc<dyn magnetar_runtime_moonpool::MessageEncryptor>`
19//! (both engines now ship the PIP-4 bridge). The chainable
20//! surface stays engine-agnostic — the `E: Engine` parameter only
21//! surfaces in the terminal `.create()` / `.subscribe()` dispatch
22//! through [`crate::CreateProducerApi`] / [`crate::SubscribeApi`], and
23//! in the per-engine `.create_with_encryption()` /
24//! `.subscribe_with_decryption()` specialisations.
25//!
26//! All three builders are re-exported from `magnetar::*` via the
27//! façade `lib.rs` so existing call sites keep working unchanged.
28
29use std::time::Duration;
30
31use magnetar_proto::conn::{CreateProducerRequest, SubscribeRequest};
32use magnetar_proto::pb;
33use magnetar_proto::types::CompressionKind;
34
35use crate::client::{PulsarClient, PulsarError, Reader};
36
37/// Result alias used inside this module, mirroring the one in
38/// `client.rs`.
39type Result<T, E = PulsarError> = std::result::Result<T, E>;
40
41/// Builder for a producer.
42///
43/// Phantom-generic over `E: Engine` per ADR-0026 §D1 — type
44/// parameter present (defaulting to [`crate::TokioEngine`]). Same lift
45/// pattern as [`ConsumerBuilder`]; the inherent impl methods stay
46/// tokio-bound until the [`crate::CreateProducerApi`] dispatch
47/// path lands (foundation traits added in commit `cc61d4d`).
48pub struct ProducerBuilder<'a, E: crate::Engine = crate::TokioEngine> {
49    client: &'a PulsarClient<E>,
50    req: CreateProducerRequest,
51    /// Engine-typed encryptor slot. Tokio resolves
52    /// `<TokioEngine as MessageEncryptorApi>::Encryptor` to
53    /// `Arc<dyn magnetar_runtime_tokio::MessageEncryptor>`; moonpool
54    /// resolves it to `Arc<dyn magnetar_runtime_moonpool::MessageEncryptor>`.
55    /// The generic `.create()` path **rejects** a configured encryptor — only
56    /// the per-engine `.create_with_encryption()` specialisations actually
57    /// open a PIP-4-encrypting producer.
58    ///
59    /// `MessageEncryptorApi` is a supertrait of [`crate::Engine`], so the
60    /// resolution is automatic — no extra bound needed at the use site.
61    encryptor: Option<<E as crate::MessageEncryptorApi>::Encryptor>,
62    /// Opt-in unique-suffix policy for [`Self::name`] (issue #406).
63    /// `false` — the default — keeps the pinned name the caller asked for.
64    unique_name_suffix: bool,
65}
66
67impl<E: crate::Engine> std::fmt::Debug for ProducerBuilder<'_, E> {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        f.debug_struct("ProducerBuilder")
70            .field("topic", &self.req.topic)
71            .field("producer_name", &self.req.producer_name)
72            .finish_non_exhaustive()
73    }
74}
75
76impl<'a, E: crate::Engine> ProducerBuilder<'a, E> {
77    pub(crate) fn new(client: &'a PulsarClient<E>, topic: String) -> Self {
78        let req = CreateProducerRequest {
79            topic,
80            ..CreateProducerRequest::default()
81        };
82        Self {
83            client,
84            req,
85            encryptor: None,
86            unique_name_suffix: false,
87        }
88    }
89
90    /// Set the optional producer name.
91    ///
92    /// The name is advertised verbatim unless [`Self::unique_name_suffix`] is
93    /// enabled.
94    #[must_use]
95    pub fn name(mut self, name: impl Into<String>) -> Self {
96        self.req.producer_name = Some(name.into());
97        self
98    }
99
100    /// Append an engine-generated unique suffix to the name set by
101    /// [`Self::name`]. **Off by default** — a pinned producer name stays
102    /// pinned.
103    ///
104    /// A pinned name is what makes a leaked broker-side producer fatal
105    /// (issue #406): the broker rejects every later open under a name it still
106    /// holds with `ProducerBusy` / `NamingException`, and a client that died
107    /// mid-open — or behind a proxy that outlives it — cannot send the
108    /// `CommandCloseProducer` that would free it. Enabling this makes each open
109    /// claim its own name, so a stranded registration can never collide with
110    /// the next one.
111    ///
112    /// The cost is the reason it is opt-in: a unique name breaks every
113    /// behaviour keyed on producer identity — `Exclusive` /
114    /// `WaitForExclusive` access-mode fencing, broker-side sequence-id dedup
115    /// across a restart, and dashboard/metric continuity. Enable it only for
116    /// `Shared` producers whose identity is disposable.
117    ///
118    /// With no [`Self::name`] set this is a no-op: the broker already assigns a
119    /// unique name of its own.
120    ///
121    /// The suffix comes from
122    /// [`crate::Engine::random_subscription_suffix`] — the same seam
123    /// [`ReaderBuilder`] uses for auto-generated subscription names — so the
124    /// tokio engine draws a UUID and the moonpool engine a deterministic
125    /// counter. Randomness stays out of `magnetar-proto`
126    /// (`ARCHITECTURE.md` § known non-determinism leaks).
127    #[must_use]
128    pub fn unique_name_suffix(mut self, enable: bool) -> Self {
129        self.unique_name_suffix = enable;
130        self
131    }
132
133    /// Apply the [`Self::unique_name_suffix`] policy to the request just
134    /// before it leaves the builder. Shared by [`Self::create`] and both
135    /// engine-specialised `create_with_encryption` entries.
136    fn resolve_producer_name(&mut self) {
137        if self.unique_name_suffix
138            && let Some(name) = self.req.producer_name.as_mut()
139        {
140            name.push('-');
141            name.push_str(&E::random_subscription_suffix());
142        }
143    }
144
145    /// Enable batching with the given limits.
146    #[must_use]
147    pub fn batching(mut self, max_messages: usize, max_bytes: usize) -> Self {
148        self.req.enable_batching = true;
149        self.req.max_messages_in_batch = max_messages;
150        self.req.max_batch_size_bytes = max_bytes;
151        self
152    }
153
154    /// Enable chunking for oversize messages.
155    #[must_use]
156    pub fn chunking(mut self, enable: bool) -> Self {
157        self.req.enable_chunking = enable;
158        self
159    }
160
161    /// Set the compression codec.
162    #[must_use]
163    pub fn compression(mut self, kind: CompressionKind) -> Self {
164        self.req.compression = kind;
165        self
166    }
167
168    /// Advertise the given Pulsar schema on `CommandProducer.schema`. The broker stores it
169    /// and surfaces it on the dashboard; magnetar does not enforce serialisation on its own
170    /// — pair with a [`crate::TypedProducer`] for that.
171    #[must_use]
172    pub fn schema(mut self, schema: pb::Schema) -> Self {
173        self.req.schema = Some(schema);
174        self
175    }
176
177    /// Mirrors Java `ProducerBuilder#initialSequenceId`. The producer's first publish gets
178    /// the supplied sequence id; the next one gets `id + 1`, and so on. Useful for resuming
179    /// at-least-once delivery from a checkpoint (where the caller knows the last sequence
180    /// id the broker acknowledged for this producer name).
181    #[must_use]
182    pub fn initial_sequence_id(mut self, id: u64) -> Self {
183        self.req.initial_sequence_id = Some(id);
184        self
185    }
186
187    /// Mirrors Java `ProducerBuilder#accessMode`. Defaults to `Shared`; switch to
188    /// `Exclusive` for single-writer-per-topic patterns, `WaitForExclusive` to queue
189    /// behind the current writer, or `ExclusiveWithFencing` to evict it.
190    #[must_use]
191    pub fn access_mode(mut self, mode: pb::ProducerAccessMode) -> Self {
192        self.req.access_mode = mode;
193        self
194    }
195
196    /// Mirrors Java `ProducerBuilder#property`. Appends a `(key, value)` entry to the
197    /// producer metadata advertised on `CommandProducer.metadata`. Visible on the broker
198    /// dashboard alongside the producer.
199    #[must_use]
200    pub fn property(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
201        self.req.producer_metadata.push((key.into(), value.into()));
202        self
203    }
204
205    /// Mirrors Java `ProducerBuilder#sendTimeout`. In-flight sends past
206    /// `enqueued_at + timeout` resolve with a synthetic `SendError` carrying
207    /// `code=-1, message="send timeout"` on the next state-machine tick.
208    ///
209    /// The default is **30 s** (Java parity — `sendTimeoutMs = 30000`, ADR-0072),
210    /// so a send whose receipt is lost or corrupted in flight fails deterministically
211    /// rather than hanging forever. Call [`Self::disable_send_timeout`] for the
212    /// unbounded (never-times-out) behavior.
213    #[must_use]
214    pub fn send_timeout(mut self, timeout: Duration) -> Self {
215        self.req.send_timeout = Some(timeout);
216        self
217    }
218
219    /// Disable the send timeout: in-flight sends never resolve with a synthetic
220    /// timeout `SendError` — they wait indefinitely for the broker's receipt
221    /// (or a session-loss / terminal error). Mirrors Java
222    /// `ProducerBuilder#sendTimeout(0, …)`. Overrides the 30 s default (ADR-0072).
223    #[must_use]
224    pub fn disable_send_timeout(mut self) -> Self {
225        self.req.send_timeout = None;
226        self
227    }
228
229    /// Mirrors Java `ProducerBuilder#batchingMaxPublishDelay`. With batching enabled,
230    /// the state machine flushes any non-empty batch whose oldest message has been waiting
231    /// longer than this duration. Caps end-to-end latency for batched sends that would
232    /// otherwise sit until the batch fills up.
233    #[must_use]
234    pub fn batching_max_publish_delay(mut self, delay: Duration) -> Self {
235        self.req.batching_max_publish_delay = Some(delay);
236        self
237    }
238
239    /// Open the producer via the engine-generic
240    /// [`crate::CreateProducerApi`] trait. Returns the engine's
241    /// concrete `Producer` type.
242    ///
243    /// **PIP-4 encryption guardrail (BREAKING since the encryptor-storage lift).**
244    /// If [`Self::encryption`] was called on the per-engine specialisation,
245    /// `.create()` returns [`PulsarError::Other`] instead of silently opening
246    /// a plaintext producer. The engine-generic dispatch does not know how to
247    /// thread an engine-typed encryptor through `open_producer`, so the
248    /// previous "silently drop the encryptor" behaviour was a footgun.
249    /// Use [`Self::create_with_encryption`] on the tokio /
250    /// moonpool specialisation instead.
251    ///
252    /// # Errors
253    /// - [`PulsarError::Other`] if an encryptor was configured via [`Self::encryption`] — call
254    ///   `create_with_encryption()` instead.
255    /// - [`PulsarError::Other`] (stringified) on broker rejection or wire failure.
256    pub async fn create(
257        mut self,
258    ) -> Result<<E::ClientState as crate::CreateProducerApi>::Producer, PulsarError>
259    where
260        E::ClientState: crate::BrokerMetadataApi + crate::CreateProducerApi,
261    {
262        self.resolve_producer_name();
263        if self.encryptor.is_some() {
264            return Err(PulsarError::Other(
265                "ProducerBuilder::create() refuses a configured encryptor — \
266                 use create_with_encryption() on the engine-specific builder \
267                 (PIP-4 encryptors are engine-typed and cannot dispatch \
268                 through the engine-generic CreateProducerApi)"
269                    .to_owned(),
270            ));
271        }
272        // Pre-check the partition metadata: if the topic happens to be
273        // partitioned, opening a bare-topic producer surfaces as the broker's
274        // `NotAllowedError(22) "Found partitioned metadata for non-partitioned
275        // topic"`. Catch that here and surface an actionable error pointing at
276        // `client.partitioned_producer(...)`, which fans out into one child
277        // producer per partition (the Java client's
278        // `PulsarClientImpl#createProducerAsync` does the same thing
279        // transparently — magnetar makes the routing explicit per ADR-0051).
280        //
281        // The per-partition fast path in `partitioned_topic_metadata`
282        // short-circuits `<base>-partition-<N>` suffixes to `N == 0` without
283        // a broker round-trip; for other topic names this is one
284        // `CommandPartitionedTopicMetadata` round-trip, the same cost the
285        // Java client pays.
286        let topic = self.req.topic.clone();
287        let mut deadline =
288            crate::CreateProducerApi::new_producer_operation_deadline(&self.client.inner);
289        let partitions = crate::BrokerMetadataApi::partitioned_topic_metadata_with_deadline(
290            &self.client.inner,
291            &topic,
292            &mut deadline,
293        )
294        .await
295        .map_err(|err| PulsarError::Other(format!("partitioned_topic_metadata: {err}")))?;
296        if partitions > 0 {
297            return Err(PulsarError::Other(format!(
298                "topic `{topic}` is partitioned (broker reports {partitions} partitions); \
299                 call `client.partitioned_producer(\"{topic}\").create()` instead — \
300                 a bare `client.producer(t).create()` would round-trip to a broker \
301                 NotAllowedError(22) \"Found partitioned metadata for non-partitioned topic\"."
302            )));
303        }
304        crate::CreateProducerApi::open_producer_with_deadline(
305            &self.client.inner,
306            self.req,
307            &mut deadline,
308        )
309        .await
310        .map_err(|err| PulsarError::Other(format!("open_producer: {err}")))
311    }
312}
313
314/// Tokio-engine-specific `ProducerBuilder` methods that depend on the
315/// tokio `MessageEncryptor` extension. The moonpool equivalent lives in
316/// the `#[cfg(feature = "moonpool")]` block below (ADR-0044).
317impl ProducerBuilder<'_, crate::TokioEngine> {
318    /// Set the PIP-4 encryptor. The encryptor is consulted on every
319    /// `send()` to wrap the (post-compression) payload.
320    #[must_use]
321    pub fn encryption(
322        mut self,
323        encryptor: std::sync::Arc<dyn magnetar_runtime_tokio::MessageEncryptor>,
324    ) -> Self {
325        // `<TokioEngine as MessageEncryptorApi>::Encryptor` resolves
326        // exactly to `Arc<dyn MessageEncryptor>` so we store the arg
327        // directly into the engine-typed slot.
328        self.encryptor = Some(encryptor);
329        self
330    }
331
332    /// Open the producer honoring the configured encryptor (PIP-4).
333    /// Tokio-engine-only — use [`Self::create`] for the engine-generic
334    /// path that ignores the encryptor.
335    ///
336    /// # Errors
337    /// - [`PulsarError::Client`] on broker rejection or wire failure.
338    pub async fn create_with_encryption(mut self) -> Result<magnetar_runtime_tokio::Producer> {
339        self.resolve_producer_name();
340        Ok(self
341            .client
342            .inner
343            .open_producer_with(self.req, self.encryptor)
344            .await?)
345    }
346}
347
348/// Moonpool-engine-specific `ProducerBuilder` methods that depend on the
349/// moonpool `MessageEncryptor` extension (PIP-4). 1:1 mirror of the tokio
350/// specialisation above — the moonpool runtime now ships the same encryption
351/// hook surface, so the façade exposes the same `.encryption()` +
352/// `.create_with_encryption()` chain for the moonpool engine.
353#[cfg(feature = "moonpool")]
354impl<P: moonpool_core::Providers + Send + Sync + 'static>
355    ProducerBuilder<'_, crate::MoonpoolEngine<P>>
356{
357    /// Set the PIP-4 encryptor. The encryptor is consulted on every
358    /// `send()` to wrap the (post-compression) payload.
359    #[must_use]
360    pub fn encryption(
361        mut self,
362        encryptor: std::sync::Arc<dyn magnetar_runtime_moonpool::MessageEncryptor>,
363    ) -> Self {
364        // `<MoonpoolEngine<P> as MessageEncryptorApi>::Encryptor` resolves
365        // exactly to `Arc<dyn MessageEncryptor>` so we store the arg
366        // directly into the engine-typed slot.
367        self.encryptor = Some(encryptor);
368        self
369    }
370
371    /// Open the producer honoring the configured encryptor (PIP-4).
372    /// Moonpool-engine-only — use [`Self::create`] for the engine-generic
373    /// path that ignores the encryptor.
374    ///
375    /// # Errors
376    /// - [`PulsarError::Other`] (stringified) on broker rejection or wire failure.
377    pub async fn create_with_encryption(
378        mut self,
379    ) -> Result<magnetar_runtime_moonpool::Producer<P>> {
380        self.resolve_producer_name();
381        self.client
382            .inner
383            .open_producer_with(self.req, self.encryptor)
384            .await
385            .map_err(|err| PulsarError::Other(format!("open_producer: {err}")))
386    }
387}
388
389/// Builder for a consumer.
390///
391/// Engine-generic over `E: Engine` per ADR-0026 §D1 (default
392/// [`crate::TokioEngine`]). The base `subscribe()` dispatches through
393/// the [`crate::SubscribeApi`] extension trait implemented by both
394/// runtimes' `Client`; the per-engine PIP-4 decryption knobs live on the
395/// engine-specialised `impl ConsumerBuilder<TokioEngine>` /
396/// `#[cfg(feature = "moonpool")]` blocks (ADR-0044).
397pub struct ConsumerBuilder<'a, E: crate::Engine = crate::TokioEngine> {
398    client: &'a PulsarClient<E>,
399    req: SubscribeRequest,
400    /// Engine-typed decryptor slot. See
401    /// [`ProducerBuilder`] for the analogous tokio /
402    /// moonpool split; same per-engine
403    /// [`crate::MessageDecryptorApi`] resolution (supertrait of
404    /// [`crate::Engine`], so no extra bound needed at the use site).
405    ///
406    /// The generic `.subscribe()` path **rejects** a configured decryptor —
407    /// only the per-engine `.subscribe_with_decryption()` specialisations
408    /// actually open a PIP-4-decrypting consumer.
409    decryptor: Option<<E as crate::MessageDecryptorApi>::Decryptor>,
410    /// Optional push-delivery callback (Java `ConsumerBuilder#messageListener`).
411    /// Engine-agnostic — the façade [`crate::MessageListener`] takes the façade
412    /// [`crate::IncomingMessage`], which both engines produce. Set it via
413    /// [`Self::message_listener`] and subscribe via
414    /// [`Self::subscribe_with_listener`]; the plain [`Self::subscribe`] ignores
415    /// it and returns a pull-mode consumer.
416    listener: Option<crate::MessageListener>,
417    /// Optional Failover active/standby callback (Java
418    /// `ConsumerBuilder#consumerEventListener`, issue #348). Set via
419    /// [`Self::consumer_event_listener`] and subscribe via
420    /// [`Self::subscribe_with_event_listener`]; the plain [`Self::subscribe`]
421    /// (and [`Self::subscribe_with_listener`]) ignore it.
422    event_listener: Option<crate::ConsumerEventListener>,
423}
424
425impl<E: crate::Engine> std::fmt::Debug for ConsumerBuilder<'_, E> {
426    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
427        f.debug_struct("ConsumerBuilder")
428            .field("topic", &self.req.topic)
429            .field("subscription", &self.req.subscription)
430            .finish_non_exhaustive()
431    }
432}
433
434impl<'a, E: crate::Engine> ConsumerBuilder<'a, E> {
435    pub(crate) fn new(client: &'a PulsarClient<E>, topic: String) -> Self {
436        let req = SubscribeRequest {
437            topic,
438            ..SubscribeRequest::default()
439        };
440        Self {
441            client,
442            req,
443            decryptor: None,
444            listener: None,
445            event_listener: None,
446        }
447    }
448
449    /// Read-only snapshot of the [`SubscribeRequest`] this builder has assembled
450    /// so far. Test-support seam (`#[doc(hidden)]`, not part of the stable API):
451    /// lets the builder-surface guard test assert that each of the five consumer
452    /// builders seeds the bounded-chunk-reassembly knobs into the request that
453    /// seeds `ConsumerState`, without opening a real broker connection.
454    #[doc(hidden)]
455    #[must_use]
456    pub fn request_snapshot(&self) -> &SubscribeRequest {
457        &self.req
458    }
459
460    /// Test-support seam (`#[doc(hidden)]`, not part of the stable API):
461    /// `true` once a push-delivery listener has been set via
462    /// [`Self::message_listener`]. Lets the builder-surface guard test pin the
463    /// listener → field wiring without opening a real broker connection.
464    #[doc(hidden)]
465    #[must_use]
466    pub fn has_listener_for_test(&self) -> bool {
467        self.listener.is_some()
468    }
469
470    /// Test-support seam (`#[doc(hidden)]`, not part of the stable API):
471    /// `true` once a [`crate::ConsumerEventListener`] has been set via
472    /// [`Self::consumer_event_listener`]. Lets the builder-surface guard test
473    /// pin the listener → field wiring without opening a real broker
474    /// connection.
475    #[doc(hidden)]
476    #[must_use]
477    pub fn has_event_listener_for_test(&self) -> bool {
478        self.event_listener.is_some()
479    }
480
481    /// Required: set the subscription name.
482    #[must_use]
483    pub fn subscription(mut self, name: impl Into<String>) -> Self {
484        self.req.subscription = name.into();
485        self
486    }
487
488    /// Set the subscription type.
489    #[must_use]
490    pub fn subscription_type(mut self, sub_type: pb::command_subscribe::SubType) -> Self {
491        self.req.sub_type = sub_type;
492        self
493    }
494
495    /// Set the consumer name.
496    #[must_use]
497    pub fn name(mut self, name: impl Into<String>) -> Self {
498        self.req.consumer_name = Some(name.into());
499        self
500    }
501
502    /// Set the receiver queue size (the fixed permit count handed to the broker).
503    ///
504    /// This is sugar for [`Self::receiver_queue_policy`]`(`[`magnetar_proto::Fixed`]`(size))`:
505    /// it pins the queue to a constant size, the historical (and default)
506    /// behaviour. To let the queue self-tune under load, use
507    /// [`Self::receiver_queue_policy`] with [`magnetar_proto::Auto`] instead.
508    #[must_use]
509    pub fn receiver_queue_size(mut self, size: usize) -> Self {
510        self.req.receiver_queue_size = size;
511        // Clearing any previously-set policy keeps `receiver_queue_size` and
512        // `receiver_queue_policy` from disagreeing: the last setter wins.
513        self.req.receiver_queue_policy = None;
514        self.req.receiver_queue_adjust_interval = None;
515        self
516    }
517
518    /// Set a pluggable receiver-queue-size policy (issue #301, PIP-74
519    /// `autoScaledReceiverQueueSizeEnabled` parity).
520    ///
521    /// Pass [`magnetar_proto::Auto`] (wrapped in `Arc`) to let the queue grow
522    /// under starvation and shrink under memory pressure, or any custom
523    /// [`magnetar_proto::ReceiverQueuePolicy`]. The policy's `adjust` is called
524    /// from the connection's deterministic timeout tick, so it MUST be pure (no
525    /// clock, no RNG, no I/O) — see the trait docs.
526    ///
527    /// Enabling a policy turns on auto-adjust with a default 5-second tick;
528    /// override the cadence with [`Self::receiver_queue_adjust_interval`].
529    ///
530    /// # Example
531    ///
532    /// ```no_run
533    /// # use std::sync::Arc;
534    /// # use magnetar_proto::Auto;
535    /// # fn demo<'a>(b: magnetar::ConsumerBuilder<'a>) -> magnetar::ConsumerBuilder<'a> {
536    /// b.receiver_queue_policy(Arc::new(Auto::new(1_000, 128 * 1024 * 1024)))
537    /// # }
538    /// ```
539    #[must_use]
540    pub fn receiver_queue_policy(
541        mut self,
542        policy: std::sync::Arc<dyn magnetar_proto::ReceiverQueuePolicy>,
543    ) -> Self {
544        self.req.receiver_queue_policy = Some(policy);
545        // Default auto-adjust cadence; tunable via `receiver_queue_adjust_interval`.
546        if self.req.receiver_queue_adjust_interval.is_none() {
547            self.req.receiver_queue_adjust_interval = Some(Duration::from_secs(5));
548        }
549        self
550    }
551
552    /// Override the auto-adjust tick cadence used when a
553    /// [`Self::receiver_queue_policy`] is set. No effect with the default
554    /// [`magnetar_proto::Fixed`] policy. Mirrors how often Java re-evaluates its
555    /// auto-scaled receiver queue.
556    #[must_use]
557    pub fn receiver_queue_adjust_interval(mut self, interval: Duration) -> Self {
558        self.req.receiver_queue_adjust_interval = Some(interval);
559        self
560    }
561
562    /// Choose between a durable subscription (cursor persisted broker-side, the default)
563    /// and a non-durable one (used by [`Reader`] / streaming use cases).
564    #[must_use]
565    pub fn durable(mut self, durable: bool) -> Self {
566        self.req.durable = durable;
567        self
568    }
569
570    /// Set the initial position the broker dispatches from when the subscription is new.
571    #[must_use]
572    pub fn initial_position(mut self, position: pb::command_subscribe::InitialPosition) -> Self {
573        self.req.initial_position = position;
574        self
575    }
576
577    /// Read from the compacted (key-deduplicated) view of the topic. Required by
578    /// [`crate::TableView`] and by any "latest-value-per-key" workflow against compacted topics.
579    #[must_use]
580    pub fn read_compacted(mut self, on: bool) -> Self {
581        self.req.read_compacted = on;
582        self
583    }
584
585    /// Advertise the given Pulsar schema on `CommandSubscribe.schema`. The broker uses it
586    /// for schema-version negotiation; magnetar does not enforce deserialisation on its own
587    /// — pair with a [`crate::TypedConsumer`] for that.
588    #[must_use]
589    pub fn schema(mut self, schema: pb::Schema) -> Self {
590        self.req.schema = Some(schema);
591        self
592    }
593
594    /// Mirrors Java `ConsumerBuilder#priorityLevel`. The broker uses the value for Shared
595    /// / Failover dispatch ordering — higher-priority consumers receive messages first.
596    #[must_use]
597    pub fn priority_level(mut self, level: i32) -> Self {
598        self.req.priority_level = Some(level);
599        self
600    }
601
602    /// Append a (key, value) entry to the subscription properties advertised on
603    /// `CommandSubscribe.subscription_properties`. Mirrors Java
604    /// `ConsumerBuilder#subscriptionProperties` (one entry at a time).
605    #[must_use]
606    pub fn subscription_property(
607        mut self,
608        key: impl Into<String>,
609        value: impl Into<String>,
610    ) -> Self {
611        self.req
612            .subscription_properties
613            .push((key.into(), value.into()));
614        self
615    }
616
617    /// Mirrors Java `ConsumerBuilder#keySharedPolicy`. Only meaningful when
618    /// [`Self::subscription_type`] is `Key_Shared`. The broker rejects the subscribe if
619    /// the config is invalid (e.g. overlapping sticky ranges across consumers in the same
620    /// subscription).
621    #[must_use]
622    pub fn key_shared_policy(mut self, cfg: magnetar_proto::KeySharedConfig) -> Self {
623        self.req.key_shared = Some(cfg);
624        self
625    }
626
627    /// Mirrors Java `ConsumerBuilder#startMessageId`. Overrides the initial position with a
628    /// specific message id. Only honoured for fresh subscriptions — has no effect if the
629    /// subscription already has a persisted cursor.
630    #[must_use]
631    pub fn start_message_id(mut self, id: magnetar_proto::MessageId) -> Self {
632        self.req.start_message_id = Some(id);
633        self
634    }
635
636    /// Mirrors Java `ConsumerBuilder#replicateSubscriptionState`. When `true`, the broker
637    /// replicates this subscription's cursor across geo-replicated clusters.
638    #[must_use]
639    pub fn replicate_subscription_state(mut self, on: bool) -> Self {
640        self.req.replicate_subscription_state = Some(on);
641        self
642    }
643
644    /// Mirrors Java `ConsumerBuilder#enableTopicCreation`. When `false`, the broker fails
645    /// the subscribe if the topic doesn't already exist. Defaults to the broker default
646    /// (which is `true`).
647    #[must_use]
648    pub fn force_topic_creation(mut self, on: bool) -> Self {
649        self.req.force_topic_creation = Some(on);
650        self
651    }
652
653    /// Mirrors Java's `startMessageRollbackDuration` knob — rolls the subscription cursor
654    /// back by `seconds` at subscribe time so the consumer re-reads recent history. Useful
655    /// for "catch up on the last hour" patterns.
656    #[must_use]
657    pub fn start_message_rollback_duration(mut self, seconds: u64) -> Self {
658        self.req.start_message_rollback_duration_sec = Some(seconds);
659        self
660    }
661
662    /// Mirrors Java `ConsumerBuilder#property`. Appends a `(key, value)` entry to the
663    /// consumer metadata advertised on `CommandSubscribe.metadata`. Visible on the broker
664    /// dashboard alongside the consumer.
665    #[must_use]
666    pub fn property(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
667        self.req.consumer_metadata.push((key.into(), value.into()));
668        self
669    }
670
671    /// Mirrors Java `ConsumerBuilder#negativeAckRedeliveryDelay`. When set, the consumer
672    /// keeps nacked ids locally and defers the redelivery command until the delay has
673    /// elapsed. The state machine drives the timer on its existing keepalive tick.
674    #[must_use]
675    pub fn negative_ack_redelivery_delay(mut self, delay: Duration) -> Self {
676        self.req.negative_ack_redelivery_delay = Some(delay);
677        self
678    }
679
680    /// Mirrors Java `ConsumerBuilder#ackTimeout`. The consumer client-tracks every
681    /// delivered message and forces a redelivery if no positive ack arrives within
682    /// `timeout`. The state machine drives the tracker on its existing tick.
683    #[must_use]
684    pub fn ack_timeout(mut self, timeout: Duration) -> Self {
685        self.req.ack_timeout = Some(timeout);
686        self
687    }
688
689    /// Mirrors Java `ConsumerBuilder#ackTimeoutRedeliveryBackoff`. PIP-37 backoff applied to
690    /// the per-message ack-timeout deadline using the broker-reported `redelivery_count`.
691    /// Has no effect unless [`Self::ack_timeout`] is also set.
692    #[must_use]
693    pub fn ack_timeout_backoff(
694        mut self,
695        backoff: magnetar_proto::trackers::MultiplierRedeliveryBackoff,
696    ) -> Self {
697        self.req.ack_timeout_backoff = Some(backoff);
698        self
699    }
700
701    /// Mirrors Java `ConsumerBuilder#acknowledgmentGroupTime`. When set, calls to
702    /// [`magnetar_runtime_tokio::Consumer::ack_grouped`] (and
703    /// `ack_grouped_cumulative`) stage acks in an in-memory tracker and the state
704    /// machine flushes them as one coalesced `CommandAck` every `window`. Trades
705    /// broker-confirmation guarantees for lower ack bandwidth on high-throughput
706    /// consumers. Has no effect on the synchronous [`Self::ack_timeout`] or the
707    /// awaited `Consumer::ack` paths.
708    #[must_use]
709    pub fn ack_group_time(mut self, window: Duration) -> Self {
710        self.req.ack_group_time = Some(window);
711        self
712    }
713
714    /// Mirrors Java `ConsumerBuilder#cryptoFailureAction`. Controls what the consumer does
715    /// when payload decryption fails (PIP-4): `Fail` (default) propagates the error,
716    /// `Discard` silently drops the message, `Consume` returns the encrypted ciphertext
717    /// as-is. All three arms are honored by the
718    /// [`magnetar_runtime_tokio::Consumer`] receive path.
719    #[must_use]
720    pub fn crypto_failure_action(
721        mut self,
722        action: magnetar_proto::conn::CryptoFailureAction,
723    ) -> Self {
724        self.req.crypto_failure_action = action;
725        self
726    }
727
728    /// Mirrors Java `ConsumerBuilder#maxPendingChunkedMessage` (default `10`).
729    /// Bounds the number of distinct incomplete chunked messages the consumer
730    /// buffers; on breach the oldest incomplete message is evicted. `0` disables
731    /// the cap. Guards against a hostile/buggy broker streaming distinct-UUID
732    /// first chunks that never complete (unbounded `chunk_reassembly` growth →
733    /// OOM).
734    #[must_use]
735    pub fn max_pending_chunked_message(mut self, max: usize) -> Self {
736        self.req.max_pending_chunked_message = max;
737        self
738    }
739
740    /// Mirrors Java `ConsumerBuilder#autoAckOldestChunkedMessageOnQueueFull`
741    /// (default `false`). When `true`, an evicted/expired partial chunked
742    /// message's first-chunk id is acked before drop (the broker treats it as
743    /// consumed); when `false`, it is dropped without acking so the broker
744    /// redelivers the whole message.
745    #[must_use]
746    pub fn auto_ack_oldest_chunked_message_on_queue_full(mut self, auto_ack: bool) -> Self {
747        self.req.auto_ack_oldest_chunked_message_on_queue_full = auto_ack;
748        self
749    }
750
751    /// Mirrors Java `ConsumerBuilder#expireTimeOfIncompleteChunkedMessage`
752    /// (default `60s`). An incomplete chunked message older than this is swept
753    /// on the connection's existing timeout tick and dropped (or acked, per
754    /// [`Self::auto_ack_oldest_chunked_message_on_queue_full`]).
755    #[must_use]
756    pub fn expire_time_of_incomplete_chunked_message(mut self, expire: Duration) -> Self {
757        self.req.expire_time_of_incomplete_chunked_message = Some(expire);
758        self
759    }
760
761    /// Mirrors Java `ConsumerBuilder#deadLetterPolicy`. After `max_redeliver_count`
762    /// redeliveries, the consumer flags the message as dead-letter — drain via
763    /// [`magnetar_runtime_tokio::Consumer::drain_dead_letter`] and republish to
764    /// `dead_letter_topic` (or to the Java-default `<topic>-<subscription>-DLQ` when
765    /// `dead_letter_topic` is `None`).
766    ///
767    /// `0` disables DLQ routing (the default).
768    #[must_use]
769    pub fn dead_letter_policy(
770        mut self,
771        max_redeliver_count: u32,
772        dead_letter_topic: Option<String>,
773    ) -> Self {
774        self.req.max_redeliver_count = max_redeliver_count;
775        self.req.dead_letter_topic = dead_letter_topic;
776        self
777    }
778
779    /// Subscribe via the engine-generic [`crate::SubscribeApi`] trait.
780    /// Returns the engine's concrete `Consumer` type.
781    ///
782    /// **PIP-4 decryption guardrail (BREAKING since the decryptor-storage lift).**
783    /// If [`Self::encryption`] was called on the per-engine specialisation,
784    /// `.subscribe()` returns [`PulsarError::Other`] instead of silently opening
785    /// a plaintext consumer. The engine-generic dispatch cannot thread an
786    /// engine-typed decryptor through `subscribe`, so the previous "silently
787    /// drop the decryptor" behaviour was a footgun. Use
788    /// [`Self::subscribe_with_decryption`] on the tokio / moonpool
789    /// specialisation instead.
790    ///
791    /// # Errors
792    /// - [`PulsarError::Other`] if a decryptor was configured via [`Self::encryption`] — call
793    ///   `subscribe_with_decryption()` instead.
794    /// - [`PulsarError::Other`] (stringified) on broker rejection or wire failure.
795    pub async fn subscribe(
796        self,
797    ) -> Result<<E::ClientState as crate::SubscribeApi>::Consumer, PulsarError>
798    where
799        E::ClientState: crate::SubscribeApi,
800    {
801        let mut deadline =
802            crate::SubscribeApi::new_subscribe_operation_deadline(&self.client.inner);
803        self.subscribe_with_deadline(&mut deadline).await
804    }
805
806    pub(crate) async fn subscribe_with_deadline(
807        self,
808        deadline: &mut crate::OperationDeadline,
809    ) -> Result<<E::ClientState as crate::SubscribeApi>::Consumer, PulsarError>
810    where
811        E::ClientState: crate::SubscribeApi,
812    {
813        if self.decryptor.is_some() {
814            return Err(PulsarError::Other(
815                "ConsumerBuilder::subscribe() refuses a configured decryptor — \
816                 use subscribe_with_decryption() on the engine-specific builder \
817                 (PIP-4 decryptors are engine-typed and cannot dispatch \
818                 through the engine-generic SubscribeApi)"
819                    .to_owned(),
820            ));
821        }
822        crate::SubscribeApi::subscribe_with_deadline(&self.client.inner, self.req, deadline)
823            .await
824            .map_err(|err| PulsarError::Other(format!("subscribe: {err}")))
825    }
826
827    /// Register a push-delivery callback (Java
828    /// `ConsumerBuilder#messageListener`). Once set, subscribe via
829    /// [`Self::subscribe_with_listener`] to start a background poller that
830    /// drives `receive()` and hands every message to `listener`, sequentially
831    /// and in order.
832    ///
833    /// The plain [`Self::subscribe`] does **not** consult the listener — it
834    /// returns a pull-mode consumer. Pull and push are mutually exclusive
835    /// (Java forbids `receive()` on a listener-backed consumer): use
836    /// `subscribe_with_listener` for push and never call `receive()`, or use
837    /// `subscribe` for pull and call `receive()` yourself.
838    ///
839    /// The callback **must ack explicitly** — the poller never auto-acks (Java
840    /// parity). Hold a clone of your consumer in the closure to ack.
841    #[must_use]
842    pub fn message_listener(mut self, listener: crate::MessageListener) -> Self {
843        self.listener = Some(listener);
844        self
845    }
846
847    /// Register a Failover active/standby callback (Java
848    /// `ConsumerBuilder#consumerEventListener`, issue #348). Subscribe via
849    /// [`Self::subscribe_with_event_listener`] to spawn the poller that
850    /// drives it; the plain [`Self::subscribe`] and
851    /// [`Self::subscribe_with_listener`] ignore it.
852    ///
853    /// The callback fires [`crate::ConsumerEvent::BecameActive`] /
854    /// [`crate::ConsumerEvent::BecameInactive`] once per broker-reported
855    /// `CommandActiveConsumerChange`, sequentially, from the detached poller
856    /// task — never under any lock.
857    #[must_use]
858    pub fn consumer_event_listener(mut self, listener: crate::ConsumerEventListener) -> Self {
859        self.event_listener = Some(listener);
860        self
861    }
862
863    /// Subscribe and start a push-delivery poller over the resulting consumer,
864    /// returning the owning [`crate::MessageListenerHandle`]. Mirrors Java's
865    /// `ConsumerBuilder#messageListener(...)` + `subscribe()` flow.
866    ///
867    /// The poller delivers messages sequentially and in order, does **not**
868    /// auto-ack (the callback acks explicitly), and stops cleanly when the
869    /// consumer is closed or the returned handle is dropped. Because the
870    /// consumer is moved into the poller, there is no handle left to call
871    /// `receive()` on — the listener owns delivery (matching Java's
872    /// "no `receive()` with a `messageListener`" rule).
873    ///
874    /// # Errors
875    /// - [`PulsarError::Config`] if no listener was set via [`Self::message_listener`].
876    /// - [`PulsarError::Other`] (stringified) on broker rejection or wire failure.
877    pub async fn subscribe_with_listener(self) -> Result<crate::MessageListenerHandle, PulsarError>
878    where
879        E::ClientState: crate::SubscribeApi,
880        <E::ClientState as crate::SubscribeApi>::Consumer: Clone,
881    {
882        let Some(listener) = self.listener.clone() else {
883            return Err(PulsarError::Config(
884                "subscribe_with_listener() requires a listener — \
885                 call message_listener(...) first (or use subscribe() for pull mode)"
886                    .to_owned(),
887            ));
888        };
889        let consumer = self.subscribe().await?;
890        Ok(crate::consumer_listener::spawn_message_listener(
891            consumer, listener,
892        ))
893    }
894
895    /// Subscribe and start a Failover active/standby poller over the
896    /// resulting consumer, returning the owning
897    /// [`crate::ConsumerEventListenerHandle`] (issue #348). Mirrors Java's
898    /// `ConsumerBuilder#consumerEventListener(...)` + `subscribe()` flow.
899    ///
900    /// The consumer is moved into the poller, so there is no handle left to
901    /// call on this terminal — hold a separate clone (subscribe pull-mode,
902    /// then use [`crate::spawn_consumer_event_listener`] directly on a
903    /// clone) if you also need to receive messages from the same consumer.
904    ///
905    /// # Errors
906    /// - [`PulsarError::Config`] if no listener was set via [`Self::consumer_event_listener`].
907    /// - [`PulsarError::Other`] (stringified) on broker rejection or wire failure.
908    pub async fn subscribe_with_event_listener(
909        self,
910    ) -> Result<crate::ConsumerEventListenerHandle, PulsarError>
911    where
912        E::ClientState: crate::SubscribeApi,
913        <E::ClientState as crate::SubscribeApi>::Consumer: Clone,
914    {
915        let Some(listener) = self.event_listener.clone() else {
916            return Err(PulsarError::Config(
917                "subscribe_with_event_listener() requires a listener — \
918                 call consumer_event_listener(...) first (or use subscribe() for pull mode)"
919                    .to_owned(),
920            ));
921        };
922        let consumer = self.subscribe().await?;
923        Ok(crate::consumer_listener::spawn_consumer_event_listener(
924            consumer, listener,
925        ))
926    }
927}
928
929/// Tokio-engine-specific `ConsumerBuilder` methods that depend on the
930/// tokio `MessageDecryptor` extension. The moonpool equivalent lives in
931/// the `#[cfg(feature = "moonpool")]` block below (ADR-0044).
932impl ConsumerBuilder<'_, crate::TokioEngine> {
933    /// Configure PIP-4 end-to-end decryption. The decryptor is consulted on every received
934    /// message whose `MessageMetadata.encryption_keys` is non-empty.
935    #[must_use]
936    pub fn encryption(
937        mut self,
938        decryptor: std::sync::Arc<dyn magnetar_runtime_tokio::MessageDecryptor>,
939    ) -> Self {
940        // `<TokioEngine as MessageDecryptorApi>::Decryptor` resolves
941        // exactly to `Arc<dyn MessageDecryptor>` so we store the arg
942        // directly into the engine-typed slot.
943        self.decryptor = Some(decryptor);
944        self
945    }
946
947    /// Subscribe with the configured decryptor (PIP-4). Tokio-engine-only.
948    /// Use [`Self::subscribe`] for the engine-generic path that ignores
949    /// the decryptor.
950    ///
951    /// # Errors
952    /// - [`PulsarError::Client`] on broker rejection or wire failure.
953    pub async fn subscribe_with_decryption(self) -> Result<magnetar_runtime_tokio::Consumer> {
954        Ok(self
955            .client
956            .inner
957            .subscribe_with(self.req, self.decryptor)
958            .await?)
959    }
960}
961
962/// Moonpool-engine-specific `ConsumerBuilder` methods that depend on the
963/// moonpool `MessageDecryptor` extension (PIP-4). 1:1 mirror of the tokio
964/// specialisation above.
965#[cfg(feature = "moonpool")]
966impl<P: moonpool_core::Providers + Send + Sync + 'static>
967    ConsumerBuilder<'_, crate::MoonpoolEngine<P>>
968{
969    /// Configure PIP-4 end-to-end decryption. The decryptor is consulted on every received
970    /// message whose `MessageMetadata.encryption_keys` is non-empty.
971    #[must_use]
972    pub fn encryption(
973        mut self,
974        decryptor: std::sync::Arc<dyn magnetar_runtime_moonpool::MessageDecryptor>,
975    ) -> Self {
976        // `<MoonpoolEngine<P> as MessageDecryptorApi>::Decryptor` resolves
977        // exactly to `Arc<dyn MessageDecryptor>` so we store the arg
978        // directly into the engine-typed slot.
979        self.decryptor = Some(decryptor);
980        self
981    }
982
983    /// Subscribe with the configured decryptor (PIP-4). Moonpool-engine-only.
984    /// Use [`Self::subscribe`] for the engine-generic path that ignores
985    /// the decryptor.
986    ///
987    /// # Errors
988    /// - [`PulsarError::Other`] (stringified) on broker rejection or wire failure.
989    pub async fn subscribe_with_decryption(self) -> Result<magnetar_runtime_moonpool::Consumer<P>> {
990        self.client
991            .inner
992            .subscribe_with(self.req, self.decryptor)
993            .await
994            .map_err(|err| PulsarError::Other(format!("subscribe: {err}")))
995    }
996}
997
998/// Builder for a [`Reader`].
999///
1000/// Mirrors `org.apache.pulsar.client.api.ReaderBuilder`. Internally a `Reader` is just a
1001/// non-durable `Exclusive` consumer with an auto-generated subscription name — there's no
1002/// dedicated wire command, so the protocol layer doesn't need any extra plumbing.
1003///
1004/// Phantom-generic over `E: Engine` (defaults to [`crate::TokioEngine`]).
1005/// Wraps a [`ConsumerBuilder<E>`]; the impl methods stay tokio-bound
1006/// until the `SubscribeApi` dispatch path lands in the Builder lift
1007/// sub-PR.
1008pub struct ReaderBuilder<'a, E: crate::Engine = crate::TokioEngine> {
1009    inner: ConsumerBuilder<'a, E>,
1010}
1011
1012impl<E: crate::Engine> std::fmt::Debug for ReaderBuilder<'_, E> {
1013    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1014        f.debug_struct("ReaderBuilder")
1015            .field("inner", &self.inner)
1016            .finish()
1017    }
1018}
1019
1020impl<'a, E: crate::Engine> ReaderBuilder<'a, E> {
1021    pub(crate) fn new(client: &'a PulsarClient<E>, topic: String) -> Self {
1022        let subscription = format!("reader-{}", E::random_subscription_suffix());
1023        let inner = ConsumerBuilder::new(client, topic)
1024            .subscription(subscription)
1025            .subscription_type(pb::command_subscribe::SubType::Exclusive)
1026            .durable(false);
1027        Self { inner }
1028    }
1029
1030    /// Override the auto-generated subscription name. Rarely needed — Reader subscriptions
1031    /// are not visible on the broker dashboard anyway.
1032    #[must_use]
1033    pub fn subscription_name(mut self, name: impl Into<String>) -> Self {
1034        self.inner = self.inner.subscription(name);
1035        self
1036    }
1037
1038    /// Set the receiver queue size.
1039    #[must_use]
1040    pub fn receiver_queue_size(mut self, size: usize) -> Self {
1041        self.inner = self.inner.receiver_queue_size(size);
1042        self
1043    }
1044
1045    /// Set the consumer name advertised to the broker.
1046    #[must_use]
1047    pub fn name(mut self, name: impl Into<String>) -> Self {
1048        self.inner = self.inner.name(name);
1049        self
1050    }
1051
1052    /// Choose where the reader starts when its non-durable subscription is fresh.
1053    /// Defaults to [`pb::command_subscribe::InitialPosition::Latest`].
1054    #[must_use]
1055    pub fn start_position(mut self, position: pb::command_subscribe::InitialPosition) -> Self {
1056        self.inner = self.inner.initial_position(position);
1057        self
1058    }
1059
1060    /// Read from the compacted (key-deduplicated) view of the topic. Mirrors Java
1061    /// `ReaderBuilder#readCompacted`. Required for compacted-topic readers.
1062    #[must_use]
1063    pub fn read_compacted(mut self, on: bool) -> Self {
1064        self.inner = self.inner.read_compacted(on);
1065        self
1066    }
1067
1068    /// Override the initial message id the reader starts from. Mirrors Java
1069    /// `ReaderBuilder#startMessageId`. Pass [`magnetar_proto::MessageId::EARLIEST`] /
1070    /// [`magnetar_proto::MessageId::LATEST`] for the sentinel positions.
1071    #[must_use]
1072    pub fn start_message_id(mut self, id: magnetar_proto::MessageId) -> Self {
1073        self.inner = self.inner.start_message_id(id);
1074        self
1075    }
1076
1077    /// Mirrors `ConsumerBuilder::property`. The reader's underlying consumer carries the
1078    /// (key, value) pair on its `CommandSubscribe.metadata`.
1079    #[must_use]
1080    pub fn property(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
1081        self.inner = self.inner.property(key, value);
1082        self
1083    }
1084
1085    /// Roll the reader cursor back by `seconds` at create time. Mirrors Java
1086    /// `ReaderBuilder#startMessageIdInclusive` rollback knob.
1087    #[must_use]
1088    pub fn start_message_rollback_duration(mut self, seconds: u64) -> Self {
1089        self.inner = self.inner.start_message_rollback_duration(seconds);
1090        self
1091    }
1092
1093    /// Create the reader via the engine-generic
1094    /// [`crate::SubscribeApi`] dispatch path. Returns
1095    /// `Reader<<E::ClientState as SubscribeApi>::Consumer>` —
1096    /// resolves to `Reader<magnetar_runtime_tokio::Consumer>` (the
1097    /// default `Reader<>` alias) under the default
1098    /// `E = TokioEngine`.
1099    ///
1100    /// # Errors
1101    /// - [`PulsarError::Other`] on broker rejection or wire failure.
1102    pub async fn create(
1103        self,
1104    ) -> Result<Reader<<E::ClientState as crate::SubscribeApi>::Consumer>, PulsarError>
1105    where
1106        E::ClientState: crate::SubscribeApi,
1107    {
1108        let consumer = self.inner.subscribe().await?;
1109        Ok(Reader {
1110            consumer,
1111            last_received: parking_lot::Mutex::new(None),
1112        })
1113    }
1114}
1115
1116/// Tokio-engine-specific `ReaderBuilder` methods that depend on the
1117/// tokio `MessageDecryptor` extension (PIP-4).
1118impl ReaderBuilder<'_, crate::TokioEngine> {
1119    /// Mirrors Java `ReaderBuilder#cryptoKeyReader` — supplies a PIP-4 decryptor for the
1120    /// reader's underlying subscription.
1121    #[must_use]
1122    pub fn encryption(
1123        mut self,
1124        decryptor: std::sync::Arc<dyn magnetar_runtime_tokio::MessageDecryptor>,
1125    ) -> Self {
1126        self.inner = self.inner.encryption(decryptor);
1127        self
1128    }
1129}