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}