Skip to main content

magnetar/
client.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Ergonomic top-level client built on the tokio engine.
4//!
5//! Wraps [`magnetar_runtime_tokio::Client`] with a builder API plus simple
6//! `producer(topic).create()` / `consumer(topic).subscription(s).subscribe()`
7//! constructors so the common path doesn't expose raw protocol types like
8//! [`magnetar_proto::conn::CreateProducerRequest`] unless the user wants
9//! them.
10
11use bytes::Bytes;
12use magnetar_proto::pb;
13use magnetar_runtime_tokio::{Client, ClientError};
14
15/// Result alias used inside this module.
16type Result<T, E = PulsarError> = std::result::Result<T, E>;
17
18/// Top-level errors surfaced by the façade.
19#[derive(Debug, thiserror::Error)]
20pub enum PulsarError {
21    /// Underlying tokio engine error.
22    #[error("client error: {0}")]
23    Client(#[from] ClientError),
24    /// Configuration error before any I/O happened.
25    #[error("configuration error: {0}")]
26    Config(String),
27    /// Schema encode / decode error from a [`crate::TypedProducer`] / [`crate::TypedConsumer`].
28    #[error("schema error: {0}")]
29    Schema(#[from] magnetar_proto::schema::SchemaError),
30    /// Engine-agnostic error surfaced by a generic façade method that
31    /// dispatches through an extension trait (e.g.
32    /// [`crate::TransactionApi`]). Carries the runtime's error message
33    /// stringified — the per-engine error type is recovered through
34    /// the runtime crate when full-fidelity diagnostics are needed.
35    /// Phase 4 of the D1 lift train (ADR-0026 §D1).
36    #[error("engine error: {0}")]
37    Other(String),
38}
39
40/// Convenience alias for outgoing application messages.
41///
42/// Wraps a `Bytes` payload plus optional [`pb::MessageMetadata`] overrides.
43/// The producer state machine assigns the sequence id and stamps publish
44/// time on send.
45#[derive(Debug, Clone, Default)]
46pub struct OutgoingMessage {
47    /// Application payload bytes.
48    pub payload: Bytes,
49    /// Optional message key (sets `partition_key`).
50    pub key: Option<String>,
51    /// Optional ordering key.
52    pub ordering_key: Option<Bytes>,
53    /// Optional event time (millis since epoch).
54    pub event_time_ms: Option<u64>,
55    /// Optional per-message properties.
56    pub properties: Vec<(String, String)>,
57    /// Optional absolute deliver-at time (millis since epoch). Mirrors Java's
58    /// `TypedMessageBuilder#deliverAt`; the broker holds the message until the deadline.
59    pub deliver_at_ms: Option<i64>,
60    /// Optional explicit replication cluster list. Mirrors Java's
61    /// `TypedMessageBuilder#replicationClusters`. An empty vector means "use the namespace
62    /// default"; pass `vec!["__local__".to_owned()]` to opt out of replication entirely
63    /// (Java's `disableReplication()` writes the same sentinel).
64    pub replication_clusters: Vec<String>,
65    /// Optional transaction id (PIP-31). When set, the broker treats this publish as part
66    /// of the open transaction. Mirrors Java `Producer#newMessage(Transaction)`.
67    pub txn_id: Option<magnetar_proto::TxnId>,
68}
69
70impl OutgoingMessage {
71    /// Construct an `OutgoingMessage` from raw payload bytes.
72    pub fn with_payload(payload: impl Into<Bytes>) -> Self {
73        Self {
74            payload: payload.into(),
75            ..Self::default()
76        }
77    }
78
79    /// Set the routing key.
80    #[must_use]
81    pub fn key(mut self, key: impl Into<String>) -> Self {
82        self.key = Some(key.into());
83        self
84    }
85
86    /// Set the ordering key.
87    #[must_use]
88    pub fn ordering_key(mut self, key: impl Into<Bytes>) -> Self {
89        self.ordering_key = Some(key.into());
90        self
91    }
92
93    /// Set the event time (milliseconds since epoch).
94    #[must_use]
95    pub fn event_time_ms(mut self, ts: u64) -> Self {
96        self.event_time_ms = Some(ts);
97        self
98    }
99
100    /// Append a property.
101    #[must_use]
102    pub fn property(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
103        self.properties.push((key.into(), value.into()));
104        self
105    }
106
107    /// Mirrors `TypedMessageBuilder#deliverAt`. The broker holds the message until the
108    /// supplied UNIX-epoch millisecond deadline before dispatching it.
109    #[must_use]
110    pub fn deliver_at_ms(mut self, ts_ms: i64) -> Self {
111        self.deliver_at_ms = Some(ts_ms);
112        self
113    }
114
115    /// Mirrors `TypedMessageBuilder#deliverAfter`. Stamps the message with
116    /// `now_ms + delay_ms` as the absolute UNIX-epoch millisecond
117    /// deadline. The caller supplies `now_ms` so this stays
118    /// sans-io-pure (ADR-0011 invariant #3): the tokio engine convenience
119    /// methods snapshot the host clock at the call site; the moonpool
120    /// engine plugs in a virtual wall clock so the resulting wire bytes
121    /// are deterministic across seeds. Use [`Self::deliver_at_ms`] for
122    /// the absolute-deadline variant.
123    #[must_use]
124    pub fn deliver_after_ms(mut self, now_ms: i64, delay_ms: i64) -> Self {
125        self.deliver_at_ms = Some(now_ms.saturating_add(delay_ms));
126        self
127    }
128
129    /// Mirrors `TypedMessageBuilder#replicationClusters`. Overrides the namespace-default
130    /// replication list with the given clusters for this message only.
131    #[must_use]
132    pub fn replication_clusters(mut self, clusters: Vec<String>) -> Self {
133        self.replication_clusters = clusters;
134        self
135    }
136
137    /// Mirrors `TypedMessageBuilder#disableReplication`. Sentinel for "do not replicate this
138    /// message to any other cluster" — the broker recognises the `__local__` cluster id.
139    #[must_use]
140    pub fn disable_replication(mut self) -> Self {
141        self.replication_clusters = vec!["__local__".to_owned()];
142        self
143    }
144
145    /// Mirrors Java `Producer#newMessage(Transaction)`. Stamps the supplied transaction id
146    /// on the publish so the broker treats it as part of the open transaction (PIP-31).
147    #[must_use]
148    pub fn txn(mut self, txn_id: magnetar_proto::TxnId) -> Self {
149        self.txn_id = Some(txn_id);
150        self
151    }
152
153    /// Set the payload bytes. Mirrors Java `TypedMessageBuilder#value(byte[])` for the raw
154    /// bytes case — schema-encoded values land here after the schema-aware layer serialises
155    /// them. Lets the builder be constructed `OutgoingMessage::default().key(..).value(..)`
156    /// without forcing the caller through [`Self::with_payload`].
157    #[must_use]
158    pub fn value(mut self, payload: impl Into<Bytes>) -> Self {
159        self.payload = payload.into();
160        self
161    }
162
163    /// Send this message through `producer` and return the in-flight
164    /// [`magnetar_runtime_tokio::SendFut`]. Mirrors the terminal `send()` step of Java's
165    /// `TypedMessageBuilder`: `producer.newMessage().key(..).value(..).send()`. Equivalent
166    /// to `producer.send(msg.into())`, just chainable.
167    pub fn send(
168        mut self,
169        producer: &magnetar_runtime_tokio::Producer,
170    ) -> magnetar_runtime_tokio::SendFut {
171        crate::inject_otel_context(&mut self.properties);
172        producer.send(self.into())
173    }
174}
175
176impl From<OutgoingMessage> for magnetar_proto::producer::OutgoingMessage {
177    fn from(msg: OutgoingMessage) -> Self {
178        let mut metadata = pb::MessageMetadata::default();
179        if let Some(k) = msg.key {
180            metadata.partition_key = Some(k);
181            metadata.partition_key_b64_encoded = Some(false);
182        }
183        if let Some(ok) = msg.ordering_key {
184            metadata.ordering_key = Some(ok);
185        }
186        if let Some(ts) = msg.event_time_ms {
187            metadata.event_time = Some(ts);
188        }
189        if let Some(ts) = msg.deliver_at_ms {
190            metadata.deliver_at_time = Some(ts);
191        }
192        if !msg.replication_clusters.is_empty() {
193            metadata.replicate_to = msg.replication_clusters;
194        }
195        for (k, v) in msg.properties {
196            metadata.properties.push(pb::KeyValue { key: k, value: v });
197        }
198        let uncompressed_size = u32::try_from(msg.payload.len()).unwrap_or(u32::MAX);
199        Self {
200            payload: msg.payload,
201            metadata,
202            uncompressed_size,
203            num_messages: 1,
204            txn_id: msg.txn_id,
205            source_message_id: None,
206        }
207    }
208}
209
210/// Per-topic seek target supplied by the closure passed to
211/// [`crate::MultiTopicsConsumer::seek_per_partition`] (and the equivalent on
212/// [`crate::PartitionedConsumer`]). Mirrors Java's
213/// `Consumer#seek(Function<String, Object>)`, where the function returns either a
214/// `MessageId` or a `Long` publish-time millis-since-epoch.
215#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216pub enum SeekTarget {
217    /// Seek the child consumer to a specific message id. Mirrors Java
218    /// `Consumer#seek(MessageId)`.
219    MessageId(magnetar_proto::MessageId),
220    /// Seek the child consumer to a publish-time deadline (millis since UNIX epoch).
221    /// Mirrors Java `Consumer#seek(long)`.
222    PublishTimeMs(u64),
223}
224
225/// Java `ProducerInterceptor` SPI. Plug pipeline hooks in front of `Producer::send` to
226/// inspect, mutate, or react to outgoing messages. Mirrors the Java
227/// `org.apache.pulsar.client.api.interceptor.ProducerInterceptor` interface — `eligible`
228/// gates whether the interceptor runs for a given message, `before_send` runs first
229/// (mutating the [`OutgoingMessage`]), and `on_send_acknowledgement` fires after the
230/// broker acks the publish (or the send errors out).
231///
232/// Each callback runs on the send path — keep them fast and non-blocking. Use
233/// [`send_with_interceptors`] to chain a list of interceptors against an
234/// [`OutgoingMessage`].
235pub trait ProducerInterceptor: Send + Sync + std::fmt::Debug {
236    /// Decide whether this interceptor applies to the given message. Default: always.
237    fn eligible(&self, _msg: &OutgoingMessage) -> bool {
238        true
239    }
240
241    /// Mutate the message before it is encoded and sent. Mirrors Java
242    /// `ProducerInterceptor#beforeSend`.
243    fn before_send(&self, msg: &mut OutgoingMessage);
244
245    /// Fired after the broker acks the publish (or the send errors out). Mirrors Java
246    /// `ProducerInterceptor#onSendAcknowledgement`. The default no-ops so most
247    /// implementations only have to provide [`Self::before_send`].
248    fn on_send_acknowledgement(
249        &self,
250        _msg: &OutgoingMessage,
251        _outcome: Result<magnetar_proto::MessageId, &PulsarError>,
252    ) {
253    }
254}
255
256/// Send `msg` through `producer`, running every eligible [`ProducerInterceptor`] in
257/// `interceptors` in order. Mirrors Java's interceptor-chain semantics: `eligible` is
258/// evaluated against the *original* message, `before_send` runs in order on a single
259/// message the chain progressively mutates, and `on_send_acknowledgement` fires on every
260/// eligible interceptor regardless of whether the broker accepted the publish.
261///
262/// Use [`magnetar_runtime_tokio::Producer::send`] directly when no interceptors are
263/// configured — this helper exists so callers can opt into the chain without weaving the
264/// dispatch logic into the producer struct.
265///
266/// # Errors
267///
268/// Propagates the producer's error wrapped in [`PulsarError::Client`] after notifying
269/// the chain.
270pub async fn send_with_interceptors(
271    producer: &magnetar_runtime_tokio::Producer,
272    mut msg: OutgoingMessage,
273    interceptors: &[std::sync::Arc<dyn ProducerInterceptor>],
274) -> Result<magnetar_proto::MessageId, PulsarError> {
275    let eligible: Vec<std::sync::Arc<dyn ProducerInterceptor>> = interceptors
276        .iter()
277        .filter(|i| i.eligible(&msg))
278        .cloned()
279        .collect();
280    for i in &eligible {
281        i.before_send(&mut msg);
282    }
283    crate::inject_otel_context(&mut msg.properties);
284    let snapshot = msg.clone();
285    let mapped: Result<magnetar_proto::MessageId, PulsarError> =
286        producer.send(msg.into()).await.map_err(PulsarError::Client);
287    for i in &eligible {
288        let outcome: Result<magnetar_proto::MessageId, &PulsarError> = match &mapped {
289            Ok(id) => Ok(*id),
290            Err(err) => Err(err),
291        };
292        i.on_send_acknowledgement(&snapshot, outcome);
293    }
294    mapped
295}
296
297/// Java `ConsumerInterceptor` SPI. Plug receive-side hooks behind `Consumer::receive`
298/// to inspect / mutate incoming messages and observe ack outcomes. Mirrors
299/// `org.apache.pulsar.client.api.interceptor.ConsumerInterceptor`:
300/// - `before_consume` runs on every received message and may mutate it.
301/// - `on_acknowledge` fires on every individual / batch ack.
302/// - `on_acknowledge_cumulative` fires on every cumulative ack.
303/// - `on_negative_acks_send` fires when the runtime forwards a redeliver-unacknowledged command
304///   (negative ack with delay or immediate).
305///
306/// Each callback runs on the receive / ack path — keep them fast and non-blocking. Use
307/// [`receive_with_interceptors`] to chain a list against a [`magnetar_runtime_tokio::Consumer`].
308pub trait ConsumerInterceptor: Send + Sync + std::fmt::Debug {
309    /// Inspect and optionally mutate the incoming message before it is handed back to
310    /// the user. Mirrors Java `ConsumerInterceptor#beforeConsume`.
311    fn before_consume(&self, msg: &mut IncomingMessage);
312
313    /// Fired after an individual or batch ack completes (success or error). Mirrors Java
314    /// `ConsumerInterceptor#onAcknowledge`.
315    fn on_acknowledge(
316        &self,
317        _message_id: magnetar_proto::MessageId,
318        _outcome: Result<(), &PulsarError>,
319    ) {
320    }
321
322    /// Fired after a cumulative ack completes. Mirrors Java
323    /// `ConsumerInterceptor#onAcknowledgeCumulative`.
324    fn on_acknowledge_cumulative(
325        &self,
326        _message_id: magnetar_proto::MessageId,
327        _outcome: Result<(), &PulsarError>,
328    ) {
329    }
330
331    /// Fired when the runtime forwards a `CommandRedeliverUnacknowledgedMessages` for one
332    /// or more message ids. Mirrors Java `ConsumerInterceptor#onNegativeAcksSend`.
333    fn on_negative_acks_send(&self, _message_ids: &[magnetar_proto::MessageId]) {}
334}
335
336/// Receive the next message via `consumer`, running every [`ConsumerInterceptor`] in
337/// `interceptors` against the payload before it is returned. Mirrors Java's interceptor
338/// chain on the receive path — every interceptor's `before_consume` runs in order on a
339/// single progressively-mutated message.
340///
341/// # Errors
342///
343/// Propagates the underlying receive error wrapped in [`PulsarError::Client`].
344pub async fn receive_with_interceptors(
345    consumer: &magnetar_runtime_tokio::Consumer,
346    interceptors: &[std::sync::Arc<dyn ConsumerInterceptor>],
347) -> Result<IncomingMessage, PulsarError> {
348    let raw = consumer.receive().await.map_err(PulsarError::Client)?;
349    let mut msg: IncomingMessage = raw.into();
350    for i in interceptors {
351        i.before_consume(&mut msg);
352    }
353    Ok(msg)
354}
355
356/// Ack via `consumer` and notify every interceptor of the outcome. Mirrors Java's
357/// post-ack callback chain. Returns whatever the runtime ack returned, mapped into a
358/// [`PulsarError`].
359pub async fn ack_with_interceptors(
360    consumer: &magnetar_runtime_tokio::Consumer,
361    message_id: magnetar_proto::MessageId,
362    interceptors: &[std::sync::Arc<dyn ConsumerInterceptor>],
363) -> Result<(), PulsarError> {
364    let result: Result<(), PulsarError> =
365        consumer.ack(message_id).await.map_err(PulsarError::Client);
366    for i in interceptors {
367        let outcome: Result<(), &PulsarError> = match &result {
368            Ok(()) => Ok(()),
369            Err(err) => Err(err),
370        };
371        i.on_acknowledge(message_id, outcome);
372    }
373    result
374}
375
376/// Cumulative ack variant of [`ack_with_interceptors`]. Notifies via
377/// `on_acknowledge_cumulative` instead of `on_acknowledge`.
378pub async fn ack_cumulative_with_interceptors(
379    consumer: &magnetar_runtime_tokio::Consumer,
380    message_id: magnetar_proto::MessageId,
381    interceptors: &[std::sync::Arc<dyn ConsumerInterceptor>],
382) -> Result<(), PulsarError> {
383    let result: Result<(), PulsarError> = consumer
384        .ack_cumulative(message_id)
385        .await
386        .map_err(PulsarError::Client);
387    for i in interceptors {
388        let outcome: Result<(), &PulsarError> = match &result {
389            Ok(()) => Ok(()),
390            Err(err) => Err(err),
391        };
392        i.on_acknowledge_cumulative(message_id, outcome);
393    }
394    result
395}
396
397/// Extension trait that gives [`magnetar_runtime_tokio::Producer`] the Java-symmetric
398/// `producer.new_message().key(..).value(..).send().await` entry point.
399///
400/// Bring it into scope with `use magnetar::ProducerExt;`.
401///
402/// # Why an extension trait
403///
404/// The trait exists to satisfy Rust's orphan rule: [`MessageBuilder`]
405/// lives in this façade crate (`magnetar`), and `Producer` lives in
406/// `magnetar-runtime-tokio` (a downstream crate from `magnetar`'s
407/// perspective in the workspace dep graph). Neither side can directly
408/// `impl MessageBuilder<'_>` against the foreign `Producer` without
409/// the trait indirection.
410///
411/// Two alternatives were considered and rejected:
412///
413/// - **Move `MessageBuilder` + `OutgoingMessage` to a new shared crate** that both `magnetar` and
414///   `magnetar-runtime-tokio` depend on. Cleanest layering but adds a crate to publish.
415/// - **Move `MessageBuilder` down into `magnetar-runtime-tokio`**. Inverts the workspace dep graph
416///   and ties `MessageBuilder` to the tokio engine specifically — bad fit because the V5 surface
417///   and any future engine-generic producer surface want `MessageBuilder` at the façade tier.
418///
419/// **Chosen path: accept the trait as a zero-cost layering artefact.**
420/// The single-impl trait is the canonical Rust workaround for this
421/// shape; bringing it into scope with `use magnetar::ProducerExt;` is
422/// a one-line cost at every call site, and the trait + impl produce
423/// no runtime overhead.
424pub trait ProducerExt {
425    /// Start a new [`OutgoingMessage`] bound to this producer. Chain the same setters as
426    /// `OutgoingMessage` ([`OutgoingMessage::key`], [`OutgoingMessage::value`],
427    /// [`OutgoingMessage::event_time_ms`], etc.) and finish with `.send().await`.
428    fn new_message(&self) -> MessageBuilder<'_>;
429}
430
431impl ProducerExt for magnetar_runtime_tokio::Producer {
432    fn new_message(&self) -> MessageBuilder<'_> {
433        MessageBuilder {
434            producer: self,
435            msg: OutgoingMessage::default(),
436        }
437    }
438}
439
440/// Producer-bound counterpart to [`OutgoingMessage`]. Mirrors Java's
441/// `TypedMessageBuilder` — the producer is captured at construction so the terminal `send()`
442/// has no extra argument.
443#[derive(Debug)]
444pub struct MessageBuilder<'a> {
445    producer: &'a magnetar_runtime_tokio::Producer,
446    msg: OutgoingMessage,
447}
448
449impl MessageBuilder<'_> {
450    /// Set the routing key. See [`OutgoingMessage::key`].
451    #[must_use]
452    pub fn key(mut self, key: impl Into<String>) -> Self {
453        self.msg = self.msg.key(key);
454        self
455    }
456
457    /// Set the ordering key. See [`OutgoingMessage::ordering_key`].
458    #[must_use]
459    pub fn ordering_key(mut self, key: impl Into<Bytes>) -> Self {
460        self.msg = self.msg.ordering_key(key);
461        self
462    }
463
464    /// Set the event time (millis since epoch). See [`OutgoingMessage::event_time_ms`].
465    #[must_use]
466    pub fn event_time_ms(mut self, ts: u64) -> Self {
467        self.msg = self.msg.event_time_ms(ts);
468        self
469    }
470
471    /// Append a property. See [`OutgoingMessage::property`].
472    #[must_use]
473    pub fn property(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
474        self.msg = self.msg.property(key, value);
475        self
476    }
477
478    /// See [`OutgoingMessage::deliver_at_ms`].
479    #[must_use]
480    pub fn deliver_at_ms(mut self, ts_ms: i64) -> Self {
481        self.msg = self.msg.deliver_at_ms(ts_ms);
482        self
483    }
484
485    /// See [`OutgoingMessage::deliver_after_ms`]. The caller supplies
486    /// `now_ms` (sans-io, ADR-0011 invariant #3); the engine convenience
487    /// methods snapshot the host wall clock on the tokio path and the
488    /// virtual wall clock on the moonpool path.
489    #[must_use]
490    pub fn deliver_after_ms(mut self, now_ms: i64, delay_ms: i64) -> Self {
491        self.msg = self.msg.deliver_after_ms(now_ms, delay_ms);
492        self
493    }
494
495    /// See [`OutgoingMessage::replication_clusters`].
496    #[must_use]
497    pub fn replication_clusters(mut self, clusters: Vec<String>) -> Self {
498        self.msg = self.msg.replication_clusters(clusters);
499        self
500    }
501
502    /// See [`OutgoingMessage::disable_replication`].
503    #[must_use]
504    pub fn disable_replication(mut self) -> Self {
505        self.msg = self.msg.disable_replication();
506        self
507    }
508
509    /// See [`OutgoingMessage::txn`].
510    #[must_use]
511    pub fn txn(mut self, txn_id: magnetar_proto::TxnId) -> Self {
512        self.msg = self.msg.txn(txn_id);
513        self
514    }
515
516    /// Set the payload bytes. See [`OutgoingMessage::value`].
517    #[must_use]
518    pub fn value(mut self, payload: impl Into<Bytes>) -> Self {
519        self.msg = self.msg.value(payload);
520        self
521    }
522
523    /// Submit the message to the producer captured at `new_message()` time. Mirrors Java's
524    /// terminal `TypedMessageBuilder#send`.
525    pub fn send(mut self) -> magnetar_runtime_tokio::SendFut {
526        crate::inject_otel_context(&mut self.msg.properties);
527        self.producer.send(self.msg.into())
528    }
529}
530
531/// Convenience alias for an incoming message handed back to the caller.
532#[derive(Debug, Clone)]
533pub struct IncomingMessage {
534    /// Message id assigned by the broker.
535    pub id: magnetar_proto::types::MessageId,
536    /// Pulsar `MessageMetadata` for the message. Refcounted (Arc) so the
537    /// batched-delivery path inside the consumer state machine can share
538    /// one parsed metadata across every sub-message of a batch instead of
539    /// deep-cloning per message. Field access works transparently
540    /// (`Arc` derefs).
541    pub metadata: std::sync::Arc<pb::MessageMetadata>,
542    /// Application payload bytes (post-decompression / post-decryption).
543    pub payload: Bytes,
544    /// Broker-supplied redelivery count.
545    pub redelivery_count: u32,
546    /// PIP-90 `BrokerEntryMetadata`. `None` when the broker did not stamp one (older
547    /// brokers / disabled namespace policy). Carries the broker's wall-clock timestamp
548    /// and per-topic index — useful for routing, dedup, and exactly-once-ish flows.
549    pub broker_entry_metadata: Option<std::sync::Arc<pb::BrokerEntryMetadata>>,
550}
551
552impl IncomingMessage {
553    /// Mirrors Java `Message#getKey`. Returns `None` for keyless messages.
554    #[must_use]
555    pub fn key(&self) -> Option<&str> {
556        self.metadata.partition_key.as_deref()
557    }
558
559    /// Mirrors Java `Message#hasKey`.
560    #[must_use]
561    pub fn has_key(&self) -> bool {
562        self.metadata.partition_key.is_some()
563    }
564
565    /// Mirrors Java `Message#getOrderingKey`. Returns `None` if unset.
566    #[must_use]
567    pub fn ordering_key(&self) -> Option<&Bytes> {
568        self.metadata.ordering_key.as_ref()
569    }
570
571    /// Mirrors Java `Message#getPublishTime` — millis since the UNIX epoch as stamped by
572    /// the producer's state machine at queue time.
573    #[must_use]
574    pub fn publish_time_ms(&self) -> u64 {
575        self.metadata.publish_time
576    }
577
578    /// Mirrors Java `Message#getEventTime`. Returns `0` if the producer didn't stamp one
579    /// (Java returns `0` in the same situation).
580    #[must_use]
581    pub fn event_time_ms(&self) -> u64 {
582        self.metadata.event_time.unwrap_or(0)
583    }
584
585    /// Mirrors Java `Message#getSequenceId`. The sequence id assigned by the producer's
586    /// state machine (visible alongside the broker-assigned message id).
587    #[must_use]
588    pub fn sequence_id(&self) -> u64 {
589        self.metadata.sequence_id
590    }
591
592    /// Mirrors Java `Message#getProducerName`.
593    #[must_use]
594    pub fn producer_name(&self) -> &str {
595        &self.metadata.producer_name
596    }
597
598    /// Mirrors Java `Message#getProperty(String)`. Returns the value for the first matching
599    /// property entry, or `None` if absent.
600    #[must_use]
601    pub fn property(&self, key: &str) -> Option<&str> {
602        self.metadata
603            .properties
604            .iter()
605            .find(|kv| kv.key == key)
606            .map(|kv| kv.value.as_str())
607    }
608
609    /// Mirrors Java `Message#getProperties` — every (key, value) pair on the message.
610    pub fn properties(&self) -> impl Iterator<Item = (&str, &str)> {
611        self.metadata
612            .properties
613            .iter()
614            .map(|kv| (kv.key.as_str(), kv.value.as_str()))
615    }
616
617    /// Mirrors Java `Message#getRedeliveryCount`. The broker-side count of how many times
618    /// this message has been redelivered.
619    #[must_use]
620    pub fn redelivery_count(&self) -> u32 {
621        self.redelivery_count
622    }
623
624    /// Mirrors Java `Message#getReplicatedFrom`. `None` if the message wasn't replicated.
625    #[must_use]
626    pub fn replicated_from(&self) -> Option<&str> {
627        self.metadata.replicated_from.as_deref()
628    }
629
630    /// Mirrors Java `Message#isReplicated`. `true` if this message was geo-replicated from
631    /// another cluster — equivalent to `replicated_from().is_some()`.
632    #[must_use]
633    pub fn is_replicated(&self) -> bool {
634        self.metadata.replicated_from.is_some()
635    }
636
637    /// `true` if the message arrived as part of a batched entry. The position within the
638    /// batch is on `id.batch_index`. Useful for partial-batch ack logic and telemetry.
639    #[must_use]
640    pub fn is_batched(&self) -> bool {
641        self.id.batch_index >= 0
642    }
643
644    /// `true` if the message arrived on a partitioned topic. The partition index is on
645    /// `id.partition`.
646    #[must_use]
647    pub fn is_partitioned(&self) -> bool {
648        self.id.partition >= 0
649    }
650
651    /// Payload size in bytes (post-decompression / post-decryption). Mirrors Java
652    /// `Message#size`. Equivalent to `self.payload.len()`.
653    #[must_use]
654    pub fn size(&self) -> usize {
655        self.payload.len()
656    }
657
658    /// `true` if the payload is empty — the Pulsar convention for a tombstone in a
659    /// compacted topic. Mirrors Java `Message#isEmpty`.
660    #[must_use]
661    pub fn is_empty(&self) -> bool {
662        self.payload.is_empty()
663    }
664
665    /// Mirrors Java `Message#hasReplicateTo`. `true` when the producer stamped an explicit
666    /// replication cluster list (via `OutgoingMessage::replication_clusters` /
667    /// `disable_replication`).
668    #[must_use]
669    pub fn has_replicate_to(&self) -> bool {
670        !self.metadata.replicate_to.is_empty()
671    }
672
673    /// Mirrors Java `Message#getReplicateTo`. Returns the cluster ids the message was
674    /// pinned to, or an empty slice when the producer used the namespace default.
675    #[must_use]
676    pub fn replicate_to(&self) -> &[String] {
677        &self.metadata.replicate_to
678    }
679
680    /// Mirrors Java `Message#hasEventTime`. `true` if the producer stamped a non-zero
681    /// event-time (Java distinguishes "unset" from "stamped 0" via this predicate).
682    #[must_use]
683    pub fn has_event_time(&self) -> bool {
684        self.metadata.event_time.is_some_and(|t| t != 0)
685    }
686
687    /// Mirrors Java `Message#hasOrderingKey`.
688    #[must_use]
689    pub fn has_ordering_key(&self) -> bool {
690        self.metadata.ordering_key.is_some()
691    }
692
693    /// Mirrors Java `Message#hasProperty(String)`.
694    #[must_use]
695    pub fn has_property(&self, key: &str) -> bool {
696        self.metadata.properties.iter().any(|kv| kv.key == key)
697    }
698
699    /// Mirrors Java `Message#hasProperties` — `true` if the message carries at least one
700    /// (key, value) property entry.
701    #[must_use]
702    pub fn has_properties(&self) -> bool {
703        !self.metadata.properties.is_empty()
704    }
705
706    /// Mirrors Java `Message#getSchemaVersion`. `None` for messages produced by schemaless
707    /// producers (or via auto-produce-bytes).
708    #[must_use]
709    pub fn schema_version(&self) -> Option<&[u8]> {
710        self.metadata.schema_version.as_deref()
711    }
712
713    /// PIP-90 broker timestamp — wall-clock millis since epoch the broker assigned when it
714    /// persisted the entry. Returns `None` when the namespace policy disables broker-entry
715    /// metadata or the broker is older than PIP-90.
716    #[must_use]
717    pub fn broker_publish_time_ms(&self) -> Option<u64> {
718        self.broker_entry_metadata
719            .as_ref()
720            .and_then(|m| m.broker_timestamp)
721    }
722
723    /// PIP-90 per-topic broker index — monotonic offset the broker assigned when it
724    /// persisted the entry. `None` under the same conditions as
725    /// [`Self::broker_publish_time_ms`].
726    #[must_use]
727    pub fn broker_index(&self) -> Option<u64> {
728        self.broker_entry_metadata.as_ref().and_then(|m| m.index)
729    }
730
731    /// `true` if the message metadata carries PIP-4 encryption context (one or more
732    /// wrapped symmetric keys + the encryption algorithm name). Useful for callers
733    /// running with `CryptoFailureAction::Consume` who want to know whether they need
734    /// to attempt out-of-band decryption.
735    #[must_use]
736    pub fn has_encryption(&self) -> bool {
737        !self.metadata.encryption_keys.is_empty()
738    }
739
740    /// PIP-4 encryption algorithm name (e.g. `"AES/GCM/NoPadding"`). `None` if the
741    /// producer did not encrypt this message.
742    #[must_use]
743    pub fn encryption_algorithm(&self) -> Option<&str> {
744        self.metadata.encryption_algo.as_deref()
745    }
746
747    /// PIP-4 wrapped symmetric-key entries. Empty slice when the producer did not
748    /// encrypt this message. Each entry carries the key name + the ciphertext-wrapped
749    /// data key the broker echoed back from the producer's `CryptoKeyReader`.
750    #[must_use]
751    pub fn encryption_keys(&self) -> &[magnetar_proto::pb::EncryptionKeys] {
752        &self.metadata.encryption_keys
753    }
754
755    /// PIP-4 encryption parameter bytes (typically the AES GCM IV/nonce). `None` if the
756    /// producer did not encrypt this message.
757    #[must_use]
758    pub fn encryption_param(&self) -> Option<&[u8]> {
759        self.metadata.encryption_param.as_deref()
760    }
761}
762
763impl From<magnetar_proto::event::IncomingMessage> for IncomingMessage {
764    fn from(msg: magnetar_proto::event::IncomingMessage) -> Self {
765        Self {
766            id: msg.message_id,
767            metadata: msg.metadata,
768            payload: msg.payload,
769            redelivery_count: msg.redelivery_count,
770            broker_entry_metadata: msg.broker_entry_metadata,
771        }
772    }
773}
774
775/// High-level Pulsar client, generic over the runtime [`Engine`](crate::Engine).
776///
777/// Defaults to [`crate::TokioEngine`] (the production engine) so existing
778/// callers write `PulsarClient::builder()` without naming a type parameter.
779/// Callers exercising the moonpool deterministic-simulation engine
780/// parametrise with `PulsarClient::<MoonpoolEngine<P>>` (see
781/// [ADR-0019](../../specs/adr/0019-engine-scope-and-moonpool-parity.md)
782/// gate (e), "Option A").
783///
784/// Every façade surface (`producer`, `consumer`, `reader`, `typed_producer`,
785/// `typed_consumer`, partitioned / multi-topics / pattern / table-view
786/// constructors, transactions, interceptor SPI, …) is implemented only on
787/// `PulsarClient<TokioEngine>`. Moonpool-side callers that reach
788/// for one of these get a clean trait-bound failure — matching ADR-0019
789/// §Decision "no silent fallbacks".
790#[derive(Debug)]
791pub struct PulsarClient<E: crate::Engine = crate::TokioEngine> {
792    pub(crate) inner: E::ClientState,
793    pub(crate) memory_limit: Option<MemoryLimit>,
794}
795
796impl PulsarClient<crate::TokioEngine> {
797    /// Borrow the underlying runtime client. Re-exported for sibling modules
798    /// ([`crate::PartitionedProducer`]) that need to call lower-level methods like
799    /// `partitioned_topic_metadata` without going through a builder.
800    pub(crate) fn runtime_client(&self) -> &Client {
801        &self.inner
802    }
803
804    /// Start building a client. Returns a tokio-engine
805    /// [`crate::ClientBuilder`] — the default `E = TokioEngine` on
806    /// [`PulsarClient<E>`]. Users targeting the moonpool engine open
807    /// the engine directly via
808    /// [`magnetar_runtime_moonpool::MoonpoolEngine`] (see
809    /// [`PulsarClient::<MoonpoolEngine<P>>::from_moonpool`](crate::PulsarClient)
810    /// for the equivalent constructor).
811    #[must_use]
812    pub fn builder() -> crate::client_builder::ClientBuilder {
813        crate::client_builder::ClientBuilder::default()
814    }
815
816    /// The global publish memory budget configured at build time, if any.
817    /// Mirrors Java `PulsarClient#getMemoryLimit`. `None` means no limit was
818    /// configured (the Java default).
819    ///
820    /// **Note**: today this is configuration-only — the runtime does not yet
821    /// enforce the limit. See [`crate::ClientBuilder::memory_limit`] for the planned
822    /// follow-up.
823    #[must_use]
824    pub fn memory_limit(&self) -> Option<MemoryLimit> {
825        self.memory_limit
826    }
827
828    // producer / consumer / reader / table_view / typed_table_view /
829    // partitioned_producer are engine-generic — see the dedicated
830    // `impl<E: Engine> PulsarClient<E>` block below.
831
832    /// PIP-180 (ADR-0033): subscribe with automatic shadow-source resolution.
833    ///
834    /// Performs the `magnetar-admin` `get_shadow_source(topic)` REST lookup,
835    /// subscribes to `topic` with `subscription_name` (exclusive, durable),
836    /// and — when the broker reports `topic` is a shadow — primes the
837    /// consumer's shadow metadata via
838    /// [`magnetar_runtime_tokio::Consumer::set_shadow_source`] so the receive
839    /// path emits
840    /// [`magnetar_proto::ConnectionEvent::MessageReceivedFromShadow`]
841    /// without an out-of-band lookup per message.
842    ///
843    /// For regular (non-shadow) topics the call collapses to a plain
844    /// `.consumer(topic).subscription(subscription_name).subscribe()`.
845    ///
846    /// # Errors
847    ///
848    /// - [`PulsarError::Other`] wrapping the admin REST error if the `get_shadow_source` lookup
849    ///   fails.
850    /// - Any error from the underlying `.subscribe()` round-trip.
851    #[cfg(feature = "admin")]
852    pub async fn subscribe_shadow_aware(
853        &self,
854        admin: &magnetar_admin::AdminClient,
855        topic: impl Into<String>,
856        subscription_name: impl Into<String>,
857    ) -> Result<magnetar_runtime_tokio::Consumer, PulsarError> {
858        let topic = topic.into();
859        let subscription_name = subscription_name.into();
860        let source = admin
861            .get_shadow_source(&topic)
862            .await
863            .map_err(|e| PulsarError::Other(format!("get_shadow_source({topic}): {e}")))?;
864        let consumer = self
865            .consumer(topic)
866            .subscription(subscription_name)
867            .subscribe()
868            .await?;
869        if let Some(source_topic) = source {
870            consumer.set_shadow_source(source_topic);
871        }
872        Ok(consumer)
873    }
874
875    /// PIP-33 (ADR-0034): non-blocking peek for the next replicated-subscription
876    /// marker observation buffered by the driver. `None` when the buffer is empty.
877    /// Mirrors [`magnetar_runtime_tokio::Client::poll_replicated_subscription_marker`].
878    #[must_use]
879    pub fn poll_replicated_subscription_marker(
880        &self,
881    ) -> Option<magnetar_runtime_tokio::ObservedReplicatedSubscriptionMarker> {
882        self.inner.poll_replicated_subscription_marker()
883    }
884
885    /// PIP-33 (ADR-0034): await the next replicated-subscription marker
886    /// observation. Resolves to `None` when the connection has closed and no
887    /// further markers will arrive. Mirrors
888    /// [`magnetar_runtime_tokio::Client::next_replicated_subscription_marker`].
889    pub async fn next_replicated_subscription_marker(
890        &self,
891    ) -> Option<magnetar_runtime_tokio::ObservedReplicatedSubscriptionMarker> {
892        self.inner.next_replicated_subscription_marker().await
893    }
894
895    /// Close the underlying connection.
896    pub async fn close(self) {
897        self.inner.close().await;
898    }
899
900    /// Alias for [`Self::close`]. Mirrors Java `PulsarClient#shutdown`, which is just the
901    /// blocking form of `close` — same semantics from Rust because every async future is
902    /// already non-blocking from the caller's perspective.
903    pub async fn shutdown(self) {
904        self.close().await;
905    }
906
907    /// Returns `true` while the underlying broker connection is up. Mirrors Java's
908    /// `org.apache.pulsar.client.api.Producer#isConnected` and
909    /// `Consumer#isConnected` at the client scope.
910    #[must_use]
911    pub fn is_connected(&self) -> bool {
912        self.inner.is_connected()
913    }
914
915    /// `true` once [`Self::close`] has been called or the broker connection has entered a
916    /// terminal state. Mirrors Java `PulsarClient#isClosed`.
917    #[must_use]
918    pub fn is_closed(&self) -> bool {
919        self.inner.is_closed()
920    }
921
922    /// Wall-clock time the underlying broker connection was most recently torn down (peer
923    /// EOF, I/O error, or an explicit `close()`). `None` while it has never been torn down.
924    ///
925    /// Mirrors `org.apache.pulsar.client.api.Producer#getLastDisconnectedTimestamp` /
926    /// `Consumer#getLastDisconnectedTimestamp`. Convert with
927    /// [`std::time::SystemTime::duration_since`] for Java-style millis-since-epoch.
928    #[must_use]
929    pub fn last_disconnected_timestamp(&self) -> Option<std::time::SystemTime> {
930        self.inner.last_disconnected_timestamp()
931    }
932}
933
934impl<E: crate::Engine> PulsarClient<E> {
935    /// Open a [`crate::ProducerBuilder`] for the given topic.
936    /// Engine-generic — the underlying transport is selected at
937    /// construction time.
938    #[must_use]
939    pub fn producer(&self, topic: impl Into<String>) -> crate::builders::ProducerBuilder<'_, E> {
940        crate::builders::ProducerBuilder::new(self, topic.into())
941    }
942
943    /// Open a [`crate::ConsumerBuilder`] for the given topic.
944    /// Engine-generic — the underlying transport is selected at
945    /// construction time.
946    #[must_use]
947    pub fn consumer(&self, topic: impl Into<String>) -> crate::builders::ConsumerBuilder<'_, E> {
948        crate::builders::ConsumerBuilder::new(self, topic.into())
949    }
950
951    /// Open a [`crate::ReaderBuilder`] for the given topic. A reader is a
952    /// non-durable, exclusive consumer with an auto-generated
953    /// subscription — useful for log inspection and replay.
954    /// Engine-generic — the underlying transport is selected at
955    /// construction time.
956    #[must_use]
957    pub fn reader(&self, topic: impl Into<String>) -> crate::builders::ReaderBuilder<'_, E> {
958        crate::builders::ReaderBuilder::new(self, topic.into())
959    }
960
961    /// Open a schema-aware [`crate::TypedProducerBuilder`] for the given topic. Mirrors Java's
962    /// `PulsarClient#newProducer(Schema<T>)`. Engine-generic per ADR-0026 §D1.
963    #[must_use]
964    pub fn typed_producer<S: magnetar_proto::schema::Schema>(
965        &self,
966        topic: impl Into<String>,
967        schema: std::sync::Arc<S>,
968    ) -> crate::TypedProducerBuilder<'_, S, E> {
969        crate::TypedProducerBuilder::new(self, topic.into(), schema)
970    }
971
972    /// Open a schema-aware [`crate::TypedConsumerBuilder`] for the given topic. Mirrors Java's
973    /// `PulsarClient#newConsumer(Schema<T>)`. Engine-generic per ADR-0026 §D1.
974    #[must_use]
975    pub fn typed_consumer<S: magnetar_proto::schema::Schema>(
976        &self,
977        topic: impl Into<String>,
978        schema: std::sync::Arc<S>,
979    ) -> crate::TypedConsumerBuilder<'_, S, E> {
980        crate::TypedConsumerBuilder::new(self, topic.into(), schema)
981    }
982
983    /// Open a [`crate::MultiTopicsConsumerBuilder`] that subscribes to many topics at once.
984    /// Mirrors Java's `PulsarClient#newConsumer().topics(...)`. Engine-generic per
985    /// ADR-0026 §D1 — `.subscribe()` routes through the engine-generic
986    /// [`crate::ConsumerBuilder`].
987    #[must_use]
988    pub fn multi_topics_consumer(&self) -> crate::MultiTopicsConsumerBuilder<'_, E> {
989        crate::MultiTopicsConsumerBuilder::new(self)
990    }
991
992    /// Open a [`crate::PatternConsumerBuilder`] that subscribes to every topic in a namespace
993    /// matching a broker-side regex pattern (PIP-145). Reconciles against `TopicListChanged`
994    /// deltas on demand via [`crate::PatternConsumer::update`]. Mirrors Java's
995    /// `PulsarClient#newConsumer().topicsPattern(...)`. Engine-generic per ADR-0026 §D1.
996    #[must_use]
997    pub fn pattern_consumer(&self) -> crate::PatternConsumerBuilder<'_, E> {
998        crate::PatternConsumerBuilder::new(self)
999    }
1000
1001    /// Open a [`crate::PartitionedConsumerBuilder`] for the given topic. The builder
1002    /// auto-discovers the partition count and subscribes to every partition under a single
1003    /// subscription name. Mirrors Java's `PulsarClient#newConsumer()` against a partitioned
1004    /// topic. Engine-generic per ADR-0026 §D1.
1005    #[must_use]
1006    pub fn partitioned_consumer(
1007        &self,
1008        topic: impl Into<String>,
1009    ) -> crate::PartitionedConsumerBuilder<'_, E> {
1010        crate::PartitionedConsumerBuilder::new(self, topic.into())
1011    }
1012
1013    /// Open a [`crate::PartitionedProducerBuilder`] for the given topic.
1014    /// The builder queries the broker for the partition count and opens
1015    /// one child producer per partition. Mirrors Java's
1016    /// `PulsarClient#newProducer()` against a partitioned topic.
1017    /// Engine-generic — both runtimes'
1018    /// `Client` types implement [`crate::BrokerMetadataApi`] +
1019    /// [`crate::CreateProducerApi`] so the same builder shape works
1020    /// against tokio or moonpool.
1021    #[must_use]
1022    pub fn partitioned_producer(
1023        &self,
1024        topic: impl Into<String>,
1025    ) -> crate::PartitionedProducerBuilder<'_, E> {
1026        crate::PartitionedProducerBuilder::new(self, topic.into())
1027    }
1028
1029    /// Open a [`crate::TableViewBuilder`] for the given topic. A
1030    /// [`crate::TableView`] is a key/value snapshot built from a
1031    /// compacted topic — useful for config snapshots and similar
1032    /// "latest value wins per key" patterns. Mirrors
1033    /// `PulsarClient#newTableViewBuilder`. Engine-generic — dispatches
1034    /// through [`crate::SubscribeApi`] under the hood.
1035    #[must_use]
1036    pub fn table_view(&self, topic: impl Into<String>) -> crate::TableViewBuilder<'_, E> {
1037        crate::TableViewBuilder::new(self, topic.into())
1038    }
1039
1040    /// Schema-aware [`crate::TypedTableView`] builder. Mirrors Java
1041    /// `pulsar.tableViewBuilder(Schema)` — the view decodes payloads on
1042    /// read so getters return `S::Owned` directly. Engine-generic.
1043    #[must_use]
1044    pub fn typed_table_view<S: magnetar_proto::schema::Schema>(
1045        &self,
1046        topic: impl Into<String>,
1047        schema: std::sync::Arc<S>,
1048    ) -> crate::TypedTableViewBuilder<'_, S, E> {
1049        crate::TypedTableViewBuilder::new(self, topic.into(), schema)
1050    }
1051}
1052
1053/// Broker-metadata methods that dispatch through the
1054/// [`crate::BrokerMetadataApi`] extension trait. Engine-generic per
1055/// ADR-0026 §D1 — both runtimes implement `BrokerMetadataApi` on their
1056/// `Client` type.
1057impl<E: crate::Engine> PulsarClient<E>
1058where
1059    E::ClientState: crate::BrokerMetadataApi,
1060{
1061    /// Query the broker for the partition count of `topic`. Returns `0` for non-partitioned
1062    /// topics. Mirrors Java `PulsarClient#getPartitionsForTopic`.
1063    ///
1064    /// # Errors
1065    ///
1066    /// Returns [`PulsarError::Other`] if the broker refuses the metadata lookup.
1067    pub async fn partitions_for_topic(&self, topic: &str) -> Result<u32> {
1068        let mut deadline = crate::BrokerMetadataApi::new_metadata_operation_deadline(&self.inner);
1069        self.partitions_for_topic_with_deadline(topic, &mut deadline)
1070            .await
1071    }
1072
1073    pub(crate) async fn partitions_for_topic_with_deadline(
1074        &self,
1075        topic: &str,
1076        deadline: &mut crate::OperationDeadline,
1077    ) -> Result<u32> {
1078        crate::BrokerMetadataApi::partitioned_topic_metadata_with_deadline(
1079            &self.inner,
1080            topic,
1081            deadline,
1082        )
1083        .await
1084        .map_err(|err| PulsarError::Other(format!("partitions_for_topic: {err}")))
1085    }
1086
1087    /// Subscribe to a topic-list watcher and return the initial topic snapshot for the
1088    /// given namespace + regex pattern (PIP-145). Useful for "discover all topics matching
1089    /// this pattern right now" workflows. Live updates are emitted by the connection as
1090    /// `TopicListChanged` events and surfaced through
1091    /// [`crate::BrokerMetadataApi::poll_topic_list_change`].
1092    ///
1093    /// # Errors
1094    ///
1095    /// Returns [`PulsarError::Other`] if the broker refuses the watch.
1096    pub async fn topic_list_snapshot(&self, namespace: &str, pattern: &str) -> Result<Vec<String>> {
1097        let mut deadline = crate::BrokerMetadataApi::new_metadata_operation_deadline(&self.inner);
1098        self.topic_list_snapshot_with_deadline(namespace, pattern, &mut deadline)
1099            .await
1100    }
1101
1102    pub(crate) async fn topic_list_snapshot_with_deadline(
1103        &self,
1104        namespace: &str,
1105        pattern: &str,
1106        deadline: &mut crate::OperationDeadline,
1107    ) -> Result<Vec<String>> {
1108        crate::BrokerMetadataApi::watch_topic_list_with_deadline(
1109            &self.inner,
1110            namespace,
1111            pattern,
1112            deadline,
1113        )
1114        .await
1115        .map_err(|err| PulsarError::Other(format!("topic_list_snapshot: {err}")))
1116    }
1117}
1118
1119/// Java parity: `org.apache.pulsar.client.api.MemoryLimitPolicy`.
1120///
1121/// Selects how the client behaves when the configured global publish memory budget is
1122/// exhausted (see [`crate::ClientBuilder::memory_limit`]).
1123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1124pub enum MemoryLimitPolicy {
1125    /// Fail new sends immediately with an `out of memory` error. Mirrors Java
1126    /// `MemoryLimitPolicy.FAIL_IMMEDIATELY` (the Java default).
1127    FailImmediately,
1128    /// Block the producer's `send`/`sendAsync` until enough room frees up. Mirrors
1129    /// Java `MemoryLimitPolicy.PRODUCER_BLOCK`.
1130    ProducerBlock,
1131}
1132
1133impl From<MemoryLimitPolicy> for magnetar_proto::MemoryLimitPolicy {
1134    fn from(policy: MemoryLimitPolicy) -> Self {
1135        match policy {
1136            MemoryLimitPolicy::FailImmediately => Self::FailImmediately,
1137            MemoryLimitPolicy::ProducerBlock => Self::ProducerBlock,
1138        }
1139    }
1140}
1141
1142/// Java parity: configured global publish memory budget. Stored verbatim on
1143/// [`crate::ClientBuilder`] and exposed to consumers via [`PulsarClient::memory_limit`].
1144///
1145/// Both fields are copied into the runtime [`magnetar_proto::ConnectionConfig`]; the selected
1146/// policy therefore controls whether an exhausted budget rejects or parks a send.
1147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1148pub struct MemoryLimit {
1149    /// Upper bound in bytes. `0` disables the limit (matches Java default).
1150    pub bytes: usize,
1151    /// Policy applied when the budget is exhausted.
1152    pub policy: MemoryLimitPolicy,
1153}
1154
1155/// Reader handle — a non-durable consumer that reads from a topic without persisting an
1156/// acknowledgement cursor. Use a reader for: log replay, message inspection, batch ETL, or
1157/// anywhere you want at-most-once delivery semantics that the broker doesn't track.
1158///
1159/// Generic over `C: ConsumerApi` per ADR-0026 §D1. The default
1160/// (`C = magnetar_runtime_tokio::Consumer`) keeps existing callers — including
1161/// `magnetar::Reader` (no type argument) — pointing at the tokio specialisation.
1162/// Moonpool callers name `Reader<magnetar_runtime_moonpool::Consumer<P>>` directly.
1163#[derive(Debug)]
1164pub struct Reader<C: crate::ConsumerApi = magnetar_runtime_tokio::Consumer> {
1165    pub(crate) consumer: C,
1166    /// Last message id returned via [`Self::read_next`]. Used by
1167    /// [`Self::has_message_available`] to ask the broker "is there anything past
1168    /// what I last handed you?" without the caller having to track the cursor.
1169    pub(crate) last_received: parking_lot::Mutex<Option<magnetar_proto::MessageId>>,
1170}
1171
1172impl<C: crate::ConsumerApi> Reader<C> {
1173    /// Block until the next message arrives. Identical to Java `Reader#readNext`.
1174    /// Internally also stamps the returned id into the per-reader cursor so a subsequent
1175    /// [`Self::has_message_available`] call asks the broker the right question.
1176    ///
1177    /// # Errors
1178    /// - [`PulsarError::Other`] (with the runtime's error stringified) on broker rejection or wire
1179    ///   failure.
1180    pub async fn read_next(&self) -> Result<IncomingMessage, PulsarError> {
1181        let msg = crate::ConsumerApi::receive(&self.consumer)
1182            .await
1183            .map_err(|err| PulsarError::Other(format!("read_next: {err}")))?;
1184        *self.last_received.lock() = Some(msg.message_id);
1185        Ok(IncomingMessage::from(msg))
1186    }
1187
1188    /// Manually record a received message id into the per-reader cursor. Useful when
1189    /// callers go through engine-specific receive paths directly and still want
1190    /// [`Self::has_message_available`] to behave correctly.
1191    pub fn record_received(&self, message_id: magnetar_proto::MessageId) {
1192        *self.last_received.lock() = Some(message_id);
1193    }
1194
1195    /// `true` if the broker has at least one message strictly past the most-recently
1196    /// returned message id. Mirrors Java `Reader#hasMessageAvailable` (no argument —
1197    /// the reader tracks its own cursor). Returns `true` for fresh readers (no
1198    /// `read_next` yet) if the broker reports any non-empty topic.
1199    ///
1200    /// # Errors
1201    /// - [`PulsarError::Other`] on broker rejection or wire failure.
1202    pub async fn has_message_available(&self) -> Result<bool, PulsarError> {
1203        let cursor = *self.last_received.lock();
1204        if let Some(c) = cursor {
1205            return crate::ConsumerApi::has_message_after(&self.consumer, c)
1206                .await
1207                .map_err(|err| PulsarError::Other(format!("has_message_available: {err}")));
1208        }
1209        let last = crate::ConsumerApi::last_message_id(&self.consumer)
1210            .await
1211            .map_err(|err| PulsarError::Other(format!("has_message_available: {err}")))?;
1212        Ok(last != magnetar_proto::MessageId::EARLIEST)
1213    }
1214
1215    /// Borrow the underlying consumer for advanced operations not covered by
1216    /// [`crate::ConsumerApi`] (close, seek, flow, etc.).
1217    #[must_use]
1218    pub fn consumer(&self) -> &C {
1219        &self.consumer
1220    }
1221
1222    /// Topic this reader is bound to. Mirrors Java `Reader#getTopic`.
1223    #[must_use]
1224    pub fn topic(&self) -> String {
1225        crate::ConsumerApi::topic(&self.consumer)
1226    }
1227
1228    /// Auto-generated subscription name behind this reader. Mirrors Java
1229    /// `Reader#getSubscriptionName`.
1230    #[must_use]
1231    pub fn subscription(&self) -> String {
1232        crate::ConsumerApi::subscription(&self.consumer)
1233    }
1234
1235    /// Ask the broker for the topic's last-published message id. Mirrors Java
1236    /// `Reader#getLastMessageId`.
1237    ///
1238    /// # Errors
1239    /// - [`PulsarError::Other`] on broker rejection or wire failure.
1240    pub async fn last_message_id(&self) -> Result<magnetar_proto::MessageId, PulsarError> {
1241        crate::ConsumerApi::last_message_id(&self.consumer)
1242            .await
1243            .map_err(|err| PulsarError::Other(format!("last_message_id: {err}")))
1244    }
1245
1246    /// `true` if the broker has at least one message strictly past the supplied cursor.
1247    /// Mirrors Java `Reader#hasMessageAvailable` (the Reader form takes no cursor; pass
1248    /// the last id you received).
1249    ///
1250    /// # Errors
1251    /// - [`PulsarError::Other`] on broker rejection or wire failure.
1252    pub async fn has_message_after(
1253        &self,
1254        cursor: magnetar_proto::MessageId,
1255    ) -> Result<bool, PulsarError> {
1256        crate::ConsumerApi::has_message_after(&self.consumer, cursor)
1257            .await
1258            .map_err(|err| PulsarError::Other(format!("has_message_after: {err}")))
1259    }
1260}
1261
1262/// Tokio-engine-specific Reader methods that touch types not on the
1263/// engine-agnostic [`crate::ConsumerApi`] surface — the tokio `ReceiveFut`,
1264/// `tokio::time::timeout`, `Consumer::close(self)`, and `seek_to_earliest`.
1265impl Reader<magnetar_runtime_tokio::Consumer> {
1266    /// Same as [`Self::read_next`] but bounded by `timeout`. Returns `Ok(None)` when the
1267    /// deadline elapses with no message. Mirrors Java
1268    /// `Reader#readNext(int timeout, TimeUnit unit)`.
1269    pub async fn read_next_with_timeout(
1270        &self,
1271        timeout: std::time::Duration,
1272    ) -> Result<Option<magnetar_proto::IncomingMessage>, PulsarError> {
1273        match tokio::time::timeout(timeout, self.consumer.receive()).await {
1274            Ok(Ok(msg)) => {
1275                *self.last_received.lock() = Some(msg.message_id);
1276                Ok(Some(msg))
1277            }
1278            Ok(Err(err)) => Err(PulsarError::Client(err)),
1279            Err(_) => Ok(None),
1280        }
1281    }
1282
1283    /// Returns the raw [`magnetar_runtime_tokio::ReceiveFut`] without per-reader cursor
1284    /// tracking. Use this when integrating with a custom select loop where you want
1285    /// cancel-safe receive futures; pair with [`Self::record_received`] if you still want
1286    /// `has_message_available` to work.
1287    pub fn read_next_fut(&self) -> magnetar_runtime_tokio::ReceiveFut {
1288        self.consumer.receive()
1289    }
1290
1291    /// Close the reader.
1292    pub async fn close(self) -> Result<(), PulsarError> {
1293        self.consumer.close().await.map_err(PulsarError::Client)
1294    }
1295
1296    /// Seek the reader to the earliest available message. Mirrors Java
1297    /// `Reader#seek(MessageId.earliest)`.
1298    pub async fn seek_to_earliest(&self) -> Result<(), PulsarError> {
1299        self.consumer
1300            .seek_to_earliest()
1301            .await
1302            .map_err(PulsarError::Client)
1303    }
1304
1305    /// Seek the reader to the latest (head) position. Mirrors Java
1306    /// `Reader#seek(MessageId.latest)`.
1307    pub async fn seek_to_latest(&self) -> Result<(), PulsarError> {
1308        self.consumer
1309            .seek_to_latest()
1310            .await
1311            .map_err(PulsarError::Client)
1312    }
1313
1314    /// Seek the reader to a specific message id. Mirrors Java
1315    /// `Reader#seek(MessageId)`.
1316    pub async fn seek_to_message(
1317        &self,
1318        message_id: magnetar_proto::MessageId,
1319    ) -> Result<(), PulsarError> {
1320        self.consumer
1321            .seek_to_message(message_id)
1322            .await
1323            .map_err(PulsarError::Client)
1324    }
1325
1326    /// Seek the reader to a publish-time deadline (millis since UNIX epoch). Mirrors Java
1327    /// `Reader#seek(long)`.
1328    pub async fn seek_to_timestamp(&self, publish_time_ms: u64) -> Result<(), PulsarError> {
1329        self.consumer
1330            .seek_to_timestamp(publish_time_ms)
1331            .await
1332            .map_err(PulsarError::Client)
1333    }
1334
1335    /// Mirrors `org.apache.pulsar.client.api.Reader#isConnected`.
1336    #[must_use]
1337    pub fn is_connected(&self) -> bool {
1338        self.consumer.is_connected()
1339    }
1340
1341    /// Mirrors `org.apache.pulsar.client.api.Reader#getLastDisconnectedTimestamp`.
1342    #[must_use]
1343    pub fn last_disconnected_timestamp(&self) -> Option<std::time::SystemTime> {
1344        self.consumer.last_disconnected_timestamp()
1345    }
1346
1347    /// Mirrors `org.apache.pulsar.client.api.Reader#getStats`.
1348    #[must_use]
1349    pub fn stats(&self) -> magnetar_proto::ConsumerStats {
1350        self.consumer.stats()
1351    }
1352
1353    /// `true` once the broker has signalled (via `CommandReachedEndOfTopic`) that no more
1354    /// messages will be dispatched on this topic. Mirrors Java
1355    /// `Reader#hasReachedEndOfTopic`.
1356    #[must_use]
1357    pub fn has_reached_end_of_topic(&self) -> bool {
1358        self.consumer.has_reached_end_of_topic()
1359    }
1360
1361    /// Pause delivery for this reader. The broker stops dispatching new messages once
1362    /// already-issued permits drain; buffered messages remain available via
1363    /// [`Self::read_next`]. Mirrors `Reader#pause`.
1364    pub fn pause(&self) {
1365        self.consumer.pause();
1366    }
1367
1368    /// Resume delivery after [`Self::pause`]. Mirrors `Reader#resume`.
1369    pub fn resume(&self) {
1370        self.consumer.resume();
1371    }
1372
1373    /// `true` when the reader has been disconnected longer than the configured
1374    /// "inactive" threshold. Mirrors Java `Reader#isInactive` (returns the underlying
1375    /// consumer's inactivity state since readers wrap an `Exclusive` subscription).
1376    #[must_use]
1377    pub fn is_inactive(&self) -> bool {
1378        self.consumer.is_inactive()
1379    }
1380
1381    /// `true` once the reader's underlying subscription has been closed locally or by
1382    /// the broker. Mirrors Java `Reader#isClosed`.
1383    #[must_use]
1384    pub fn is_closed(&self) -> bool {
1385        self.consumer.is_closed()
1386    }
1387
1388    /// Number of messages currently buffered in the reader's receiver queue, waiting for
1389    /// a `read_next` call to pull them out. Mirrors Java
1390    /// `Reader#getNumOfPendingMessages` semantics.
1391    #[must_use]
1392    pub fn available_in_queue(&self) -> usize {
1393        self.consumer.available_in_queue()
1394    }
1395
1396    /// Number of dispatch permits the broker still holds un-spent for this reader —
1397    /// grants issued, minus one per dispatch unit that has actually arrived. Issue #414
1398    /// re-pointed this from the purely-additive grant mirror to the real decrementing
1399    /// balance, so the value moves under dispatch (ADR-0101 amending ADR-0082).
1400    #[must_use]
1401    pub fn available_permits(&self) -> u32 {
1402        self.consumer.available_permits()
1403    }
1404
1405    /// `true` if the reader has received at least one message since opening. Mirrors
1406    /// Java `Reader#hasReceivedAnyMessage`.
1407    #[must_use]
1408    pub fn has_received_any_message(&self) -> bool {
1409        self.consumer.has_received_any_message()
1410    }
1411}
1412
1413#[cfg(test)]
1414mod outgoing_message_tests {
1415    use super::*;
1416
1417    #[test]
1418    fn value_sets_payload() {
1419        let msg = OutgoingMessage::default()
1420            .key("k")
1421            .event_time_ms(42)
1422            .property("p", "v")
1423            .value("hello");
1424        assert_eq!(msg.payload.as_ref(), b"hello");
1425        assert_eq!(msg.key.as_deref(), Some("k"));
1426        assert_eq!(msg.event_time_ms, Some(42));
1427        assert_eq!(msg.properties.len(), 1);
1428    }
1429
1430    // ADR-0011 — invariant #3 sans-io clock injection. `deliver_after_ms`
1431    // used to read the host's `SystemTime::now`; the caller now supplies
1432    // `now_ms` so the stamped `deliver_at_ms` flows through the engine
1433    // boundary deterministically.
1434    #[test]
1435    fn deliver_after_ms_routes_through_caller_now() {
1436        // Two distinct virtual "now"s — under the new signature they
1437        // MUST produce two distinct `deliver_at_ms`.
1438        let msg_a = OutgoingMessage::with_payload("p").deliver_after_ms(1_000, 250);
1439        let msg_b = OutgoingMessage::with_payload("p").deliver_after_ms(5_000, 250);
1440        assert_eq!(msg_a.deliver_at_ms, Some(1_250));
1441        assert_eq!(msg_b.deliver_at_ms, Some(5_250));
1442
1443        // Saturating add — caller passing i64::MAX should not panic.
1444        let msg_sat = OutgoingMessage::with_payload("p").deliver_after_ms(i64::MAX, 1);
1445        assert_eq!(msg_sat.deliver_at_ms, Some(i64::MAX));
1446    }
1447
1448    #[test]
1449    fn into_carries_payload_and_metadata() {
1450        let msg = OutgoingMessage::default()
1451            .key("k")
1452            .event_time_ms(7)
1453            .property("p", "v")
1454            .value(b"abc".to_vec());
1455        let converted: magnetar_proto::producer::OutgoingMessage = msg.into();
1456        assert_eq!(converted.payload.as_ref(), b"abc");
1457        assert_eq!(converted.metadata.partition_key.as_deref(), Some("k"));
1458        assert_eq!(converted.metadata.event_time, Some(7));
1459        assert_eq!(converted.metadata.properties.len(), 1);
1460        assert_eq!(converted.uncompressed_size, 3);
1461    }
1462
1463    fn message_with(metadata: pb::MessageMetadata) -> IncomingMessage {
1464        IncomingMessage {
1465            id: magnetar_proto::types::MessageId::EARLIEST,
1466            metadata: std::sync::Arc::new(metadata),
1467            payload: Bytes::new(),
1468            redelivery_count: 0,
1469            broker_entry_metadata: None,
1470        }
1471    }
1472
1473    #[test]
1474    fn incoming_has_event_time_distinguishes_zero_and_unset() {
1475        let unset = message_with(pb::MessageMetadata::default());
1476        assert!(!unset.has_event_time());
1477
1478        let zero = message_with(pb::MessageMetadata {
1479            event_time: Some(0),
1480            ..pb::MessageMetadata::default()
1481        });
1482        assert!(!zero.has_event_time());
1483
1484        let stamped = message_with(pb::MessageMetadata {
1485            event_time: Some(42),
1486            ..pb::MessageMetadata::default()
1487        });
1488        assert!(stamped.has_event_time());
1489        assert_eq!(stamped.event_time_ms(), 42);
1490    }
1491
1492    #[test]
1493    fn incoming_property_helpers() {
1494        let msg = message_with(pb::MessageMetadata {
1495            properties: vec![pb::KeyValue {
1496                key: "k".to_owned(),
1497                value: "v".to_owned(),
1498            }],
1499            ..pb::MessageMetadata::default()
1500        });
1501        assert!(msg.has_properties());
1502        assert!(msg.has_property("k"));
1503        assert!(!msg.has_property("missing"));
1504        assert_eq!(msg.property("k"), Some("v"));
1505    }
1506
1507    #[test]
1508    fn incoming_replicate_to_helpers() {
1509        let empty = message_with(pb::MessageMetadata::default());
1510        assert!(!empty.has_replicate_to());
1511        assert!(empty.replicate_to().is_empty());
1512
1513        let stamped = message_with(pb::MessageMetadata {
1514            replicate_to: vec!["a".to_owned(), "b".to_owned()],
1515            ..pb::MessageMetadata::default()
1516        });
1517        assert!(stamped.has_replicate_to());
1518        assert_eq!(stamped.replicate_to(), &["a", "b"]);
1519    }
1520
1521    #[test]
1522    fn broker_entry_metadata_getters() {
1523        let mut msg = message_with(pb::MessageMetadata::default());
1524        assert_eq!(msg.broker_publish_time_ms(), None);
1525        assert_eq!(msg.broker_index(), None);
1526
1527        msg.broker_entry_metadata = Some(std::sync::Arc::new(pb::BrokerEntryMetadata {
1528            broker_timestamp: Some(1_700_000_000_000),
1529            index: Some(42),
1530        }));
1531        assert_eq!(msg.broker_publish_time_ms(), Some(1_700_000_000_000));
1532        assert_eq!(msg.broker_index(), Some(42));
1533    }
1534
1535    #[test]
1536    fn is_replicated_tracks_metadata() {
1537        let unset = message_with(pb::MessageMetadata::default());
1538        assert!(!unset.is_replicated());
1539
1540        let stamped = message_with(pb::MessageMetadata {
1541            replicated_from: Some("us-east".to_owned()),
1542            ..pb::MessageMetadata::default()
1543        });
1544        assert!(stamped.is_replicated());
1545        assert_eq!(stamped.replicated_from(), Some("us-east"));
1546    }
1547
1548    #[test]
1549    fn is_batched_and_is_partitioned_track_id_fields() {
1550        let single = message_with(pb::MessageMetadata::default());
1551        assert!(!single.is_batched());
1552        assert!(!single.is_partitioned());
1553
1554        let mut batched = message_with(pb::MessageMetadata::default());
1555        batched.id = magnetar_proto::types::MessageId {
1556            ledger_id: 1,
1557            entry_id: 2,
1558            partition: -1,
1559            batch_index: 3,
1560            batch_size: 10,
1561            #[cfg(feature = "scalable-topics")]
1562            segment_id: None,
1563        };
1564        assert!(batched.is_batched());
1565        assert!(!batched.is_partitioned());
1566
1567        let mut partitioned = message_with(pb::MessageMetadata::default());
1568        partitioned.id = magnetar_proto::types::MessageId {
1569            ledger_id: 1,
1570            entry_id: 2,
1571            partition: 4,
1572            batch_index: -1,
1573            batch_size: 0,
1574            #[cfg(feature = "scalable-topics")]
1575            segment_id: None,
1576        };
1577        assert!(!partitioned.is_batched());
1578        assert!(partitioned.is_partitioned());
1579    }
1580
1581    #[derive(Debug, Default)]
1582    struct AppendPropertyInterceptor {
1583        key: String,
1584        value: String,
1585        applied: std::sync::atomic::AtomicUsize,
1586    }
1587
1588    impl ProducerInterceptor for AppendPropertyInterceptor {
1589        fn eligible(&self, msg: &OutgoingMessage) -> bool {
1590            !msg.payload.is_empty()
1591        }
1592
1593        fn before_send(&self, msg: &mut OutgoingMessage) {
1594            msg.properties.push((self.key.clone(), self.value.clone()));
1595            self.applied
1596                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1597        }
1598    }
1599
1600    #[test]
1601    fn interceptor_eligibility_skips_unmatched_messages() {
1602        let i = AppendPropertyInterceptor {
1603            key: "trace-id".to_owned(),
1604            value: "abc".to_owned(),
1605            applied: std::sync::atomic::AtomicUsize::new(0),
1606        };
1607        let empty = OutgoingMessage::default();
1608        assert!(!i.eligible(&empty));
1609
1610        let with_payload = OutgoingMessage::with_payload("hi");
1611        assert!(i.eligible(&with_payload));
1612    }
1613
1614    #[test]
1615    fn interceptor_before_send_mutates_message() {
1616        let i = AppendPropertyInterceptor {
1617            key: "trace-id".to_owned(),
1618            value: "abc".to_owned(),
1619            applied: std::sync::atomic::AtomicUsize::new(0),
1620        };
1621        let mut msg = OutgoingMessage::with_payload("hi");
1622        assert!(msg.properties.is_empty());
1623        i.before_send(&mut msg);
1624        assert_eq!(msg.properties.len(), 1);
1625        assert_eq!(msg.properties[0].0, "trace-id");
1626        assert_eq!(msg.properties[0].1, "abc");
1627        assert_eq!(i.applied.load(std::sync::atomic::Ordering::SeqCst), 1);
1628    }
1629
1630    #[derive(Debug, Default)]
1631    struct StampSeenInterceptor {
1632        seen: std::sync::atomic::AtomicUsize,
1633    }
1634
1635    impl ConsumerInterceptor for StampSeenInterceptor {
1636        fn before_consume(&self, _msg: &mut IncomingMessage) {
1637            self.seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1638        }
1639    }
1640
1641    #[test]
1642    fn consumer_interceptor_before_consume_runs_on_messages() {
1643        let i = StampSeenInterceptor::default();
1644        let mut msg = message_with(pb::MessageMetadata::default());
1645        i.before_consume(&mut msg);
1646        assert_eq!(i.seen.load(std::sync::atomic::Ordering::SeqCst), 1);
1647    }
1648}