Skip to main content

magnetar/
typed.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Strongly-typed producer and consumer wrappers.
4//!
5//! Mirrors Java's `Producer<T>` / `Consumer<T>` shape, where `T` is the value type produced or
6//! consumed and a [`magnetar_proto::schema::Schema`] handles the serialisation.
7//!
8//! [`TypedProducer`] wraps a runtime [`Producer`](magnetar_runtime_tokio::Producer) and a
9//! schema; calling `send(value)` encodes the value, stamps `MessageMetadata.partition_key` when
10//! a key is supplied, and forwards to the inner producer. [`TypedConsumer`] does the inverse on
11//! the receive path, returning [`TypedMessage<S>`] (payload + decoded value + message id).
12//!
13//! Both wrappers stamp the schema's wire bytes on the underlying open frames via the
14//! `magnetar_proto` schema field on `CreateProducerRequest` / `SubscribeRequest`, so the broker
15//! records the schema and surfaces it to the dashboard.
16//!
17//! # Engine-generic surfaces (ADR-0026 §D1)
18//!
19//! `TypedProducer<S, P>` and `TypedConsumer<S, C>` are generic over
20//! their inner runtime type (`P: ProducerApi`, `C: ConsumerApi`). The
21//! defaults (`P = magnetar_runtime_tokio::Producer`,
22//! `C = magnetar_runtime_tokio::Consumer`) keep existing callers
23//! pointing at the tokio specialisation without naming the producer /
24//! consumer type. Moonpool callers name
25//! `TypedProducer<S, magnetar_runtime_moonpool::Producer<P>>` and
26//! `TypedConsumer<S, magnetar_runtime_moonpool::Consumer<P>>`.
27//!
28//! Methods that depend on the runtime's `magnetar_proto::IncomingMessage`
29//! shape (the `receive` family, `receive_batch`, `reconsume_later`,
30//! `republish_dead_letters`) stay on the tokio specialisation
31//! because the engine-generic [`crate::ConsumerApi`] trait returns
32//! [`crate::IncomingMessage`] which loses the protocol-level
33//! `single_metadata` and `arrived_at` fields. Same split pattern as
34//! `PartitionedProducer<P>` (commit `aaa0661`).
35//!
36//! [`TypedProducerBuilder<'a, S, E>`] and [`TypedConsumerBuilder<'a,
37//! S, E>`] are generic over the engine and route their `.create()` /
38//! `.subscribe()` through [`crate::CreateProducerApi`] /
39//! [`crate::SubscribeApi`] on the inner runtime client.
40
41use std::sync::Arc;
42
43use bytes::Bytes;
44use magnetar_proto::schema::{Schema, SchemaError};
45use magnetar_proto::{IncomingMessage, MessageId, pb};
46use magnetar_runtime_tokio::{Consumer, Producer};
47
48use crate::PulsarClient;
49use crate::client::PulsarError;
50
51/// A schema-aware producer. Wraps a producer and applies the configured schema to every
52/// outbound value.
53///
54/// Generic over `P: ProducerApi` per ADR-0026 §D1. The default
55/// (`P = magnetar_runtime_tokio::Producer`) keeps existing callers —
56/// `magnetar::TypedProducer<S>` without a producer type argument —
57/// pointing at the tokio specialisation. Moonpool callers name
58/// `TypedProducer<S, magnetar_runtime_moonpool::Producer<P>>`.
59///
60/// Every inherent method dispatches through the [`crate::ProducerApi`]
61/// trait, so the surface compiles against both engines.
62pub struct TypedProducer<S: Schema, P: crate::ProducerApi = Producer> {
63    inner: P,
64    schema: Arc<S>,
65}
66
67impl<S: Schema, P: crate::ProducerApi + std::fmt::Debug> std::fmt::Debug for TypedProducer<S, P> {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        f.debug_struct("TypedProducer")
70            .field("inner", &self.inner)
71            .field("schema_type", &self.schema.schema_type())
72            .finish()
73    }
74}
75
76impl<S: Schema, P: crate::ProducerApi> TypedProducer<S, P> {
77    /// The inner runtime producer. Useful for accessing connection-state observers and stats.
78    #[must_use]
79    pub fn inner(&self) -> &P {
80        &self.inner
81    }
82
83    /// Encode `value` with the schema and publish it. `key` (optional) becomes the message's
84    /// `partition_key`, which the broker uses for compaction and `key_shared` routing.
85    ///
86    /// PIP-87 [`AutoProduceBytesSchema`](magnetar_proto::schema::AutoProduceBytesSchema)
87    /// producers transparently warm their schema cache on first send via
88    /// [`Schema::needs_broker_schema`](magnetar_proto::schema::Schema::needs_broker_schema) +
89    /// [`Schema::store_resolved_schema`](magnetar_proto::schema::Schema::store_resolved_schema).
90    /// Encoding is pass-through whether or not the cache is populated (Java parity); the lookup
91    /// is purely diagnostic / cache-warming and subsequent sends skip the round-trip.
92    pub async fn send(
93        &self,
94        value: &S::Owned,
95        key: Option<String>,
96    ) -> Result<MessageId, PulsarError> {
97        self.warm_broker_schema().await?;
98        let bytes = self.schema.encode(value).map_err(schema_to_pulsar)?;
99        // Build a façade [`crate::OutgoingMessage`] so the engine-generic
100        // [`crate::ProducerApi::send`] (which consumes that shape) can
101        // dispatch through the runtime's per-engine
102        // `From<crate::OutgoingMessage> for magnetar_proto::OutgoingMessage`
103        // bridge.
104        let mut msg = crate::OutgoingMessage::with_payload(bytes);
105        if let Some(k) = key {
106            msg = msg.key(k);
107        }
108        let id = crate::ProducerApi::send(&self.inner, msg)
109            .await
110            .map_err(|err| PulsarError::Other(format!("send: {err}")))?;
111        Ok(id)
112    }
113
114    /// Warm the schema cache by issuing a `CommandGetSchema` for the producer's topic if the
115    /// schema reports `needs_broker_schema()`. Pure no-op for inline schemas (Avro / JSON /
116    /// primitives). Used on every send path so PIP-87 `AutoProduceBytesSchema` producers cache
117    /// the broker-resolved schema after the first successful round-trip.
118    async fn warm_broker_schema(&self) -> Result<(), PulsarError> {
119        if self.schema.needs_broker_schema() {
120            let resolved = crate::ProducerApi::get_schema(&self.inner, None)
121                .await
122                .map_err(|err| PulsarError::Other(format!("get_schema: {err}")))?;
123            self.schema.store_resolved_schema(resolved);
124        }
125        Ok(())
126    }
127
128    /// Close the underlying producer.
129    pub async fn close(self) -> Result<(), PulsarError> {
130        crate::ProducerApi::close_owned(self.inner)
131            .await
132            .map_err(|err| PulsarError::Other(format!("close: {err}")))
133    }
134
135    /// Topic this producer is bound to. Mirrors Java `Producer#getTopic`.
136    #[must_use]
137    pub fn topic(&self) -> String {
138        crate::ProducerApi::topic(&self.inner)
139    }
140
141    /// Producer name (broker-assigned if not user-supplied). Mirrors Java
142    /// `Producer#getProducerName`.
143    #[must_use]
144    pub fn name(&self) -> String {
145        crate::ProducerApi::name(&self.inner)
146    }
147
148    /// Compression codec this producer was configured with. See `Producer::compression`.
149    #[must_use]
150    pub fn compression(&self) -> magnetar_proto::types::CompressionKind {
151        crate::ProducerApi::compression(&self.inner)
152    }
153
154    /// `true` while the broker connection is up. Mirrors Java `Producer#isConnected`.
155    #[must_use]
156    pub fn is_connected(&self) -> bool {
157        crate::ProducerApi::is_connected(&self.inner)
158    }
159
160    /// `true` once [`Self::close`] has been called.
161    #[must_use]
162    pub fn is_closed(&self) -> bool {
163        crate::ProducerApi::is_closed(&self.inner)
164    }
165
166    /// Cumulative producer counters snapshot. Mirrors Java `Producer#getStats`.
167    #[must_use]
168    pub fn stats(&self) -> magnetar_proto::ProducerStats {
169        crate::ProducerApi::stats(&self.inner)
170    }
171
172    /// Wall-clock instant of the most-recent connection drop. Mirrors Java
173    /// `Producer#getLastDisconnectedTimestamp`.
174    #[must_use]
175    pub fn last_disconnected_timestamp(&self) -> Option<std::time::SystemTime> {
176        crate::ProducerApi::last_disconnected_timestamp(&self.inner)
177    }
178
179    /// Last sequence id pushed onto the wire. Mirrors Java `Producer#getLastSequenceId`.
180    #[must_use]
181    pub fn last_sequence_id(&self) -> i64 {
182        crate::ProducerApi::last_sequence_id(&self.inner)
183    }
184
185    /// Last sequence id the broker has acknowledged. Mirrors Java
186    /// `Producer#getLastSequenceIdPublished`.
187    #[must_use]
188    pub fn last_sequence_id_published(&self) -> i64 {
189        crate::ProducerApi::last_sequence_id_published(&self.inner)
190    }
191
192    /// Number of in-flight sends. See `Producer::pending_count`.
193    #[must_use]
194    pub fn pending_count(&self) -> usize {
195        crate::ProducerApi::pending_count(&self.inner)
196    }
197
198    /// Number of messages buffered in the batch container. See `Producer::batch_len`.
199    #[must_use]
200    pub fn batch_len(&self) -> usize {
201        crate::ProducerApi::batch_len(&self.inner)
202    }
203
204    /// Payload bytes buffered in the batch container. See `Producer::batch_bytes`.
205    #[must_use]
206    pub fn batch_bytes(&self) -> usize {
207        crate::ProducerApi::batch_bytes(&self.inner)
208    }
209
210    /// Flush pending batches and await every in-flight send. Mirrors Java
211    /// `Producer#flushAsync`.
212    pub async fn flush(&self) -> Result<(), PulsarError> {
213        crate::ProducerApi::flush(&self.inner)
214            .await
215            .map_err(|err| PulsarError::Other(format!("flush: {err}")))
216    }
217}
218
219impl<S: Schema> TypedProducer<S, Producer> {
220    /// Start a Java-symmetric `TypedMessageBuilder`. Mirrors `producer.newMessage()` —
221    /// chain `.key`, `.event_time_ms`, `.property`, etc., end with `.send(&value).await`.
222    ///
223    /// Tokio-only — [`TypedMessageBuilder`] composes the per-message
224    /// [`crate::OutgoingMessage`] which today flows through the
225    /// tokio-specialised `Producer::send` for the dispatch surface
226    /// `.into()` already covers. Moonpool callers can build their own
227    /// `OutgoingMessage` and call [`Self::send`] directly.
228    pub fn new_message(&self) -> TypedMessageBuilder<'_, S> {
229        TypedMessageBuilder {
230            producer: self,
231            msg: crate::OutgoingMessage::default(),
232        }
233    }
234}
235
236/// Schema-aware counterpart to [`crate::MessageBuilder`]. Captures a `&TypedProducer`
237/// and lets callers chain Java-style: `producer.new_message().key(..).value(&typed).send()`.
238/// The schema runs on `.send(&value)` so we don't pay the encode cost on values that get
239/// dropped mid-build (a logic error caught by the borrow checker, but cheap to be
240/// defensive about).
241#[derive(Debug)]
242pub struct TypedMessageBuilder<'a, S: Schema> {
243    producer: &'a TypedProducer<S, Producer>,
244    msg: crate::OutgoingMessage,
245}
246
247impl<S: Schema> TypedMessageBuilder<'_, S> {
248    /// Set the routing key. See [`crate::OutgoingMessage::key`].
249    #[must_use]
250    pub fn key(mut self, key: impl Into<String>) -> Self {
251        self.msg = self.msg.key(key);
252        self
253    }
254
255    /// Set the ordering key. See [`crate::OutgoingMessage::ordering_key`].
256    #[must_use]
257    pub fn ordering_key(mut self, key: impl Into<Bytes>) -> Self {
258        self.msg = self.msg.ordering_key(key);
259        self
260    }
261
262    /// Set the event time (millis since epoch). See [`crate::OutgoingMessage::event_time_ms`].
263    #[must_use]
264    pub fn event_time_ms(mut self, ts: u64) -> Self {
265        self.msg = self.msg.event_time_ms(ts);
266        self
267    }
268
269    /// Append a property. See [`crate::OutgoingMessage::property`].
270    #[must_use]
271    pub fn property(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
272        self.msg = self.msg.property(key, value);
273        self
274    }
275
276    /// See [`crate::OutgoingMessage::deliver_at_ms`].
277    #[must_use]
278    pub fn deliver_at_ms(mut self, ts_ms: i64) -> Self {
279        self.msg = self.msg.deliver_at_ms(ts_ms);
280        self
281    }
282
283    /// See [`crate::OutgoingMessage::deliver_after_ms`]. The caller
284    /// supplies `now_ms` (sans-io, ADR-0011 invariant #3).
285    #[must_use]
286    pub fn deliver_after_ms(mut self, now_ms: i64, delay_ms: i64) -> Self {
287        self.msg = self.msg.deliver_after_ms(now_ms, delay_ms);
288        self
289    }
290
291    /// See [`crate::OutgoingMessage::replication_clusters`].
292    #[must_use]
293    pub fn replication_clusters(mut self, clusters: Vec<String>) -> Self {
294        self.msg = self.msg.replication_clusters(clusters);
295        self
296    }
297
298    /// See [`crate::OutgoingMessage::disable_replication`].
299    #[must_use]
300    pub fn disable_replication(mut self) -> Self {
301        self.msg = self.msg.disable_replication();
302        self
303    }
304
305    /// See [`crate::OutgoingMessage::txn`].
306    #[must_use]
307    pub fn txn(mut self, txn_id: magnetar_proto::TxnId) -> Self {
308        self.msg = self.msg.txn(txn_id);
309        self
310    }
311
312    /// Encode `value` with the producer's schema and submit. Mirrors Java's
313    /// terminal `TypedMessageBuilder#send`. PIP-87 `AutoProduceBytesSchema` producers warm
314    /// their broker-schema cache on first invocation via the same path as
315    /// [`TypedProducer::send`].
316    pub async fn send(self, value: &S::Owned) -> Result<MessageId, PulsarError> {
317        self.producer.warm_broker_schema().await?;
318        let bytes = self
319            .producer
320            .schema
321            .encode(value)
322            .map_err(schema_to_pulsar)?;
323        let mut with_payload = self.msg.value(bytes);
324        crate::inject_otel_context(&mut with_payload.properties);
325        let id = self
326            .producer
327            .inner
328            .send(with_payload.into())
329            .await
330            .map_err(PulsarError::Client)?;
331        Ok(id)
332    }
333}
334
335/// Builder for a [`TypedProducer`]. The schema is required; the topic comes from the parent
336/// [`PulsarClient::typed_producer`] entry point.
337///
338/// Generic over `E: Engine` (ADR-0026 §D1). The default
339/// (`E = crate::TokioEngine`) keeps existing callers source-compatible.
340/// Moonpool callers parametrise with
341/// `TypedProducerBuilder<'_, S, MoonpoolEngine<P>>` and get a
342/// `TypedProducer<S, magnetar_runtime_moonpool::Producer<P>>` from
343/// [`Self::create`].
344pub struct TypedProducerBuilder<'a, S: Schema, E: crate::Engine = crate::TokioEngine> {
345    client: &'a PulsarClient<E>,
346    topic: String,
347    schema: Arc<S>,
348    name: Option<String>,
349    compression: magnetar_proto::types::CompressionKind,
350    batching: Option<(usize, usize)>,
351    chunking: bool,
352    properties: Vec<(String, String)>,
353    initial_sequence_id: Option<u64>,
354    access_mode: pb::ProducerAccessMode,
355    send_timeout: Option<std::time::Duration>,
356    batching_max_publish_delay: Option<std::time::Duration>,
357    /// Tokio-engine encryption hook (PIP-4). Only consulted on the
358    /// tokio specialisation of [`Self::create`] — the engine-generic
359    /// path routes through [`crate::CreateProducerApi`] which does not
360    /// surface the encryptor.
361    encryptor: Option<Arc<dyn magnetar_runtime_tokio::MessageEncryptor>>,
362}
363
364impl<S: Schema, E: crate::Engine> std::fmt::Debug for TypedProducerBuilder<'_, S, E> {
365    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
366        f.debug_struct("TypedProducerBuilder")
367            .field("topic", &self.topic)
368            .field("schema_type", &self.schema.schema_type())
369            .field("name", &self.name)
370            .finish()
371    }
372}
373
374impl<'a, S: Schema, E: crate::Engine> TypedProducerBuilder<'a, S, E> {
375    pub(crate) fn new(client: &'a PulsarClient<E>, topic: String, schema: Arc<S>) -> Self {
376        Self {
377            client,
378            topic,
379            schema,
380            name: None,
381            compression: magnetar_proto::types::CompressionKind::None,
382            batching: None,
383            chunking: false,
384            properties: Vec::new(),
385            initial_sequence_id: None,
386            access_mode: pb::ProducerAccessMode::Shared,
387            send_timeout: None,
388            batching_max_publish_delay: None,
389            encryptor: None,
390        }
391    }
392
393    /// Override the producer name advertised to the broker.
394    #[must_use]
395    pub fn name(mut self, name: impl Into<String>) -> Self {
396        self.name = Some(name.into());
397        self
398    }
399
400    /// Mirrors `ProducerBuilder::compression`.
401    #[must_use]
402    pub fn compression(mut self, kind: magnetar_proto::types::CompressionKind) -> Self {
403        self.compression = kind;
404        self
405    }
406
407    /// Mirrors `ProducerBuilder::batching`.
408    #[must_use]
409    pub fn batching(mut self, max_messages: usize, max_bytes: usize) -> Self {
410        self.batching = Some((max_messages, max_bytes));
411        self
412    }
413
414    /// Mirrors `ProducerBuilder::chunking`.
415    #[must_use]
416    pub fn chunking(mut self, enable: bool) -> Self {
417        self.chunking = enable;
418        self
419    }
420
421    /// Mirrors `ProducerBuilder::property`.
422    #[must_use]
423    pub fn property(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
424        self.properties.push((key.into(), value.into()));
425        self
426    }
427
428    /// Mirrors `ProducerBuilder::initial_sequence_id`.
429    #[must_use]
430    pub fn initial_sequence_id(mut self, id: u64) -> Self {
431        self.initial_sequence_id = Some(id);
432        self
433    }
434
435    /// Mirrors `ProducerBuilder::access_mode`.
436    #[must_use]
437    pub fn access_mode(mut self, mode: pb::ProducerAccessMode) -> Self {
438        self.access_mode = mode;
439        self
440    }
441
442    /// Mirrors `ProducerBuilder::send_timeout`.
443    #[must_use]
444    pub fn send_timeout(mut self, timeout: std::time::Duration) -> Self {
445        self.send_timeout = Some(timeout);
446        self
447    }
448
449    /// Mirrors `ProducerBuilder::batching_max_publish_delay`.
450    #[must_use]
451    pub fn batching_max_publish_delay(mut self, delay: std::time::Duration) -> Self {
452        self.batching_max_publish_delay = Some(delay);
453        self
454    }
455}
456
457impl<S: Schema, E: crate::Engine> TypedProducerBuilder<'_, S, E>
458where
459    E::ClientState: crate::CreateProducerApi + crate::BrokerMetadataApi,
460{
461    /// Build and open the producer via the engine-generic
462    /// [`crate::CreateProducerApi`] trait. The configured schema is
463    /// advertised on `CommandProducer.schema`.
464    ///
465    /// **PIP-4 encryption guardrail (BREAKING since the encryptor-storage lift).**
466    /// If [`Self::encryption`] was called on the tokio specialisation,
467    /// `.create()` returns [`PulsarError::Other`] instead of silently opening
468    /// a plaintext producer. Use [`Self::create_with_encryption`] to honor
469    /// the PIP-4 encryptor.
470    ///
471    /// # Errors
472    /// - [`PulsarError::Other`] if an encryptor was configured via [`Self::encryption`] — call
473    ///   `create_with_encryption()` instead.
474    /// - [`PulsarError::Other`] (stringified) on broker rejection or wire failure.
475    pub async fn create(
476        self,
477    ) -> Result<TypedProducer<S, <E::ClientState as crate::CreateProducerApi>::Producer>, PulsarError>
478    {
479        if self.encryptor.is_some() {
480            return Err(PulsarError::Other(
481                "TypedProducerBuilder::create() refuses a configured encryptor — \
482                 use create_with_encryption() to honor the PIP-4 encryptor"
483                    .to_owned(),
484            ));
485        }
486        let schema_pb = pb::Schema {
487            name: self.topic.clone(),
488            schema_data: self.schema.schema_data(),
489            r#type: self.schema.schema_type() as i32,
490            properties: self
491                .schema
492                .properties()
493                .into_iter()
494                .map(|(key, value)| pb::KeyValue { key, value })
495                .collect(),
496        };
497        let mut builder = self
498            .client
499            .producer(self.topic)
500            .schema(schema_pb)
501            .compression(self.compression)
502            .chunking(self.chunking)
503            .access_mode(self.access_mode);
504        if let Some(n) = self.name {
505            builder = builder.name(n);
506        }
507        if let Some((max_msgs, max_bytes)) = self.batching {
508            builder = builder.batching(max_msgs, max_bytes);
509        }
510        for (k, v) in self.properties {
511            builder = builder.property(k, v);
512        }
513        if let Some(id) = self.initial_sequence_id {
514            builder = builder.initial_sequence_id(id);
515        }
516        if let Some(t) = self.send_timeout {
517            builder = builder.send_timeout(t);
518        }
519        if let Some(d) = self.batching_max_publish_delay {
520            builder = builder.batching_max_publish_delay(d);
521        }
522        let inner = builder.create().await?;
523        Ok(TypedProducer {
524            inner,
525            schema: self.schema,
526        })
527    }
528}
529
530/// Tokio-engine-specific `TypedProducerBuilder` methods that depend on
531/// the tokio `MessageEncryptor` extension (PIP-4 not yet wired on
532/// moonpool).
533impl<S: Schema> TypedProducerBuilder<'_, S, crate::TokioEngine> {
534    /// Mirrors `ProducerBuilder::encryption`. Only consulted by
535    /// [`Self::create_with_encryption`].
536    #[must_use]
537    pub fn encryption(
538        mut self,
539        encryptor: Arc<dyn magnetar_runtime_tokio::MessageEncryptor>,
540    ) -> Self {
541        self.encryptor = Some(encryptor);
542        self
543    }
544
545    /// Build and open the producer honoring the configured
546    /// `MessageEncryptor` (PIP-4). The configured schema is advertised on
547    /// `CommandProducer.schema`.
548    pub async fn create_with_encryption(self) -> Result<TypedProducer<S>, PulsarError> {
549        let schema_pb = pb::Schema {
550            name: self.topic.clone(),
551            schema_data: self.schema.schema_data(),
552            r#type: self.schema.schema_type() as i32,
553            properties: self
554                .schema
555                .properties()
556                .into_iter()
557                .map(|(key, value)| pb::KeyValue { key, value })
558                .collect(),
559        };
560        let mut builder = self
561            .client
562            .producer(self.topic)
563            .schema(schema_pb)
564            .compression(self.compression)
565            .chunking(self.chunking)
566            .access_mode(self.access_mode);
567        if let Some(n) = self.name {
568            builder = builder.name(n);
569        }
570        if let Some((max_msgs, max_bytes)) = self.batching {
571            builder = builder.batching(max_msgs, max_bytes);
572        }
573        for (k, v) in self.properties {
574            builder = builder.property(k, v);
575        }
576        if let Some(id) = self.initial_sequence_id {
577            builder = builder.initial_sequence_id(id);
578        }
579        if let Some(t) = self.send_timeout {
580            builder = builder.send_timeout(t);
581        }
582        if let Some(d) = self.batching_max_publish_delay {
583            builder = builder.batching_max_publish_delay(d);
584        }
585        if let Some(e) = self.encryptor {
586            builder = builder.encryption(e);
587        }
588        let inner = builder.create_with_encryption().await?;
589        Ok(TypedProducer {
590            inner,
591            schema: self.schema,
592        })
593    }
594}
595
596/// Schema-aware push-delivery callback (Java
597/// `ConsumerBuilder<T>#messageListener`). Fired once per delivered message with
598/// the decoded [`TypedMessage`]. Like the raw [`crate::MessageListener`], it
599/// runs inside the poller task — sequentially, in order — and **must ack
600/// explicitly** (the poller never auto-acks). Register it via
601/// [`TypedConsumerBuilder::message_listener`] and subscribe via
602/// [`TypedConsumerBuilder::subscribe_with_listener`].
603pub type TypedMessageListener<S> = Arc<dyn Fn(&TypedMessage<S>) + Send + Sync>;
604
605/// A decoded message yielded by [`TypedConsumer::receive`].
606pub struct TypedMessage<S: Schema> {
607    /// Broker-assigned message id (use it to ack).
608    pub message_id: MessageId,
609    /// The decoded value.
610    pub value: S::Owned,
611    /// Raw payload bytes (post-decryption, post-decompression). Useful when a caller wants to
612    /// re-emit the message verbatim.
613    pub payload: Bytes,
614    /// The underlying incoming message (metadata, single-message metadata, etc.).
615    pub raw: IncomingMessage,
616}
617
618impl<S: Schema> std::fmt::Debug for TypedMessage<S>
619where
620    S::Owned: std::fmt::Debug,
621{
622    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
623        f.debug_struct("TypedMessage")
624            .field("message_id", &self.message_id)
625            .field("value", &self.value)
626            .field("payload_len", &self.payload.len())
627            .field("raw", &self.raw)
628            .finish()
629    }
630}
631
632/// A schema-aware consumer. Wraps a consumer and decodes every received payload with the
633/// configured schema before returning to the caller.
634///
635/// Generic over `C: ConsumerApi` per ADR-0026 §D1. The default
636/// (`C = magnetar_runtime_tokio::Consumer`) keeps existing callers —
637/// `magnetar::TypedConsumer<S>` without a consumer type argument —
638/// pointing at the tokio specialisation. Moonpool callers name
639/// `TypedConsumer<S, magnetar_runtime_moonpool::Consumer<P>>`.
640///
641/// Engine-generic methods dispatch through [`crate::ConsumerApi`].
642/// Methods that require the runtime's
643/// `magnetar_proto::IncomingMessage` shape (the `receive` family,
644/// `receive_batch`, `reconsume_later`, `republish_dead_letters`) or
645/// helpers not on `ConsumerApi` today (`pause`, `resume`, `flow`,
646/// `is_paused`, `has_reached_end_of_topic`, `available_in_queue`,
647/// `available_permits`, `has_received_any_message`, `is_inactive`,
648/// `ack_batch`, `ack_with_properties`,
649/// `ack_cumulative_with_properties`, `ack_batch_with_txn`,
650/// `seek_to_message`, `seek_to_timestamp`,
651/// `receive_batch_with_bytes_cap`, `drain_dead_letter`, unsubscribe
652/// with `force=true`) stay on the tokio specialisation
653/// `impl TypedConsumer<S, magnetar_runtime_tokio::Consumer>` until the
654/// trait grows them.
655pub struct TypedConsumer<S: Schema, C: crate::ConsumerApi = Consumer> {
656    inner: C,
657    schema: Arc<S>,
658}
659
660impl<S: Schema, C: crate::ConsumerApi + std::fmt::Debug> std::fmt::Debug for TypedConsumer<S, C> {
661    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
662        f.debug_struct("TypedConsumer")
663            .field("inner", &self.inner)
664            .field("schema_type", &self.schema.schema_type())
665            .finish()
666    }
667}
668
669impl<S: Schema, C: crate::ConsumerApi> TypedConsumer<S, C> {
670    /// The inner runtime consumer.
671    #[must_use]
672    pub fn inner(&self) -> &C {
673        &self.inner
674    }
675
676    /// Acknowledge a single message.
677    pub async fn ack(&self, message_id: MessageId) -> Result<(), PulsarError> {
678        crate::ConsumerApi::ack(&self.inner, message_id)
679            .await
680            .map_err(|err| PulsarError::Other(format!("ack: {err}")))
681    }
682
683    /// Close the underlying consumer.
684    pub async fn close(self) -> Result<(), PulsarError> {
685        crate::ConsumerApi::close_owned(self.inner)
686            .await
687            .map_err(|err| PulsarError::Other(format!("close: {err}")))
688    }
689
690    /// Topic this consumer is bound to. Mirrors Java `Consumer#getTopic`.
691    #[must_use]
692    pub fn topic(&self) -> String {
693        crate::ConsumerApi::topic(&self.inner)
694    }
695
696    /// Subscription name. Mirrors Java `Consumer#getSubscription`.
697    #[must_use]
698    pub fn subscription(&self) -> String {
699        crate::ConsumerApi::subscription(&self.inner)
700    }
701
702    /// Consumer name. Mirrors Java `Consumer#getConsumerName`.
703    #[must_use]
704    pub fn name(&self) -> String {
705        crate::ConsumerApi::name(&self.inner)
706    }
707
708    /// `true` while the broker connection is up. Mirrors Java `Consumer#isConnected`.
709    #[must_use]
710    pub fn is_connected(&self) -> bool {
711        crate::ConsumerApi::is_connected(&self.inner)
712    }
713
714    /// `true` once [`Self::close`] / `unsubscribe` has completed.
715    #[must_use]
716    pub fn is_closed(&self) -> bool {
717        crate::ConsumerApi::is_closed(&self.inner)
718    }
719
720    /// Cumulative consumer counters snapshot. Mirrors Java `Consumer#getStats`.
721    #[must_use]
722    pub fn stats(&self) -> magnetar_proto::ConsumerStats {
723        crate::ConsumerApi::stats(&self.inner)
724    }
725
726    /// Wall-clock instant of the most-recent connection drop. Mirrors Java
727    /// `Consumer#getLastDisconnectedTimestamp`.
728    #[must_use]
729    pub fn last_disconnected_timestamp(&self) -> Option<std::time::SystemTime> {
730        crate::ConsumerApi::last_disconnected_timestamp(&self.inner)
731    }
732
733    /// Negative-ack a message. Mirrors Java `Consumer#negativeAcknowledge`.
734    pub fn negative_ack(&self, message_id: MessageId) {
735        crate::ConsumerApi::negative_ack(&self.inner, message_id);
736    }
737
738    /// Tell the broker to redeliver every unacked message. Mirrors Java
739    /// `Consumer#redeliverUnacknowledgedMessages`.
740    pub fn redeliver_unacked(&self) {
741        crate::ConsumerApi::redeliver_unacked(&self.inner);
742    }
743
744    /// Cumulative ack. Mirrors Java `Consumer#acknowledgeCumulativeAsync(MessageId)`.
745    pub async fn ack_cumulative(&self, message_id: MessageId) -> Result<(), PulsarError> {
746        crate::ConsumerApi::ack_cumulative(&self.inner, message_id)
747            .await
748            .map_err(|err| PulsarError::Other(format!("ack_cumulative: {err}")))
749    }
750
751    /// Fire-and-forget ack into the consumer's ack-grouping tracker (opt-in via
752    /// `TypedConsumerBuilder::ack_group_time`).
753    pub fn ack_grouped(&self, message_id: MessageId) {
754        crate::ConsumerApi::ack_grouped(&self.inner, message_id);
755    }
756
757    /// Fire-and-forget cumulative ack into the consumer's ack-grouping tracker.
758    pub fn ack_grouped_cumulative(&self, message_id: MessageId) {
759        crate::ConsumerApi::ack_grouped_cumulative(&self.inner, message_id);
760    }
761
762    /// Ack a single message inside a transaction. Mirrors Java
763    /// `Consumer#acknowledgeAsync(MessageId, Transaction)`.
764    pub async fn ack_with_txn(
765        &self,
766        message_id: MessageId,
767        txn_id: magnetar_proto::TxnId,
768    ) -> Result<(), PulsarError> {
769        crate::ConsumerApi::ack_with_txn(&self.inner, message_id, txn_id)
770            .await
771            .map_err(|err| PulsarError::Other(format!("ack_with_txn: {err}")))
772    }
773
774    /// Cumulative ack inside a transaction. Mirrors Java
775    /// `Consumer#acknowledgeCumulativeAsync(MessageId, Transaction)`.
776    pub async fn ack_cumulative_with_txn(
777        &self,
778        message_id: MessageId,
779        txn_id: magnetar_proto::TxnId,
780    ) -> Result<(), PulsarError> {
781        crate::ConsumerApi::ack_cumulative_with_txn(&self.inner, message_id, txn_id)
782            .await
783            .map_err(|err| PulsarError::Other(format!("ack_cumulative_with_txn: {err}")))
784    }
785
786    /// Seek to the earliest message. Mirrors Java `Consumer#seek(MessageId.earliest)`.
787    pub async fn seek_to_earliest(&self) -> Result<(), PulsarError> {
788        crate::ConsumerApi::seek_to_earliest(&self.inner)
789            .await
790            .map_err(|err| PulsarError::Other(format!("seek_to_earliest: {err}")))
791    }
792
793    /// Seek to the latest (head) position. Mirrors Java `Consumer#seek(MessageId.latest)`.
794    pub async fn seek_to_latest(&self) -> Result<(), PulsarError> {
795        crate::ConsumerApi::seek_to_latest(&self.inner)
796            .await
797            .map_err(|err| PulsarError::Other(format!("seek_to_latest: {err}")))
798    }
799
800    /// Ask the broker for the topic's last-published message id. Mirrors Java
801    /// `Consumer#getLastMessageId`.
802    pub async fn last_message_id(&self) -> Result<MessageId, PulsarError> {
803        crate::ConsumerApi::last_message_id(&self.inner)
804            .await
805            .map_err(|err| PulsarError::Other(format!("last_message_id: {err}")))
806    }
807
808    /// `true` if the broker has at least one message strictly past `cursor`. Mirrors Java
809    /// `Consumer#hasMessageAvailable` (the variant taking a cursor).
810    pub async fn has_message_after(&self, cursor: MessageId) -> Result<bool, PulsarError> {
811        crate::ConsumerApi::has_message_after(&self.inner, cursor)
812            .await
813            .map_err(|err| PulsarError::Other(format!("has_message_after: {err}")))
814    }
815}
816
817/// Tokio-engine-specific `TypedConsumer` methods.
818///
819/// These methods depend on either (a) the runtime's
820/// `magnetar_proto::IncomingMessage` shape (the `receive` family
821/// returns the proto-level message so the consumer keeps access to
822/// `single_metadata` + `arrived_at` — fields the engine-generic
823/// [`crate::ConsumerApi::receive`] trait method drops by widening to
824/// [`crate::IncomingMessage`]), or (b) `Consumer` helpers not yet on
825/// [`crate::ConsumerApi`] (the long tail of `pause` / `flow` / the
826/// extended ack family / DLQ / retry / batched receive). Each of these
827/// methods can be lifted into the engine-generic impl block as the
828/// matching helper lands on the trait — same incremental split as
829/// `PartitionedProducer<P>`.
830impl<S: Schema> TypedConsumer<S, Consumer> {
831    /// Receive the next message. The payload is schema-decoded; if decoding fails the error
832    /// is surfaced as [`PulsarError::Schema`] and the message remains unacked so the broker
833    /// re-delivers it (subject to the consumer's redelivery policy).
834    ///
835    /// PIP-87 [`AutoConsumeSchema`](magnetar_proto::schema::AutoConsumeSchema) consumers
836    /// transparently fetch the broker-registered schema on first call via
837    /// [`Schema::needs_broker_schema`](magnetar_proto::schema::Schema::needs_broker_schema) +
838    /// [`Schema::store_resolved_schema`](magnetar_proto::schema::Schema::store_resolved_schema).
839    /// Subsequent receives reuse the cache. A broker-side schema-lookup failure surfaces as
840    /// [`PulsarError::Client`] with [`magnetar_runtime_tokio::ClientError::Broker`].
841    pub async fn receive(&self) -> Result<TypedMessage<S>, PulsarError> {
842        if self.schema.needs_broker_schema() {
843            let resolved = self
844                .inner
845                .get_schema(None)
846                .await
847                .map_err(PulsarError::Client)?;
848            self.schema.store_resolved_schema(resolved);
849        }
850        let raw = self.inner.receive().await?;
851        let value = self.schema.decode(&raw.payload).map_err(schema_to_pulsar)?;
852        Ok(TypedMessage {
853            message_id: raw.message_id,
854            value,
855            payload: raw.payload.clone(),
856            raw,
857        })
858    }
859
860    /// Pause delivery. Mirrors Java `Consumer#pause`.
861    pub fn pause(&self) {
862        self.inner.pause();
863    }
864
865    /// Resume delivery. Mirrors Java `Consumer#resume`.
866    pub fn resume(&self) {
867        self.inner.resume();
868    }
869
870    /// `true` after [`Self::pause`] until [`Self::resume`]. Mirrors Java
871    /// `Consumer#isPaused` semantics.
872    #[must_use]
873    pub fn is_paused(&self) -> bool {
874        self.inner.is_paused()
875    }
876
877    /// `true` once the broker has signalled end-of-topic. Mirrors Java
878    /// `Consumer#hasReachedEndOfTopic`.
879    #[must_use]
880    pub fn has_reached_end_of_topic(&self) -> bool {
881        self.inner.has_reached_end_of_topic()
882    }
883
884    /// Buffered message count. Mirrors Java `Consumer#getNumMessagesInQueue`.
885    #[must_use]
886    pub fn available_in_queue(&self) -> usize {
887        self.inner.available_in_queue()
888    }
889
890    /// Outstanding broker permits — grants issued, minus one per dispatch unit that has
891    /// actually arrived. Mirrors Java `ConsumerBase#getAvailablePermits`. Issue #414
892    /// re-pointed this from the purely-additive grant mirror to the real decrementing
893    /// balance, so the value moves under dispatch (ADR-0101 amending ADR-0082).
894    #[must_use]
895    pub fn available_permits(&self) -> u32 {
896        self.inner.available_permits()
897    }
898
899    /// `true` if this consumer has received at least one message since opening. Mirrors
900    /// Java `Consumer#hasReceivedAnyMessage`.
901    #[must_use]
902    pub fn has_received_any_message(&self) -> bool {
903        self.inner.has_received_any_message()
904    }
905
906    /// `true` when the consumer has been disconnected longer than the configured inactive
907    /// threshold. Mirrors Java `Consumer#isInactive` semantics.
908    #[must_use]
909    pub fn is_inactive(&self) -> bool {
910        self.inner.is_inactive()
911    }
912
913    /// Batched individual ack. Mirrors Java `Consumer#acknowledgeAsync(List<MessageId>)`.
914    pub async fn ack_batch(&self, message_ids: Vec<MessageId>) -> Result<(), PulsarError> {
915        self.inner
916            .ack_batch(message_ids)
917            .await
918            .map_err(PulsarError::Client)
919    }
920
921    /// Unsubscribe this consumer's subscription from the broker. Mirrors Java
922    /// `Consumer#unsubscribe`. `force=true` (PIP-313) drops the subscription even when
923    /// other consumers are still attached to the same subscription name.
924    pub async fn unsubscribe(&self, force: bool) -> Result<(), PulsarError> {
925        self.inner
926            .unsubscribe(force)
927            .await
928            .map_err(PulsarError::Client)
929    }
930
931    /// Seek to a specific message id. Mirrors Java `Consumer#seek(MessageId)`.
932    pub async fn seek_to_message(&self, message_id: MessageId) -> Result<(), PulsarError> {
933        self.inner
934            .seek_to_message(message_id)
935            .await
936            .map_err(PulsarError::Client)
937    }
938
939    /// Seek to a publish-time deadline (millis since epoch). Mirrors Java
940    /// `Consumer#seek(long)`.
941    pub async fn seek_to_timestamp(&self, publish_time_ms: u64) -> Result<(), PulsarError> {
942        self.inner
943            .seek_to_timestamp(publish_time_ms)
944            .await
945            .map_err(PulsarError::Client)
946    }
947
948    /// Issue an explicit FLOW (permit refill). Mirrors `ConsumerBase#increaseAvailablePermits`.
949    pub fn flow(&self, permits: u32) {
950        self.inner.flow(permits);
951    }
952
953    /// Same as [`Self::receive`] but bounded by `timeout`. Returns `Ok(None)` when the
954    /// deadline elapses with no message. Mirrors Java
955    /// `Consumer#receive(int timeout, TimeUnit unit)`.
956    pub async fn receive_with_timeout(
957        &self,
958        timeout: std::time::Duration,
959    ) -> Result<Option<TypedMessage<S>>, PulsarError> {
960        match self.inner.receive_with_timeout(timeout).await? {
961            Some(raw) => {
962                let value = self.schema.decode(&raw.payload).map_err(schema_to_pulsar)?;
963                Ok(Some(TypedMessage {
964                    message_id: raw.message_id,
965                    value,
966                    payload: raw.payload.clone(),
967                    raw,
968                }))
969            }
970            None => Ok(None),
971        }
972    }
973
974    /// Batched receive. Mirrors Java `Consumer#batchReceive`. Decodes every payload with
975    /// the schema; the first decode error short-circuits the call.
976    pub async fn receive_batch(
977        &self,
978        max_messages: usize,
979        max_wait: std::time::Duration,
980    ) -> Result<Vec<TypedMessage<S>>, PulsarError> {
981        let raw_batch = self.inner.receive_batch(max_messages, max_wait).await?;
982        let mut out = Vec::with_capacity(raw_batch.len());
983        for raw in raw_batch {
984            let value = self.schema.decode(&raw.payload).map_err(schema_to_pulsar)?;
985            out.push(TypedMessage {
986                message_id: raw.message_id,
987                value,
988                payload: raw.payload.clone(),
989                raw,
990            });
991        }
992        Ok(out)
993    }
994
995    /// Batched receive with a bytes cap. See [`Self::receive_batch`] and the runtime's
996    /// `Consumer::receive_batch_with_bytes_cap` for `BatchReceivePolicy` parity.
997    pub async fn receive_batch_with_bytes_cap(
998        &self,
999        max_messages: usize,
1000        max_bytes: usize,
1001        max_wait: std::time::Duration,
1002    ) -> Result<Vec<TypedMessage<S>>, PulsarError> {
1003        let raw_batch = self
1004            .inner
1005            .receive_batch_with_bytes_cap(max_messages, max_bytes, max_wait)
1006            .await?;
1007        let mut out = Vec::with_capacity(raw_batch.len());
1008        for raw in raw_batch {
1009            let value = self.schema.decode(&raw.payload).map_err(schema_to_pulsar)?;
1010            out.push(TypedMessage {
1011                message_id: raw.message_id,
1012                value,
1013                payload: raw.payload.clone(),
1014                raw,
1015            });
1016        }
1017        Ok(out)
1018    }
1019
1020    /// Ack with caller-supplied properties. Mirrors Java
1021    /// `Consumer#acknowledgeAsync(MessageId, Map<String, Long>)`.
1022    pub async fn ack_with_properties(
1023        &self,
1024        message_id: MessageId,
1025        properties: Vec<(String, i64)>,
1026    ) -> Result<(), PulsarError> {
1027        self.inner
1028            .ack_with_properties(message_id, properties)
1029            .await
1030            .map_err(PulsarError::Client)
1031    }
1032
1033    /// Batched ack inside a transaction. Mirrors Java
1034    /// `Consumer#acknowledgeAsync(List<MessageId>, Transaction)`.
1035    pub async fn ack_batch_with_txn(
1036        &self,
1037        message_ids: Vec<MessageId>,
1038        txn_id: magnetar_proto::TxnId,
1039    ) -> Result<(), PulsarError> {
1040        self.inner
1041            .ack_batch_with_txn(message_ids, txn_id)
1042            .await
1043            .map_err(PulsarError::Client)
1044    }
1045
1046    /// Cumulative ack with caller-supplied properties. Mirrors Java
1047    /// `Consumer#acknowledgeCumulativeAsync(MessageId, Map<String, Long>)`.
1048    pub async fn ack_cumulative_with_properties(
1049        &self,
1050        message_id: MessageId,
1051        properties: Vec<(String, i64)>,
1052    ) -> Result<(), PulsarError> {
1053        self.inner
1054            .ack_cumulative_with_properties(message_id, properties)
1055            .await
1056            .map_err(PulsarError::Client)
1057    }
1058
1059    /// Drain every DLQ-flagged message (raw, un-decoded so schema mismatches don't lose
1060    /// the payload). See the runtime's `Consumer::drain_dead_letter`.
1061    #[must_use]
1062    pub fn drain_dead_letter(&self) -> Vec<IncomingMessage> {
1063        self.inner.drain_dead_letter()
1064    }
1065
1066    /// Drain the DLQ pending list and republish every entry via `dlq_producer`. See the
1067    /// runtime's `Consumer::republish_dead_letters`. Returns the number republished.
1068    ///
1069    /// With the `opentelemetry` feature on, the republish span context is re-injected onto
1070    /// every dead-letter copy (overwriting any inbound `traceparent` / `tracestate`) so the
1071    /// republish is traced under the caller's current span; the original trace stays
1072    /// reachable via the `REAL_TOPIC` / `ORIGINAL_MESSAGE_ID` correlation stamps
1073    /// (ADR-0053 §D2).
1074    pub async fn republish_dead_letters(
1075        &self,
1076        dlq_producer: &magnetar_runtime_tokio::Producer,
1077    ) -> Result<usize, PulsarError> {
1078        let mut extra_properties = Vec::new();
1079        crate::inject_otel_context(&mut extra_properties);
1080        self.inner
1081            .republish_dead_letters_with_properties(dlq_producer, extra_properties)
1082            .await
1083            .map_err(PulsarError::Client)
1084    }
1085
1086    /// Republish `msg` via `retry_producer` with a delay, then ack the original. Mirrors
1087    /// Java `Consumer#reconsumeLater(Message, long, TimeUnit)`. Takes the raw
1088    /// `IncomingMessage` (use [`TypedMessage::raw`]) so the original payload is
1089    /// preserved verbatim through the retry topic.
1090    ///
1091    /// With the `opentelemetry` feature on, the retrying consumer's current span context is
1092    /// re-injected onto the retry-letter copy, replacing the inbound `traceparent` /
1093    /// `tracestate` (ADR-0053 §D2).
1094    pub async fn reconsume_later(
1095        &self,
1096        retry_producer: &magnetar_runtime_tokio::Producer,
1097        msg: magnetar_proto::IncomingMessage,
1098        delay: std::time::Duration,
1099    ) -> Result<(), PulsarError> {
1100        // Route through the properties-aware variant so the OTel re-injection (ADR-0053 §D2)
1101        // happens on this path too.
1102        self.reconsume_later_with_properties(retry_producer, msg, Vec::new(), delay)
1103            .await
1104    }
1105
1106    /// Same as [`Self::reconsume_later`] but stamps custom properties on the republished
1107    /// message. Mirrors Java's properties-aware reconsumeLater overload.
1108    ///
1109    /// With the `opentelemetry` feature on, the current span context is re-injected into
1110    /// `custom_properties` before the runtime merges them (override on key collision), so
1111    /// the retry-letter copy carries the retrying consumer's trace rather than the inbound
1112    /// one (ADR-0053 §D2). An explicit `traceparent` / `tracestate` in `custom_properties`
1113    /// is overwritten by the injected value.
1114    pub async fn reconsume_later_with_properties(
1115        &self,
1116        retry_producer: &magnetar_runtime_tokio::Producer,
1117        msg: magnetar_proto::IncomingMessage,
1118        mut custom_properties: Vec<(String, String)>,
1119        delay: std::time::Duration,
1120    ) -> Result<(), PulsarError> {
1121        crate::inject_otel_context(&mut custom_properties);
1122        self.inner
1123            .reconsume_later_with_properties(retry_producer, msg, custom_properties, delay)
1124            .await
1125            .map_err(PulsarError::Client)
1126    }
1127}
1128
1129/// Builder for a [`TypedConsumer`].
1130///
1131/// Generic over `E: Engine` (ADR-0026 §D1). The default
1132/// (`E = crate::TokioEngine`) keeps existing callers source-compatible.
1133/// Moonpool callers parametrise with
1134/// `TypedConsumerBuilder<'_, S, MoonpoolEngine<P>>` and get a
1135/// `TypedConsumer<S, magnetar_runtime_moonpool::Consumer<P>>` from
1136/// [`Self::subscribe`].
1137pub struct TypedConsumerBuilder<'a, S: Schema, E: crate::Engine = crate::TokioEngine> {
1138    client: &'a PulsarClient<E>,
1139    topic: String,
1140    schema: Arc<S>,
1141    subscription: Option<String>,
1142    sub_type: pb::command_subscribe::SubType,
1143    durable: bool,
1144    initial_position: pb::command_subscribe::InitialPosition,
1145    receiver_queue_size: usize,
1146    consumer_name: Option<String>,
1147    priority_level: Option<i32>,
1148    properties: Vec<(String, String)>,
1149    subscription_properties: Vec<(String, String)>,
1150    read_compacted: bool,
1151    negative_ack_redelivery_delay: Option<std::time::Duration>,
1152    ack_timeout: Option<std::time::Duration>,
1153    ack_group_time: Option<std::time::Duration>,
1154    dlq_policy: Option<(u32, Option<String>)>,
1155    max_pending_chunked_message: Option<usize>,
1156    auto_ack_oldest_chunked_message_on_queue_full: Option<bool>,
1157    expire_time_of_incomplete_chunked_message: Option<std::time::Duration>,
1158    key_shared: Option<magnetar_proto::KeySharedConfig>,
1159    start_message_id: Option<magnetar_proto::MessageId>,
1160    replicate_subscription_state: Option<bool>,
1161    force_topic_creation: Option<bool>,
1162    start_message_rollback_duration_sec: Option<u64>,
1163    listener: Option<TypedMessageListener<S>>,
1164}
1165
1166impl<S: Schema, E: crate::Engine> std::fmt::Debug for TypedConsumerBuilder<'_, S, E> {
1167    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1168        f.debug_struct("TypedConsumerBuilder")
1169            .field("topic", &self.topic)
1170            .field("schema_type", &self.schema.schema_type())
1171            .field("subscription", &self.subscription)
1172            .field("sub_type", &self.sub_type)
1173            .field("durable", &self.durable)
1174            .finish()
1175    }
1176}
1177
1178impl<'a, S: Schema, E: crate::Engine> TypedConsumerBuilder<'a, S, E> {
1179    pub(crate) fn new(client: &'a PulsarClient<E>, topic: String, schema: Arc<S>) -> Self {
1180        Self {
1181            client,
1182            topic,
1183            schema,
1184            subscription: None,
1185            sub_type: pb::command_subscribe::SubType::Exclusive,
1186            durable: true,
1187            initial_position: pb::command_subscribe::InitialPosition::Latest,
1188            receiver_queue_size: 1000,
1189            consumer_name: None,
1190            priority_level: None,
1191            properties: Vec::new(),
1192            subscription_properties: Vec::new(),
1193            read_compacted: false,
1194            negative_ack_redelivery_delay: None,
1195            ack_timeout: None,
1196            ack_group_time: None,
1197            dlq_policy: None,
1198            max_pending_chunked_message: None,
1199            auto_ack_oldest_chunked_message_on_queue_full: None,
1200            expire_time_of_incomplete_chunked_message: None,
1201            key_shared: None,
1202            start_message_id: None,
1203            replicate_subscription_state: None,
1204            force_topic_creation: None,
1205            start_message_rollback_duration_sec: None,
1206            listener: None,
1207        }
1208    }
1209
1210    /// Register a schema-aware push-delivery callback (Java
1211    /// `ConsumerBuilder<T>#messageListener`). Once set, subscribe via
1212    /// [`Self::subscribe_with_listener`] to start a background poller that
1213    /// decodes each message and hands the [`TypedMessage`] to `listener`,
1214    /// sequentially and in order. The plain [`Self::subscribe`] ignores the
1215    /// listener and returns a pull-mode [`TypedConsumer`].
1216    ///
1217    /// The callback **must ack explicitly** — the poller never auto-acks (Java
1218    /// parity).
1219    #[must_use]
1220    pub fn message_listener(mut self, listener: TypedMessageListener<S>) -> Self {
1221        self.listener = Some(listener);
1222        self
1223    }
1224
1225    /// Set the consumer name advertised to the broker. Mirrors Java
1226    /// `ConsumerBuilder#consumerName`.
1227    #[must_use]
1228    pub fn name(mut self, name: impl Into<String>) -> Self {
1229        self.consumer_name = Some(name.into());
1230        self
1231    }
1232
1233    /// Mirrors `ConsumerBuilder::priority_level`.
1234    #[must_use]
1235    pub fn priority_level(mut self, level: i32) -> Self {
1236        self.priority_level = Some(level);
1237        self
1238    }
1239
1240    /// Mirrors `ConsumerBuilder::property`.
1241    #[must_use]
1242    pub fn property(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
1243        self.properties.push((key.into(), value.into()));
1244        self
1245    }
1246
1247    /// Mirrors `ConsumerBuilder::subscription_property`.
1248    #[must_use]
1249    pub fn subscription_property(
1250        mut self,
1251        key: impl Into<String>,
1252        value: impl Into<String>,
1253    ) -> Self {
1254        self.subscription_properties
1255            .push((key.into(), value.into()));
1256        self
1257    }
1258
1259    /// Mirrors `ConsumerBuilder::read_compacted`.
1260    #[must_use]
1261    pub fn read_compacted(mut self, on: bool) -> Self {
1262        self.read_compacted = on;
1263        self
1264    }
1265
1266    /// Mirrors `ConsumerBuilder::negative_ack_redelivery_delay`.
1267    #[must_use]
1268    pub fn negative_ack_redelivery_delay(mut self, delay: std::time::Duration) -> Self {
1269        self.negative_ack_redelivery_delay = Some(delay);
1270        self
1271    }
1272
1273    /// Mirrors `ConsumerBuilder::ack_timeout`.
1274    #[must_use]
1275    pub fn ack_timeout(mut self, timeout: std::time::Duration) -> Self {
1276        self.ack_timeout = Some(timeout);
1277        self
1278    }
1279
1280    /// Mirrors `ConsumerBuilder::ack_group_time`. Coalesces fire-and-forget acks emitted
1281    /// via [`TypedConsumer::ack_grouped`] / [`TypedConsumer::ack_grouped_cumulative`].
1282    #[must_use]
1283    pub fn ack_group_time(mut self, window: std::time::Duration) -> Self {
1284        self.ack_group_time = Some(window);
1285        self
1286    }
1287
1288    /// Mirrors `ConsumerBuilder::dead_letter_policy`.
1289    #[must_use]
1290    pub fn dead_letter_policy(
1291        mut self,
1292        max_redeliver_count: u32,
1293        dead_letter_topic: Option<String>,
1294    ) -> Self {
1295        self.dlq_policy = Some((max_redeliver_count, dead_letter_topic));
1296        self
1297    }
1298
1299    /// Mirrors `ConsumerBuilder::max_pending_chunked_message`.
1300    #[must_use]
1301    pub fn max_pending_chunked_message(mut self, max: usize) -> Self {
1302        self.max_pending_chunked_message = Some(max);
1303        self
1304    }
1305
1306    /// Mirrors `ConsumerBuilder::auto_ack_oldest_chunked_message_on_queue_full`.
1307    #[must_use]
1308    pub fn auto_ack_oldest_chunked_message_on_queue_full(mut self, auto_ack: bool) -> Self {
1309        self.auto_ack_oldest_chunked_message_on_queue_full = Some(auto_ack);
1310        self
1311    }
1312
1313    /// Mirrors `ConsumerBuilder::expire_time_of_incomplete_chunked_message`.
1314    #[must_use]
1315    pub fn expire_time_of_incomplete_chunked_message(
1316        mut self,
1317        expire: std::time::Duration,
1318    ) -> Self {
1319        self.expire_time_of_incomplete_chunked_message = Some(expire);
1320        self
1321    }
1322
1323    /// Mirrors `ConsumerBuilder::key_shared_policy`. Only meaningful with `Key_Shared`
1324    /// subscription type.
1325    #[must_use]
1326    pub fn key_shared_policy(mut self, cfg: magnetar_proto::KeySharedConfig) -> Self {
1327        self.key_shared = Some(cfg);
1328        self
1329    }
1330
1331    /// Mirrors `ConsumerBuilder::start_message_id`. Only honoured for fresh subscriptions.
1332    #[must_use]
1333    pub fn start_message_id(mut self, id: magnetar_proto::MessageId) -> Self {
1334        self.start_message_id = Some(id);
1335        self
1336    }
1337
1338    /// Mirrors `ConsumerBuilder::replicate_subscription_state`.
1339    #[must_use]
1340    pub fn replicate_subscription_state(mut self, on: bool) -> Self {
1341        self.replicate_subscription_state = Some(on);
1342        self
1343    }
1344
1345    /// Mirrors `ConsumerBuilder::force_topic_creation`.
1346    #[must_use]
1347    pub fn force_topic_creation(mut self, on: bool) -> Self {
1348        self.force_topic_creation = Some(on);
1349        self
1350    }
1351
1352    /// Mirrors `ConsumerBuilder::start_message_rollback_duration`. Rolls the subscription
1353    /// cursor back by `seconds` at subscribe time.
1354    #[must_use]
1355    pub fn start_message_rollback_duration(mut self, seconds: u64) -> Self {
1356        self.start_message_rollback_duration_sec = Some(seconds);
1357        self
1358    }
1359
1360    /// Required: set the subscription name.
1361    #[must_use]
1362    pub fn subscription(mut self, name: impl Into<String>) -> Self {
1363        self.subscription = Some(name.into());
1364        self
1365    }
1366
1367    /// Test-support seam (`#[doc(hidden)]`): the bounded-chunk-reassembly knobs
1368    /// this typed builder delegates to the base [`crate::ConsumerBuilder`]. Lets
1369    /// the builder-surface guard test pin the setter → field plumbing without a
1370    /// broker.
1371    #[doc(hidden)]
1372    #[must_use]
1373    pub fn chunk_knobs_for_test(
1374        &self,
1375    ) -> (Option<usize>, Option<bool>, Option<std::time::Duration>) {
1376        (
1377            self.max_pending_chunked_message,
1378            self.auto_ack_oldest_chunked_message_on_queue_full,
1379            self.expire_time_of_incomplete_chunked_message,
1380        )
1381    }
1382
1383    /// Test-support seam (`#[doc(hidden)]`): `true` once a push-delivery
1384    /// listener has been set via [`Self::message_listener`]. Lets the
1385    /// builder-surface guard test pin the listener → field wiring without a
1386    /// broker.
1387    #[doc(hidden)]
1388    #[must_use]
1389    pub fn has_listener_for_test(&self) -> bool {
1390        self.listener.is_some()
1391    }
1392
1393    /// Set the subscription type.
1394    #[must_use]
1395    pub fn subscription_type(mut self, sub_type: pb::command_subscribe::SubType) -> Self {
1396        self.sub_type = sub_type;
1397        self
1398    }
1399
1400    /// Toggle durability.
1401    #[must_use]
1402    pub fn durable(mut self, durable: bool) -> Self {
1403        self.durable = durable;
1404        self
1405    }
1406
1407    /// Set the initial position the broker dispatches from when the subscription is new.
1408    #[must_use]
1409    pub fn initial_position(mut self, position: pb::command_subscribe::InitialPosition) -> Self {
1410        self.initial_position = position;
1411        self
1412    }
1413
1414    /// Set the receiver queue size.
1415    #[must_use]
1416    pub fn receiver_queue_size(mut self, size: usize) -> Self {
1417        self.receiver_queue_size = size;
1418        self
1419    }
1420}
1421
1422impl<S: Schema, E: crate::Engine> TypedConsumerBuilder<'_, S, E>
1423where
1424    E::ClientState: crate::SubscribeApi,
1425{
1426    /// Build and subscribe via the engine-generic [`crate::SubscribeApi`]
1427    /// trait. The configured schema is advertised on
1428    /// `CommandSubscribe.schema`.
1429    pub async fn subscribe(
1430        self,
1431    ) -> Result<TypedConsumer<S, <E::ClientState as crate::SubscribeApi>::Consumer>, PulsarError>
1432    {
1433        let subscription = self
1434            .subscription
1435            .ok_or_else(|| PulsarError::Config("subscription name is required".to_owned()))?;
1436        let schema_pb = pb::Schema {
1437            name: self.topic.clone(),
1438            schema_data: self.schema.schema_data(),
1439            r#type: self.schema.schema_type() as i32,
1440            properties: self
1441                .schema
1442                .properties()
1443                .into_iter()
1444                .map(|(key, value)| pb::KeyValue { key, value })
1445                .collect(),
1446        };
1447        let mut builder = self
1448            .client
1449            .consumer(self.topic)
1450            .subscription(subscription)
1451            .subscription_type(self.sub_type)
1452            .durable(self.durable)
1453            .initial_position(self.initial_position)
1454            .receiver_queue_size(self.receiver_queue_size)
1455            .read_compacted(self.read_compacted)
1456            .schema(schema_pb);
1457        if let Some(name) = self.consumer_name {
1458            builder = builder.name(name);
1459        }
1460        if let Some(level) = self.priority_level {
1461            builder = builder.priority_level(level);
1462        }
1463        for (k, v) in self.properties {
1464            builder = builder.property(k, v);
1465        }
1466        for (k, v) in self.subscription_properties {
1467            builder = builder.subscription_property(k, v);
1468        }
1469        if let Some(d) = self.negative_ack_redelivery_delay {
1470            builder = builder.negative_ack_redelivery_delay(d);
1471        }
1472        if let Some(t) = self.ack_timeout {
1473            builder = builder.ack_timeout(t);
1474        }
1475        if let Some(w) = self.ack_group_time {
1476            builder = builder.ack_group_time(w);
1477        }
1478        if let Some((max, topic_opt)) = self.dlq_policy {
1479            builder = builder.dead_letter_policy(max, topic_opt);
1480        }
1481        if let Some(max) = self.max_pending_chunked_message {
1482            builder = builder.max_pending_chunked_message(max);
1483        }
1484        if let Some(auto_ack) = self.auto_ack_oldest_chunked_message_on_queue_full {
1485            builder = builder.auto_ack_oldest_chunked_message_on_queue_full(auto_ack);
1486        }
1487        if let Some(expire) = self.expire_time_of_incomplete_chunked_message {
1488            builder = builder.expire_time_of_incomplete_chunked_message(expire);
1489        }
1490        if let Some(cfg) = self.key_shared {
1491            builder = builder.key_shared_policy(cfg);
1492        }
1493        if let Some(id) = self.start_message_id {
1494            builder = builder.start_message_id(id);
1495        }
1496        if let Some(on) = self.replicate_subscription_state {
1497            builder = builder.replicate_subscription_state(on);
1498        }
1499        if let Some(on) = self.force_topic_creation {
1500            builder = builder.force_topic_creation(on);
1501        }
1502        if let Some(sec) = self.start_message_rollback_duration_sec {
1503            builder = builder.start_message_rollback_duration(sec);
1504        }
1505        let inner = builder.subscribe().await?;
1506        Ok(TypedConsumer {
1507            inner,
1508            schema: self.schema,
1509        })
1510    }
1511}
1512
1513impl<S: Schema + Send + Sync + 'static, E: crate::Engine> TypedConsumerBuilder<'_, S, E>
1514where
1515    E::ClientState: crate::SubscribeApi,
1516    <E::ClientState as crate::SubscribeApi>::Consumer: Clone,
1517{
1518    /// Subscribe and start a schema-aware push-delivery poller, returning the
1519    /// owning [`crate::MessageListenerHandle`]. Mirrors Java's
1520    /// `ConsumerBuilder<T>#messageListener(...)` + `subscribe()`.
1521    ///
1522    /// Each message is decoded against the configured schema and the resulting
1523    /// [`TypedMessage`] is handed to the callback sequentially and in order.
1524    /// The poller does **not** auto-ack (the callback acks explicitly) and stops
1525    /// cleanly when the consumer is closed or the returned handle is dropped.
1526    ///
1527    /// If the schema needs a broker-side resolution
1528    /// ([`Schema::needs_broker_schema`](magnetar_proto::schema::Schema::needs_broker_schema)),
1529    /// it is resolved once before the poller starts, so per-message decoding in
1530    /// the callback stays synchronous.
1531    ///
1532    /// # Errors
1533    /// - [`PulsarError::Config`] if no listener was set via [`Self::message_listener`].
1534    /// - [`PulsarError::Client`] if the one-shot broker schema resolution fails.
1535    /// - [`PulsarError::Other`] (stringified) on broker rejection or wire failure.
1536    pub async fn subscribe_with_listener(
1537        self,
1538    ) -> Result<crate::MessageListenerHandle, PulsarError> {
1539        let Some(listener) = self.listener.clone() else {
1540            return Err(PulsarError::Config(
1541                "subscribe_with_listener() requires a listener — \
1542                 call message_listener(...) first (or use subscribe() for pull mode)"
1543                    .to_owned(),
1544            ));
1545        };
1546        let typed = self.subscribe().await?;
1547        let schema = typed.schema.clone();
1548        // Resolve the broker schema once up front so the per-message decode in
1549        // the poller closure can stay synchronous (matches `receive()`'s lazy
1550        // first-call resolution, hoisted to subscribe time for push mode).
1551        if schema.needs_broker_schema() {
1552            let resolved = crate::ConsumerApi::get_schema(&typed.inner, None)
1553                .await
1554                .map_err(|err| PulsarError::Other(format!("get_schema: {err}")))?;
1555            schema.store_resolved_schema(resolved);
1556        }
1557        let handle = crate::consumer_listener::spawn_listener_loop(typed.inner, move |raw| {
1558            // Decode against the (now-resolved) schema. A per-message decode
1559            // failure is dropped rather than poisoning the whole poller — the
1560            // message is left unacked so the broker can redeliver, matching the
1561            // callback's explicit-ack contract.
1562            if let Ok(value) = schema.decode(&raw.payload) {
1563                let msg = TypedMessage {
1564                    message_id: raw.message_id,
1565                    value,
1566                    payload: raw.payload.clone(),
1567                    raw,
1568                };
1569                listener(&msg);
1570            }
1571        });
1572        Ok(handle)
1573    }
1574}
1575
1576fn schema_to_pulsar(err: SchemaError) -> PulsarError {
1577    PulsarError::Schema(err)
1578}