Skip to main content

ruststream_pulsar/
subscriber.rs

1//! [`PulsarSubscriber`]: a stream of deliveries backed by a driver task.
2//!
3//! The client's acknowledgement API needs `&mut Consumer` while an ack token must be
4//! `Send + 'static`, so the crate owns a driver task per subscription: it polls the consumer
5//! stream, forwards deliveries into a bounded channel, and applies settlement commands shipped
6//! back from message handles.
7//!
8//! The client hands over one delivery at a time - it has no consumer-side batch receive, only a
9//! flow-control window - so the batches a batch handler asks for are assembled on the client, by
10//! the framework's own [`BufferedSubscriber`]. Nothing at the mount site says so: a service
11//! names a batch size and gets batches.
12
13use std::num::NonZeroUsize;
14use std::sync::Arc;
15use std::sync::atomic::{AtomicU64, Ordering};
16use std::task::{Context, Poll};
17use std::time::Duration;
18
19use futures::{Stream, StreamExt};
20
21use pulsar::consumer::{Consumer, DeadLetterPolicy};
22use pulsar::proto::MessageIdData;
23use pulsar::{Pulsar, SubType, TokioExecutor};
24use ruststream::{AckError, BatchSubscriber, BufferedSubscriber, Seekable, Subscriber};
25use tokio::sync::{mpsc, oneshot};
26use tokio::time::sleep;
27use tracing::warn;
28
29use crate::broker::Core;
30use crate::error::{PulsarError, box_err};
31#[cfg(feature = "testing")]
32use crate::in_process::{LogSeeker, Queued};
33use crate::message::{
34    DriverCmd, NackAfterCmd, PulsarMessage, PulsarPosition, SeekCmd, SettleKind, SettleSender,
35    send_settle,
36};
37use crate::pattern::TopicPattern;
38use crate::subscription::{PulsarSubscription, SubscriptionType, Topics};
39
40/// How many undelivered messages may sit between the driver and the consumer. Real prefetch is
41/// the client's own flow control (`batch_size` permits); this only decouples the two loops.
42const CHANNEL_CAPACITY: usize = 16;
43
44/// A subscription to one or more Pulsar topics; yields [`PulsarMessage`]s, one at a time or in
45/// batches.
46///
47/// Dropping the subscriber stops the driver task, which closes the client consumer.
48pub struct PulsarSubscriber {
49    topic: String,
50    /// The wire deliveries plus client-side batching. Every capability of the subscription
51    /// reaches through the wrapper: buffering does not move the subscription, so the seeker
52    /// underneath is the subscription's own.
53    inner: BufferedSubscriber<Deliveries>,
54}
55
56/// The wire form of a subscription: one delivery at a time, off the driver task's channel, or,
57/// under the `testing` feature, off the in-process transport's queue.
58///
59/// This is everything the client offers, and [`PulsarSubscriber`] is this plus the batches.
60/// Without the feature there is one variant, so the type is the consumer's channel itself.
61#[derive(Debug)]
62enum Deliveries {
63    Consumer(ConsumerDeliveries),
64    #[cfg(feature = "testing")]
65    InProcess(Queued),
66}
67
68// The zero-cost promise of the in-process mode, held by the compiler: a build without it gives
69// the wire form exactly the size of the consumer channel it wraps.
70#[cfg(not(feature = "testing"))]
71const _: () = assert!(size_of::<Deliveries>() == size_of::<ConsumerDeliveries>());
72
73/// A live consumer's deliveries, off its driver task's channel.
74#[derive(Debug)]
75struct ConsumerDeliveries {
76    rx: mpsc::Receiver<(u64, Result<PulsarMessage, PulsarError>)>,
77    cmd: SettleSender,
78    /// The delivery generation: a seek bumps it, and items queued under an older generation
79    /// are discarded on the way out - a reposition must not deliver stale buffered messages.
80    epoch: Arc<AtomicU64>,
81}
82
83impl ConsumerDeliveries {
84    /// The next delivery of the current generation. Items queued under an older generation
85    /// (before a seek) are discarded here.
86    fn poll_next(
87        &mut self,
88        cx: &mut Context<'_>,
89    ) -> Poll<Option<Result<PulsarMessage, PulsarError>>> {
90        loop {
91            match self.rx.poll_recv(cx) {
92                Poll::Ready(Some((epoch, item))) => {
93                    if epoch == self.epoch.load(Ordering::Acquire) {
94                        return Poll::Ready(Some(item));
95                    }
96                }
97                Poll::Ready(None) => return Poll::Ready(None),
98                Poll::Pending => return Poll::Pending,
99            }
100        }
101    }
102}
103
104impl std::fmt::Debug for PulsarSubscriber {
105    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106        f.debug_struct("PulsarSubscriber")
107            .field("topic", &self.topic)
108            .finish_non_exhaustive()
109    }
110}
111
112impl PulsarSubscriber {
113    /// The topic list or pattern this subscription consumes from.
114    #[must_use]
115    pub fn topic(&self) -> &str {
116        &self.topic
117    }
118
119    /// Opens the client consumer and starts the subscription's driver on the runtime the broker
120    /// connected on.
121    pub(crate) async fn open(
122        client: &Pulsar<TokioExecutor>,
123        descriptor: PulsarSubscription,
124        core: &Arc<Core>,
125    ) -> Result<Self, PulsarError> {
126        let display = descriptor.display_topic();
127        let batch_wait = descriptor.batch_wait;
128        let mut builder = client
129            .consumer()
130            .with_subscription(&descriptor.subscription)
131            .with_subscription_type(match descriptor.sub_type {
132                SubscriptionType::Exclusive => SubType::Exclusive,
133                SubscriptionType::Shared => SubType::Shared,
134                SubscriptionType::Failover => SubType::Failover,
135                SubscriptionType::KeyShared => SubType::KeyShared,
136            });
137        match &descriptor.topics {
138            Topics::List(topics) => {
139                builder = builder.with_topics(topics);
140            }
141            Topics::Pattern(pattern) => {
142                let pattern = TopicPattern::parse(pattern)?;
143                builder = builder
144                    .with_lookup_namespace(pattern.lookup_namespace())
145                    .with_topic_regex(pattern.regex().clone());
146            }
147        }
148        if let Some(dead_letter) = descriptor.dead_letter_policy()? {
149            // The client delivers while the redelivery count is below the limit, so the limit is
150            // the number of deliveries the registration asked for.
151            builder = builder.with_dead_letter_policy(DeadLetterPolicy {
152                max_redeliver_count: usize::try_from(dead_letter.max_deliveries)
153                    .unwrap_or(usize::MAX),
154                dead_letter_topic: dead_letter.topic,
155            });
156        }
157        if descriptor.ack_timeout.is_some() {
158            builder = builder.with_unacked_message_resend_delay(descriptor.ack_timeout);
159        }
160
161        let consumer: Consumer<Vec<u8>, TokioExecutor> =
162            builder.build().await.map_err(|e| PulsarError::Subscribe {
163                topic: display.clone(),
164                source: box_err(e),
165            })?;
166
167        let (out_tx, out_rx) = mpsc::channel(CHANNEL_CAPACITY);
168        let (settle_tx, settle_rx) = mpsc::unbounded_channel();
169        let epoch = Arc::new(AtomicU64::new(0));
170        core.runtime.spawn(drive(
171            consumer,
172            client.clone(),
173            Arc::clone(core),
174            out_tx,
175            settle_tx.clone(),
176            settle_rx,
177            display.clone(),
178            Arc::clone(&epoch),
179            descriptor.ack_timeout,
180        ));
181
182        Ok(Self::batching(
183            display,
184            Deliveries::Consumer(ConsumerDeliveries {
185                rx: out_rx,
186                cmd: settle_tx,
187                epoch,
188            }),
189            batch_wait,
190        ))
191    }
192
193    /// A subscription on the in-process transport, batched the way a live one is.
194    #[cfg(feature = "testing")]
195    pub(crate) fn in_process(topic: String, queued: Queued, batch_wait: Duration) -> Self {
196        Self::batching(topic, Deliveries::InProcess(queued), batch_wait)
197    }
198
199    /// Wraps the wire deliveries in the framework's client-side buffer.
200    ///
201    /// The batch size is not this crate's to choose - it arrives per subscription, as the
202    /// argument of [`BatchSubscriber::batches`]. The deadline that closes a partial batch is,
203    /// and the descriptor's `batch_wait` is where a service names it.
204    fn batching(topic: String, wire: Deliveries, batch_wait: Duration) -> Self {
205        Self {
206            topic,
207            inner: BufferedSubscriber::new(wire).max_wait(batch_wait),
208        }
209    }
210}
211
212/// Repositions a subscription while its stream runs; minted by
213/// [`Seekable::seeker`](ruststream::Seekable::seeker).
214///
215/// A seek covers every topic (and partition) of the subscription's consumer, and the broker
216/// redelivers from the new position; per-message acknowledgement state needs no reset.
217///
218/// One type serves both transports, so a service that repositions itself reads the same
219/// [`SeekHandle`](crate::SeekHandle) key whether it runs against a server or in process under
220/// the test harness.
221#[derive(Clone)]
222pub struct PulsarSeeker {
223    inner: SeekerKind,
224}
225
226/// Which subscription the handle moves. The in-process arm exists only with the `testing`
227/// feature, and the field is private, so a service sees one type either way.
228#[derive(Clone)]
229enum SeekerKind {
230    /// A live consumer, repositioned through its subscription's driver task.
231    Consumer(SettleSender),
232    /// An in-process subscription, repositioned over the transport's retained log.
233    #[cfg(feature = "testing")]
234    InProcess(LogSeeker),
235}
236
237impl std::fmt::Debug for PulsarSeeker {
238    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239        f.debug_struct("PulsarSeeker").finish_non_exhaustive()
240    }
241}
242
243impl PulsarSeeker {
244    /// Mints a handle over the subscription's driver channel. Cloning the sender is a
245    /// reference-count bump, so a per-delivery context costs no allocation.
246    pub(crate) fn new(cmd: SettleSender) -> Self {
247        Self {
248            inner: SeekerKind::Consumer(cmd),
249        }
250    }
251
252    /// Mints a handle over the in-process transport's retained log.
253    #[cfg(feature = "testing")]
254    pub(crate) const fn in_process(seeker: LogSeeker) -> Self {
255        Self {
256            inner: SeekerKind::InProcess(seeker),
257        }
258    }
259}
260
261impl ruststream::Seeker for PulsarSeeker {
262    type Position = PulsarPosition;
263    type Error = PulsarError;
264
265    async fn seek(&self, to: PulsarPosition) -> Result<(), PulsarError> {
266        match &self.inner {
267            SeekerKind::Consumer(cmd) => {
268                let (done, wait) = oneshot::channel();
269                // A driver that is gone means the subscription is closed, which the in-process
270                // arm reports the same way.
271                cmd.send(DriverCmd::Seek(SeekCmd { position: to, done }))
272                    .map_err(|_| PulsarError::NotConnected)?;
273                wait.await.map_err(|_| PulsarError::NotConnected)?
274            }
275            #[cfg(feature = "testing")]
276            SeekerKind::InProcess(seeker) => seeker.seek(&to),
277        }
278    }
279}
280
281impl Seekable for Deliveries {
282    type Seeker = PulsarSeeker;
283
284    fn seeker(&self) -> PulsarSeeker {
285        match self {
286            Self::Consumer(consumer) => PulsarSeeker::new(consumer.cmd.clone()),
287            #[cfg(feature = "testing")]
288            Self::InProcess(queued) => queued.seeker(),
289        }
290    }
291}
292
293impl Subscriber for Deliveries {
294    type Message = PulsarMessage;
295    type Error = PulsarError;
296
297    fn stream(&mut self) -> impl Stream<Item = Result<PulsarMessage, PulsarError>> + Send + '_ {
298        // Poll in place rather than wrapping the source in an owning stream, so `stream` can be
299        // called again after the returned stream is dropped (the runtime and the conformance
300        // helpers re-enter it per call).
301        futures::stream::poll_fn(move |cx| match self {
302            Self::Consumer(consumer) => consumer.poll_next(cx),
303            #[cfg(feature = "testing")]
304            Self::InProcess(queued) => queued.poll_next(cx),
305        })
306    }
307}
308
309/// Buffering does not move the subscription, so the handle is the wire subscriber's own: a batch
310/// subscription still opens at a chosen position and still repositions from a handler.
311impl Seekable for PulsarSubscriber {
312    type Seeker = PulsarSeeker;
313
314    fn seeker(&self) -> PulsarSeeker {
315        self.inner.seeker()
316    }
317}
318
319impl Subscriber for PulsarSubscriber {
320    type Message = PulsarMessage;
321    type Error = PulsarError;
322
323    fn stream(&mut self) -> impl Stream<Item = Result<PulsarMessage, PulsarError>> + Send + '_ {
324        self.inner.stream()
325    }
326}
327
328/// Batches assembled on the client: the client's consumer yields one delivery at a time, and its
329/// `batch_size` is a flow-control window rather than a receive size, so the buffer in front of the
330/// consumer cuts the batches to the size the registration names.
331///
332/// A batch never carries more than the size the registration named - the buffer closes it there -
333/// and it carries fewer whenever the deadline elapsed first.
334impl BatchSubscriber for PulsarSubscriber {
335    type Batch = Vec<PulsarMessage>;
336
337    fn batches(
338        &mut self,
339        size: NonZeroUsize,
340    ) -> impl Stream<Item = Result<Self::Batch, PulsarError>> + Send + '_ {
341        self.inner.batches(size)
342    }
343}
344
345#[allow(clippy::too_many_arguments)]
346async fn drive(
347    mut consumer: Consumer<Vec<u8>, TokioExecutor>,
348    client: Pulsar<TokioExecutor>,
349    // Read only by a seek, which must not reposition a subscription whose broker shut down.
350    core: Arc<Core>,
351    out: mpsc::Sender<(u64, Result<PulsarMessage, PulsarError>)>,
352    settle_tx: SettleSender,
353    mut settle_rx: mpsc::UnboundedReceiver<DriverCmd>,
354    topic: String,
355    epoch: Arc<AtomicU64>,
356    // Carried onto every delivery: a delayed retry has to know when the consumer would
357    // redeliver the message on its own.
358    ack_timeout: Option<Duration>,
359) {
360    // Deliveries carry the generation captured when they were pulled off the consumer:
361    // stamping at send time would let a seek's bump - which lands before the seek command is
362    // processed - leak onto a delivery positioned before the seek.
363    let mut pending: Option<(u64, PulsarMessage)> = None;
364    loop {
365        if let Some((stamp, msg)) = pending.take() {
366            // A delivery is waiting for channel capacity; keep settling while it waits so an
367            // unpolled stream can never wedge in-flight acks.
368            tokio::select! {
369                biased;
370                cmd = settle_rx.recv() => {
371                    match cmd {
372                        Some(DriverCmd::Seek(seek)) => {
373                            // The reposition drops the delivery waiting for capacity too.
374                            epoch.fetch_add(1, Ordering::Release);
375                            apply_seek(&mut consumer, &client, &core, seek).await;
376                        }
377                        Some(cmd) => {
378                            apply(&mut consumer, &client, &core, cmd).await;
379                            pending = Some((stamp, msg));
380                        }
381                        None => pending = Some((stamp, msg)),
382                    }
383                }
384                permit = out.reserve() => match permit {
385                    Ok(permit) => permit.send((stamp, Ok(msg))),
386                    Err(_) => break, // subscriber dropped
387                },
388            }
389        } else {
390            let current = epoch.load(Ordering::Acquire);
391            tokio::select! {
392                biased;
393                cmd = settle_rx.recv() => {
394                    match cmd {
395                        Some(DriverCmd::Seek(seek)) => {
396                            epoch.fetch_add(1, Ordering::Release);
397                            apply_seek(&mut consumer, &client, &core, seek).await;
398                        }
399                        Some(cmd) => apply(&mut consumer, &client, &core, cmd).await,
400                        None => {}
401                    }
402                }
403                () = out.closed() => break, // subscriber dropped
404                next = consumer.next() => match next {
405                    Some(Ok(message)) => {
406                        pending = Some((
407                            current,
408                            PulsarMessage::new(&message, settle_tx.clone(), ack_timeout),
409                        ));
410                    }
411                    Some(Err(err)) => {
412                        // Single-topic consumers surface transient errors here while the
413                        // client reconnects underneath; forward and keep going.
414                        if out
415                            .send((
416                                current,
417                                Err(PulsarError::Receive {
418                                    topic: topic.clone(),
419                                    source: box_err(err),
420                                }),
421                            ))
422                            .await
423                            .is_err()
424                        {
425                            break;
426                        }
427                    }
428                    None => {
429                        // The engine gave up (retries exhausted): the stream is dead for good.
430                        let _ = out
431                            .send((
432                                current,
433                                Err(PulsarError::Receive {
434                                    topic: topic.clone(),
435                                    source: Box::from("the consumer stream ended"),
436                                }),
437                            ))
438                            .await;
439                        break;
440                    }
441                },
442            }
443        }
444    }
445
446    // Outstanding message handles may still settle; serve them until every clone of the
447    // settle sender is gone.
448    drop(settle_tx);
449    while let Some(cmd) = settle_rx.recv().await {
450        apply(&mut consumer, &client, &core, cmd).await;
451    }
452    if let Err(err) = Box::pin(consumer.close()).await {
453        tracing::debug!(topic = %topic, error = %err, "pulsar consumer close failed");
454    }
455}
456
457async fn apply(
458    consumer: &mut Consumer<Vec<u8>, TokioExecutor>,
459    client: &Pulsar<TokioExecutor>,
460    core: &Core,
461    cmd: DriverCmd,
462) {
463    match cmd {
464        DriverCmd::NackAfter(cmd) => wait_then_nack(cmd),
465        DriverCmd::Settle(cmd) => {
466            let result = match cmd.kind {
467                SettleKind::Ack => consumer.ack_with_id(&cmd.topic, cmd.id).await,
468                SettleKind::Nack => consumer.nack_with_id(&cmd.topic, cmd.id).await,
469            };
470            let _ = cmd
471                .done
472                .send(result.map_err(|e| AckError::Broker(box_err(e))));
473        }
474        DriverCmd::Seek(seek) => apply_seek(consumer, client, core, seek).await,
475    }
476}
477
478/// Waits out a delayed retry, then negatively acknowledges the delivery through its own channel.
479///
480/// The driver runs on the runtime the broker connected on, so the wait does too, whichever thread
481/// settled the delivery.
482fn wait_then_nack(
483    NackAfterCmd {
484        topic,
485        id,
486        delay,
487        back,
488    }: NackAfterCmd,
489) {
490    tokio::spawn(async move {
491        sleep(delay).await;
492        let named = topic.clone();
493        if let Err(err) = send_settle(back, topic, id, SettleKind::Nack).await {
494            warn!(
495                target: "ruststream_pulsar::subscriber",
496                topic = %named,
497                delay = ?delay,
498                error = %err,
499                "a delayed retry could not be negatively acknowledged; the broker redelivers it \
500                 once the consumer's ack timeout elapses",
501            );
502        }
503    });
504}
505
506/// The ids the protocol reserves for the two ends of a log, spelled `-1` for the beginning and
507/// `i64::MAX` for the tip (the Java and Go clients agree on both). `ledger_id` and `entry_id`
508/// are unsigned on the wire, so the beginning travels as the all-ones pattern.
509const EARLIEST_MARK: u64 = u64::MAX;
510const LATEST_MARK: u64 = i64::MAX.unsigned_abs();
511
512fn end_of_log(mark: u64) -> MessageIdData {
513    MessageIdData {
514        ledger_id: mark,
515        entry_id: mark,
516        // The sentinel addresses the topic as a whole, which is what -1 means here.
517        partition: Some(-1),
518        ..MessageIdData::default()
519    }
520}
521
522/// Repositions the consumer, or refuses once the broker shut down: the consumer outlives the
523/// shutdown while its subscriber is held, and a reposition through a handle that outlived the
524/// connection must report the dead connection rather than succeed.
525async fn apply_seek(
526    consumer: &mut Consumer<Vec<u8>, TokioExecutor>,
527    client: &Pulsar<TokioExecutor>,
528    core: &Core,
529    SeekCmd { position, done }: SeekCmd,
530) {
531    if let Err(closed) = core.ensure_open() {
532        let _ = done.send(Err(closed));
533        return;
534    }
535    let (message_id, timestamp) = match position {
536        PulsarPosition::Earliest => (Some(end_of_log(EARLIEST_MARK)), None),
537        PulsarPosition::Latest => (Some(end_of_log(LATEST_MARK)), None),
538        PulsarPosition::MessageId(id) => (Some(id), None),
539        PulsarPosition::Timestamp(millis) => (None, Some(millis)),
540    };
541    // A multi-topic consumer (a topic list or a pattern) seeks per topic and rejects an
542    // unnamed set, so the subscription's own topics are always spelled out; the single-topic
543    // consumer ignores the list.
544    let topics = consumer.topics();
545    let result =
546        Box::pin(consumer.seek(Some(topics.clone()), message_id, timestamp, client.clone()))
547            .await
548            .map_err(|e| PulsarError::Receive {
549                topic: topics.join(","),
550                source: box_err(e),
551            });
552    let _ = done.send(result);
553}
554
555#[cfg(test)]
556mod tests {
557    use ruststream::Seeker;
558
559    use super::*;
560
561    /// A seek through a handle whose subscription is gone answers as a handle used after
562    /// shutdown does, on either transport, rather than as a receive failure on no topic.
563    #[tokio::test]
564    async fn a_seek_after_the_driver_is_gone_reports_not_connected() {
565        let (cmd, driver) = mpsc::unbounded_channel();
566        drop(driver);
567
568        let refused = PulsarSeeker::new(cmd)
569            .seek(PulsarPosition::earliest())
570            .await
571            .expect_err("no driver is left to seek");
572
573        assert!(matches!(refused, PulsarError::NotConnected), "{refused:?}");
574    }
575
576    /// The driver may go between taking the command and answering it; the handle reports that
577    /// the same way.
578    #[tokio::test]
579    async fn a_seek_the_driver_drops_unanswered_reports_not_connected() {
580        let (cmd, mut driver) = mpsc::unbounded_channel();
581        let seeker = PulsarSeeker::new(cmd);
582
583        let (refused, ()) = tokio::join!(seeker.seek(PulsarPosition::earliest()), async {
584            drop(driver.recv().await);
585        });
586
587        let refused = refused.expect_err("the driver went without answering");
588        assert!(matches!(refused, PulsarError::NotConnected), "{refused:?}");
589    }
590}