Skip to main content

ruststream_nats/
subscriber.rs

1//! Unified NATS subscriber wrapping either a Core or a `JetStream` pull consumer.
2
3// Without the `testing` feature a Core feed has one variant; see `broker.rs`.
4#![cfg_attr(
5    not(feature = "testing"),
6    allow(clippy::infallible_destructuring_match)
7)]
8
9use async_nats::jetstream::consumer::{PullConsumer, pull::Stream as PullStream};
10use futures::stream::{poll_fn, unfold};
11use futures::{Stream, StreamExt, future::Either};
12use ruststream::{BatchSubscriber, BufferedSubscriber, Subscriber};
13use std::fmt::{Debug, Formatter};
14use std::num::NonZeroUsize;
15#[cfg(feature = "testing")]
16use std::task::Poll;
17use std::{pin::Pin, time::Duration};
18use tracing::warn;
19
20#[cfg(feature = "testing")]
21use crate::in_process::{Consumer, Feed};
22use crate::{
23    error::NatsError,
24    message::{CoreMessage, JetStreamMessage, NatsMessage},
25};
26
27/// How long a partial Core NATS batch waits for further deliveries after its first one.
28///
29/// Core NATS has no wire-level batch, so its batches are assembled on the client and the deadline
30/// that closes a partial one is this crate's own choice (the batch *size* is the registration's).
31/// The window is short enough to add no meaningful latency to a batch that will not fill, and long
32/// enough to gather a burst already in flight.
33const CORE_BATCH_MAX_WAIT: Duration = Duration::from_millis(10);
34
35enum SubscriberKind {
36    // Core deliveries arrive one at a time, so the capability comes from the core adapter rather
37    // than from the wire; everything else reaches through it unchanged.
38    Core(BufferedSubscriber<CoreSubscriber>),
39    // Box the JetStream variant: PullConsumer is large (~1400 bytes) and the enum would otherwise
40    // penalise the Core path with the same footprint.
41    JetStream(Box<JetStreamKind>),
42    /// A consumer of the in-process transport, under the `testing` feature only.
43    #[cfg(feature = "testing")]
44    InProcessJetStream(Box<Consumer>),
45}
46
47struct JetStreamKind {
48    inner: Pin<Box<PullStream>>,
49    consumer: PullConsumer,
50    stream_name: String,
51    pull_expires: Duration,
52}
53
54/// The plain Core NATS subscription: one delivery per stream item, which is what
55/// [`BufferedSubscriber`] batches on the client.
56struct CoreSubscriber {
57    inner: CoreFeed,
58}
59
60/// Where Core deliveries come from: the client's subscription, or, under the `testing` feature,
61/// the in-process transport. Both hand over the client's own message, so batching and the
62/// delivery type are the production ones either way.
63enum CoreFeed {
64    Nats(async_nats::Subscriber),
65    #[cfg(feature = "testing")]
66    InProcess(Feed),
67}
68
69// A production build holds the client's subscription and nothing else.
70#[cfg(not(feature = "testing"))]
71const _: () = assert!(size_of::<CoreFeed>() == size_of::<async_nats::Subscriber>());
72
73impl Subscriber for CoreSubscriber {
74    type Message = NatsMessage;
75    type Error = NatsError;
76
77    fn stream(&mut self) -> impl Stream<Item = Result<Self::Message, Self::Error>> + Send + '_ {
78        // Poll the inner subscription in place rather than moving it into the returned stream,
79        // so `stream` can be called again after the returned stream is dropped (the runtime and
80        // the conformance helpers re-enter it per call).
81        poll_fn(move |cx| match &mut self.inner {
82            CoreFeed::Nats(inner) => Pin::new(inner)
83                .poll_next(cx)
84                .map(|msg| msg.map(core_message)),
85            #[cfg(feature = "testing")]
86            CoreFeed::InProcess(feed) => feed.poll_next(cx).map(|delivery| {
87                delivery.map(|delivery| {
88                    NatsMessage::Core(Box::new(CoreMessage::in_process(
89                        delivery.message,
90                        delivery.release,
91                    )))
92                })
93            }),
94        })
95        .map(Ok)
96    }
97}
98
99/// A NATS subscription.
100///
101/// Backed transparently by either a Core subscription (no ack) or a `JetStream` pull consumer
102/// (full ack/nack/term). Construct via [`ConnectedNatsBroker::subscribe_with`] with
103/// [`CoreSubject`] or [`JetStreamSubject`], or let the runtime resolve either source at startup.
104///
105/// A handler over `&[T]` takes its deliveries in batches, and the mount site sets the largest
106/// batch with `batch(n)`. On `JetStream` one batch is one pull request for at most `n` messages,
107/// and [`pull_expires`](crate::JetStreamSubject::pull_expires) closes a short one early. Core NATS
108/// has no batch on the wire, so the client assembles one and closes a partial batch 10 ms after
109/// its first delivery.
110///
111/// # Examples
112///
113/// ```
114/// # mod demo {
115/// use ruststream_nats::prelude::*;
116/// use serde::Deserialize;
117///
118/// #[derive(Deserialize)]
119/// struct Order {
120///     id: u64,
121/// }
122///
123/// #[subscriber(JetStreamSubject::new("orders.*", "ORDERS").durable("orders-worker"))]
124/// async fn handle(order: &Order) -> HandlerOutcome {
125///     tracing::info!(order.id, "handled");
126///     HandlerOutcome::ack()
127/// }
128///
129/// #[subscriber(JetStreamSubject::new("orders.*", "ORDERS").durable("orders-reconciler"))]
130/// async fn reconcile(orders: &[Order]) -> HandlerOutcome {
131///     tracing::info!(count = orders.len(), "reconciled");
132///     HandlerOutcome::ack()
133/// }
134///
135/// #[ruststream::app]
136/// fn app() -> impl App {
137///     RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
138///         NatsBroker::new("nats://localhost:4222"),
139///         |b| {
140///             b.include(handle);
141///             // One pull request fetches at most six messages.
142///             b.include(reconcile.batch(nonzero!(6)));
143///         },
144///     )
145/// }
146/// # }
147/// # fn main() {}
148/// ```
149///
150/// [`ConnectedNatsBroker::subscribe_with`]: crate::ConnectedNatsBroker::subscribe_with
151/// [`CoreSubject`]: crate::CoreSubject
152/// [`JetStreamSubject`]: crate::JetStreamSubject
153pub struct NatsSubscriber {
154    subject: String,
155    kind: SubscriberKind,
156}
157
158impl Debug for NatsSubscriber {
159    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
160        let mut s = f.debug_struct("NatsSubscriber");
161        s.field("subject", &self.subject);
162        match &self.kind {
163            SubscriberKind::Core(_) => {
164                s.field("kind", &"core");
165            }
166            SubscriberKind::JetStream(js) => {
167                s.field("kind", &"jetstream")
168                    .field("stream", &js.stream_name);
169            }
170            #[cfg(feature = "testing")]
171            SubscriberKind::InProcessJetStream(consumer) => {
172                s.field("kind", &"jetstream")
173                    .field("stream", &consumer.stream());
174            }
175        }
176        s.finish_non_exhaustive()
177    }
178}
179
180impl NatsSubscriber {
181    pub(crate) fn from_core(subject: String, inner: async_nats::Subscriber) -> Self {
182        Self {
183            subject,
184            kind: SubscriberKind::Core(
185                BufferedSubscriber::new(CoreSubscriber {
186                    inner: CoreFeed::Nats(inner),
187                })
188                .max_wait(CORE_BATCH_MAX_WAIT),
189            ),
190        }
191    }
192
193    /// A Core subscription of the in-process transport, batched on the client exactly as a live
194    /// one is.
195    #[cfg(feature = "testing")]
196    pub(crate) fn in_process_core(subject: String, feed: Feed) -> Self {
197        Self {
198            subject,
199            kind: SubscriberKind::Core(
200                BufferedSubscriber::new(CoreSubscriber {
201                    inner: CoreFeed::InProcess(feed),
202                })
203                .max_wait(CORE_BATCH_MAX_WAIT),
204            ),
205        }
206    }
207
208    /// A subscription reading a consumer of the in-process transport.
209    #[cfg(feature = "testing")]
210    pub(crate) fn in_process_jetstream(subject: String, consumer: Consumer) -> Self {
211        Self {
212            subject,
213            kind: SubscriberKind::InProcessJetStream(Box::new(consumer)),
214        }
215    }
216
217    pub(crate) fn from_jetstream(
218        subject: String,
219        stream_name: String,
220        inner: PullStream,
221        consumer: PullConsumer,
222        pull_expires: Duration,
223    ) -> Self {
224        Self {
225            subject,
226            kind: SubscriberKind::JetStream(Box::new(JetStreamKind {
227                inner: Box::pin(inner),
228                consumer,
229                stream_name,
230                pull_expires,
231            })),
232        }
233    }
234}
235
236fn core_message(msg: async_nats::Message) -> NatsMessage {
237    NatsMessage::Core(Box::new(CoreMessage::new(msg)))
238}
239
240fn jetstream_message(msg: async_nats::jetstream::Message) -> NatsMessage {
241    NatsMessage::JetStream(Box::new(JetStreamMessage::new(msg)))
242}
243
244impl Subscriber for NatsSubscriber {
245    type Message = NatsMessage;
246    type Error = NatsError;
247
248    fn stream(&mut self) -> impl Stream<Item = Result<Self::Message, Self::Error>> + Send + '_ {
249        // Poll the inner subscription in place rather than moving it into the returned stream,
250        // so `stream` can be called again after the returned stream is dropped (the runtime and
251        // the conformance helpers re-enter it per call).
252        match &mut self.kind {
253            // Batching does not change the per-message path: the adapter forwards it untouched.
254            SubscriberKind::Core(core) => Either::Left(core.stream()),
255            SubscriberKind::JetStream(js) => {
256                let stream =
257                    poll_fn(move |cx| js.inner.as_mut().poll_next(cx)).map(|item| match item {
258                        Ok(msg) => Ok(jetstream_message(msg)),
259                        Err(err) => {
260                            warn!(target: "ruststream::nats", error = %err, "jetstream fetch error");
261                            Err(NatsError::JetStream(Box::new(err)))
262                        }
263                    });
264                // The in-process arm is a second stream type, so the production one is nested
265                // beside it only when the feature brings it.
266                #[cfg(feature = "testing")]
267                let stream = Either::Left(stream);
268                Either::Right(stream)
269            }
270            #[cfg(feature = "testing")]
271            SubscriberKind::InProcessJetStream(consumer) => Either::Right(Either::Right(
272                poll_fn(move |cx| consumer.poll_next(cx)).map(|delivery| {
273                    Ok(NatsMessage::JetStream(Box::new(
274                        JetStreamMessage::in_process(delivery),
275                    )))
276                }),
277            )),
278        }
279    }
280}
281
282impl BatchSubscriber for NatsSubscriber {
283    type Batch = Vec<NatsMessage>;
284
285    /// Returns a stream of batches of at most `size` messages.
286    ///
287    /// `size` is the registration's batch size, and each transport spends it in its own currency.
288    /// `JetStream` batches on the wire: one stream item is one pull `fetch` of up to `size`
289    /// messages, waiting at most [`pull_expires`](crate::JetStreamSubject::pull_expires) before
290    /// delivering a partial batch (an empty fetch is retried, so the stream never yields empty
291    /// batches). Core NATS has no wire-level batching, so its batches are assembled on the client
292    /// by the framework's [`BufferedSubscriber`]: a batch closes at `size` deliveries or 10 ms
293    /// after its first one, whichever comes first.
294    ///
295    /// Drive a subscriber through either [`Subscriber::stream`] or `batches`, not both at once:
296    /// on `JetStream` each issues its own pull requests, so deliveries would be split between
297    /// them.
298    ///
299    /// # Cancel safety
300    ///
301    /// Dropping the returned stream between items is allowed. On `JetStream`, dropping it
302    /// mid-fetch can leave already-fetched, undelivered messages to be redelivered after the
303    /// consumer's `ack_wait`.
304    fn batches(
305        &mut self,
306        size: NonZeroUsize,
307    ) -> impl Stream<Item = Result<Self::Batch, Self::Error>> + Send + '_ {
308        match &mut self.kind {
309            SubscriberKind::Core(core) => Either::Left(core.batches(size)),
310            // A fetch hands over what the consumer has, up to `size`, and waits only for the
311            // first message: so does this.
312            #[cfg(feature = "testing")]
313            SubscriberKind::InProcessJetStream(consumer) => {
314                let limit = size.get();
315                Either::Right(Either::Right(poll_fn(move |cx| {
316                    let first = match consumer.poll_next(cx) {
317                        Poll::Pending => return Poll::Pending,
318                        Poll::Ready(None) => return Poll::Ready(None),
319                        Poll::Ready(Some(delivery)) => delivery,
320                    };
321                    let mut batch = vec![NatsMessage::JetStream(Box::new(
322                        JetStreamMessage::in_process(first),
323                    ))];
324                    while batch.len() < limit {
325                        match consumer.poll_next(cx) {
326                            Poll::Ready(Some(delivery)) => batch.push(NatsMessage::JetStream(
327                                Box::new(JetStreamMessage::in_process(delivery)),
328                            )),
329                            Poll::Ready(None) | Poll::Pending => break,
330                        }
331                    }
332                    Poll::Ready(Some(Ok(batch)))
333                })))
334            }
335            SubscriberKind::JetStream(js) => {
336                let max = size.get();
337                let expires = js.pull_expires;
338                let batches = unfold(&mut js.consumer, move |consumer| async move {
339                    loop {
340                        let fetch = consumer
341                            .fetch()
342                            .max_messages(max)
343                            .expires(expires)
344                            .messages()
345                            .await;
346                        let mut messages = match fetch {
347                            Ok(messages) => messages,
348                            // BatchError is a concrete sized type; wrap it.
349                            Err(err) => {
350                                return Some((Err(NatsError::JetStream(Box::new(err))), consumer));
351                            }
352                        };
353                        let mut batch = Vec::new();
354                        while let Some(item) = messages.next().await {
355                            match item {
356                                Ok(msg) => batch.push(jetstream_message(msg)),
357                                // crate::Error is already Box<dyn StdError + ...>; use directly.
358                                Err(err) => {
359                                    if batch.is_empty() {
360                                        return Some((Err(NatsError::JetStream(err)), consumer));
361                                    }
362                                    warn!(
363                                        target: "ruststream::nats",
364                                        error = %err,
365                                        "jetstream fetch error mid-batch; delivering the partial batch",
366                                    );
367                                    break;
368                                }
369                            }
370                        }
371                        if !batch.is_empty() {
372                            return Some((Ok(batch), consumer));
373                        }
374                        // An empty fetch only means `expires` elapsed with nothing pending.
375                    }
376                });
377                #[cfg(feature = "testing")]
378                let batches = Either::Left(batches);
379                Either::Right(batches)
380            }
381        }
382    }
383}