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}