Skip to main content

arminius_lib_nats/
resubscribe.rs

1// SPDX-FileCopyrightText: 2026 BWI GmbH
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! Functionality to detect NATS disconnects and seamless reconnects
6
7use std::{
8	ops::ControlFlow,
9	pin::Pin,
10	sync::{
11		Arc,
12		atomic::{AtomicBool, Ordering},
13	},
14	time::Duration,
15};
16
17use arminius_lib_message_bus::Error;
18use backon::{BackoffBuilder as _, ExponentialBuilder};
19use futures::{Stream, StreamExt as _};
20
21/// Maximum backoff delay between re-subscription attempts (also the fallback
22/// delay when the backoff iterator is exhausted).
23const RECONNECT_MAX_DELAY: Duration = Duration::from_secs(32);
24
25/// Boxed stream of the messages a subscription delivers; re-created on every
26/// re-subscribe.
27pub type MessageStream<T> = Pin<Box<dyn Stream<Item = T> + Send>>;
28
29/// Reconnect signals shared between the NATS event callback and the
30/// subscriber's background task.
31#[derive(Debug)]
32pub struct ConnectionSignals {
33	/// `true` when the connection is alive; `false` after a `Disconnected`
34	/// event.
35	pub connected: Arc<AtomicBool>,
36	/// Fired on every `Connected` event so waiting tasks can attempt to
37	/// re-subscribe.
38	pub reconnect_notify: Arc<tokio::sync::Notify>,
39}
40
41/// Outcome returned by [`forward_stream`].
42pub enum ForwardOutcome {
43	/// The upstream stream ended or a disconnection was detected; re-subscribe.
44	Resubscribe,
45	/// The receiver end of the channel was dropped; the background task should
46	/// stop.
47	ReceiverDropped,
48}
49
50/// Forwards items from `stream` into `tx`, watching `signals.connected` for
51/// disconnections.
52///
53/// Returns [`ForwardOutcome::Resubscribe`] when re-subscription is needed and
54/// [`ForwardOutcome::ReceiverDropped`] when the channel receiver has been
55/// closed.
56pub async fn forward_stream<T: Send + 'static>(
57	signals: &ConnectionSignals,
58	subject: &'static str,
59	mut stream: MessageStream<T>,
60	tx: &tokio::sync::mpsc::Sender<T>,
61) -> ForwardOutcome {
62	while let Some(item) = stream.next().await {
63		if !signals.connected.load(Ordering::SeqCst) {
64			tracing::warn!(subject, "Detected disconnection, will re-subscribe after reconnection");
65			return ForwardOutcome::Resubscribe;
66		}
67		// The only point of the try_send is to detect backpressure
68		match tx.try_send(item) {
69			Ok(()) => {}
70			Err(tokio::sync::mpsc::error::TrySendError::Full(item)) => {
71				// TODO: We should have OTel metrics here.
72				tracing::warn!(subject, "Internal channel full, waiting to send");
73				if let Err(error) = tx.send(item).await {
74					tracing::error!(subject, %error, "Receiver dropped while waiting to send");
75					return ForwardOutcome::ReceiverDropped;
76				}
77			}
78			Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {
79				tracing::warn!(subject, "Receiver closed while forwarding");
80				return ForwardOutcome::ReceiverDropped;
81			}
82		}
83	}
84
85	tracing::warn!(subject, "Stream closed, will re-subscribe");
86	ForwardOutcome::Resubscribe
87}
88
89/// Waits until the connection is usable again.
90///
91/// Returns immediately while connected. Otherwise it parks until the NATS event
92/// callback signals a reconnect, or until the receiver is dropped — the latter
93/// yields [`ControlFlow::Break`], telling the caller to stop.
94async fn await_connection<T>(
95	signals: &ConnectionSignals,
96	tx: &tokio::sync::mpsc::Sender<T>,
97) -> ControlFlow<()> {
98	if signals.connected.load(Ordering::SeqCst) {
99		return ControlFlow::Continue(());
100	}
101
102	tokio::select! {
103		() = signals.reconnect_notify.notified() => ControlFlow::Continue(()),
104		() = tx.closed() => ControlFlow::Break(()),
105	}
106}
107
108/// Re-creates the message stream once the connection is back, retrying failed
109/// attempts with exponential backoff.
110///
111/// Returns `None` when the receiver was dropped while waiting or retrying: with
112/// nobody left to receive the messages, the background task should stop.
113async fn resubscribe<T, F, Fut>(
114	signals: &ConnectionSignals,
115	subject: &'static str,
116	make_stream: &F,
117	tx: &tokio::sync::mpsc::Sender<T>,
118) -> Option<MessageStream<T>>
119where
120	F: Fn() -> Fut + Sync,
121	Fut: Future<Output = Result<MessageStream<T>, Error>>,
122{
123	// Built per call, so the delay only grows across consecutive failed
124	// attempts and starts over once a subscription succeeds.
125	let mut backoff = ExponentialBuilder::default()
126		.with_max_delay(RECONNECT_MAX_DELAY)
127		.without_max_times()
128		.build();
129
130	loop {
131		if await_connection(signals, tx).await.is_break() {
132			return None;
133		}
134
135		match make_stream().await {
136			Ok(stream) => {
137				tracing::debug!(subject, "Re-subscribed after reconnection");
138				return Some(stream);
139			}
140			Err(error) => {
141				let delay = backoff.next().unwrap_or(RECONNECT_MAX_DELAY);
142				tracing::error!(
143					subject,
144					%error,
145					delay_secs = delay.as_secs(),
146					"Failed to re-subscribe, retrying with backoff"
147				);
148				tokio::time::sleep(delay).await;
149			}
150		}
151	}
152}
153
154/// Body of the background task behind [`resubscribing_stream`]: forwards
155/// `initial` into `tx`, then alternates between re-subscribing and forwarding
156/// until the receiver is dropped.
157async fn forward_and_resubscribe<T, F, Fut>(
158	signals: &ConnectionSignals,
159	subject: &'static str,
160	initial: MessageStream<T>,
161	make_stream: F,
162	tx: tokio::sync::mpsc::Sender<T>,
163) where
164	T: Send + 'static,
165	F: Fn() -> Fut + Sync,
166	Fut: Future<Output = Result<MessageStream<T>, Error>>,
167{
168	let mut stream = initial;
169
170	loop {
171		if matches!(
172			forward_stream(signals, subject, stream, &tx).await,
173			ForwardOutcome::ReceiverDropped
174		) {
175			break;
176		}
177
178		match resubscribe(signals, subject, &make_stream, &tx).await {
179			Some(next) => stream = next,
180			None => break,
181		}
182	}
183
184	tracing::debug!(subject, "Receiver dropped, stopping subscription task");
185}
186
187/// Spawns the background re-subscribe/forward task for a durable consumer and
188/// returns the consumer-facing stream.
189///
190/// `initial` is the already-created message stream (so setup errors surface
191/// synchronously at the call site); `make_stream` re-creates it after each
192/// disconnect. Items are forwarded through an internal channel holding
193/// `channel_size` items, and the task re-subscribes with exponential backoff
194/// until the receiver is dropped.
195///
196/// Shared by [`Subscriber::subscribe`](arminius_lib_message_bus::Subscriber)
197/// and [`Responder::respond`](arminius_lib_message_bus::Responder), which
198/// differ only in the per-message stream `make_stream` produces.
199pub fn resubscribing_stream<T, F, Fut>(
200	signals: Arc<ConnectionSignals>,
201	channel_size: usize,
202	subject: &'static str,
203	initial: MessageStream<T>,
204	make_stream: F,
205) -> impl Stream<Item = T> + Send + 'static
206where
207	T: Send + 'static,
208	F: Fn() -> Fut + Send + Sync + 'static,
209	Fut: Future<Output = Result<MessageStream<T>, Error>> + Send,
210{
211	let (tx, rx) = tokio::sync::mpsc::channel(channel_size);
212
213	tokio::spawn(async move {
214		forward_and_resubscribe(&signals, subject, initial, make_stream, tx).await;
215	});
216
217	futures::stream::unfold(rx, |mut rx| async move { rx.recv().await.map(|item| (item, rx)) })
218}
219
220#[cfg(test)]
221mod tests;