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/// [`ConnectedNatsBroker::subscribe_with`]: crate::ConnectedNatsBroker::subscribe_with
106/// [`CoreSubject`]: crate::CoreSubject
107/// [`JetStreamSubject`]: crate::JetStreamSubject
108pub struct NatsSubscriber {
109 subject: String,
110 kind: SubscriberKind,
111}
112
113impl Debug for NatsSubscriber {
114 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
115 let mut s = f.debug_struct("NatsSubscriber");
116 s.field("subject", &self.subject);
117 match &self.kind {
118 SubscriberKind::Core(_) => {
119 s.field("kind", &"core");
120 }
121 SubscriberKind::JetStream(js) => {
122 s.field("kind", &"jetstream")
123 .field("stream", &js.stream_name);
124 }
125 #[cfg(feature = "testing")]
126 SubscriberKind::InProcessJetStream(consumer) => {
127 s.field("kind", &"jetstream")
128 .field("stream", &consumer.stream());
129 }
130 }
131 s.finish_non_exhaustive()
132 }
133}
134
135impl NatsSubscriber {
136 pub(crate) fn from_core(subject: String, inner: async_nats::Subscriber) -> Self {
137 Self {
138 subject,
139 kind: SubscriberKind::Core(
140 BufferedSubscriber::new(CoreSubscriber {
141 inner: CoreFeed::Nats(inner),
142 })
143 .max_wait(CORE_BATCH_MAX_WAIT),
144 ),
145 }
146 }
147
148 /// A Core subscription of the in-process transport, batched on the client exactly as a live
149 /// one is.
150 #[cfg(feature = "testing")]
151 pub(crate) fn in_process_core(subject: String, feed: Feed) -> Self {
152 Self {
153 subject,
154 kind: SubscriberKind::Core(
155 BufferedSubscriber::new(CoreSubscriber {
156 inner: CoreFeed::InProcess(feed),
157 })
158 .max_wait(CORE_BATCH_MAX_WAIT),
159 ),
160 }
161 }
162
163 /// A subscription reading a consumer of the in-process transport.
164 #[cfg(feature = "testing")]
165 pub(crate) fn in_process_jetstream(subject: String, consumer: Consumer) -> Self {
166 Self {
167 subject,
168 kind: SubscriberKind::InProcessJetStream(Box::new(consumer)),
169 }
170 }
171
172 pub(crate) fn from_jetstream(
173 subject: String,
174 stream_name: String,
175 inner: PullStream,
176 consumer: PullConsumer,
177 pull_expires: Duration,
178 ) -> Self {
179 Self {
180 subject,
181 kind: SubscriberKind::JetStream(Box::new(JetStreamKind {
182 inner: Box::pin(inner),
183 consumer,
184 stream_name,
185 pull_expires,
186 })),
187 }
188 }
189}
190
191fn core_message(msg: async_nats::Message) -> NatsMessage {
192 NatsMessage::Core(Box::new(CoreMessage::new(msg)))
193}
194
195fn jetstream_message(msg: async_nats::jetstream::Message) -> NatsMessage {
196 NatsMessage::JetStream(Box::new(JetStreamMessage::new(msg)))
197}
198
199impl Subscriber for NatsSubscriber {
200 type Message = NatsMessage;
201 type Error = NatsError;
202
203 fn stream(&mut self) -> impl Stream<Item = Result<Self::Message, Self::Error>> + Send + '_ {
204 // Poll the inner subscription in place rather than moving it into the returned stream,
205 // so `stream` can be called again after the returned stream is dropped (the runtime and
206 // the conformance helpers re-enter it per call).
207 match &mut self.kind {
208 // Batching does not change the per-message path: the adapter forwards it untouched.
209 SubscriberKind::Core(core) => Either::Left(core.stream()),
210 SubscriberKind::JetStream(js) => {
211 let stream =
212 poll_fn(move |cx| js.inner.as_mut().poll_next(cx)).map(|item| match item {
213 Ok(msg) => Ok(jetstream_message(msg)),
214 Err(err) => {
215 warn!(target: "ruststream::nats", error = %err, "jetstream fetch error");
216 Err(NatsError::JetStream(Box::new(err)))
217 }
218 });
219 // The in-process arm is a second stream type, so the production one is nested
220 // beside it only when the feature brings it.
221 #[cfg(feature = "testing")]
222 let stream = Either::Left(stream);
223 Either::Right(stream)
224 }
225 #[cfg(feature = "testing")]
226 SubscriberKind::InProcessJetStream(consumer) => Either::Right(Either::Right(
227 poll_fn(move |cx| consumer.poll_next(cx)).map(|delivery| {
228 Ok(NatsMessage::JetStream(Box::new(
229 JetStreamMessage::in_process(delivery),
230 )))
231 }),
232 )),
233 }
234 }
235}
236
237impl BatchSubscriber for NatsSubscriber {
238 type Batch = Vec<NatsMessage>;
239
240 /// Returns a stream of batches of at most `size` messages.
241 ///
242 /// `size` is the registration's batch size, and each transport spends it in its own currency.
243 /// `JetStream` batches on the wire: one stream item is one pull `fetch` of up to `size`
244 /// messages, waiting at most [`pull_expires`](crate::JetStreamSubject::pull_expires) before
245 /// delivering a partial batch (an empty fetch is retried, so the stream never yields empty
246 /// batches). Core NATS has no wire-level batching, so its batches are assembled on the client
247 /// by the framework's [`BufferedSubscriber`]: a batch closes at `size` deliveries or 10 ms
248 /// after its first one, whichever comes first.
249 ///
250 /// Drive a subscriber through either [`Subscriber::stream`] or `batches`, not both at once:
251 /// on `JetStream` each issues its own pull requests, so deliveries would be split between
252 /// them.
253 ///
254 /// # Cancel safety
255 ///
256 /// Dropping the returned stream between items is allowed. On `JetStream`, dropping it
257 /// mid-fetch can leave already-fetched, undelivered messages to be redelivered after the
258 /// consumer's `ack_wait`.
259 fn batches(
260 &mut self,
261 size: NonZeroUsize,
262 ) -> impl Stream<Item = Result<Self::Batch, Self::Error>> + Send + '_ {
263 match &mut self.kind {
264 SubscriberKind::Core(core) => Either::Left(core.batches(size)),
265 // A fetch hands over what the consumer has, up to `size`, and waits only for the
266 // first message: so does this.
267 #[cfg(feature = "testing")]
268 SubscriberKind::InProcessJetStream(consumer) => {
269 let limit = size.get();
270 Either::Right(Either::Right(poll_fn(move |cx| {
271 let first = match consumer.poll_next(cx) {
272 Poll::Pending => return Poll::Pending,
273 Poll::Ready(None) => return Poll::Ready(None),
274 Poll::Ready(Some(delivery)) => delivery,
275 };
276 let mut batch = vec![NatsMessage::JetStream(Box::new(
277 JetStreamMessage::in_process(first),
278 ))];
279 while batch.len() < limit {
280 match consumer.poll_next(cx) {
281 Poll::Ready(Some(delivery)) => batch.push(NatsMessage::JetStream(
282 Box::new(JetStreamMessage::in_process(delivery)),
283 )),
284 Poll::Ready(None) | Poll::Pending => break,
285 }
286 }
287 Poll::Ready(Some(Ok(batch)))
288 })))
289 }
290 SubscriberKind::JetStream(js) => {
291 let max = size.get();
292 let expires = js.pull_expires;
293 let batches = unfold(&mut js.consumer, move |consumer| async move {
294 loop {
295 let fetch = consumer
296 .fetch()
297 .max_messages(max)
298 .expires(expires)
299 .messages()
300 .await;
301 let mut messages = match fetch {
302 Ok(messages) => messages,
303 // BatchError is a concrete sized type; wrap it.
304 Err(err) => {
305 return Some((Err(NatsError::JetStream(Box::new(err))), consumer));
306 }
307 };
308 let mut batch = Vec::new();
309 while let Some(item) = messages.next().await {
310 match item {
311 Ok(msg) => batch.push(jetstream_message(msg)),
312 // crate::Error is already Box<dyn StdError + ...>; use directly.
313 Err(err) => {
314 if batch.is_empty() {
315 return Some((Err(NatsError::JetStream(err)), consumer));
316 }
317 warn!(
318 target: "ruststream::nats",
319 error = %err,
320 "jetstream fetch error mid-batch; delivering the partial batch",
321 );
322 break;
323 }
324 }
325 }
326 if !batch.is_empty() {
327 return Some((Ok(batch), consumer));
328 }
329 // An empty fetch only means `expires` elapsed with nothing pending.
330 }
331 });
332 #[cfg(feature = "testing")]
333 let batches = Either::Left(batches);
334 Either::Right(batches)
335 }
336 }
337 }
338}