cdk_common/stream.rs
1//! Supervision for long-lived streaming subscriptions.
2//!
3//! [`SupervisedStream`] owns the reconnect, backoff, and shutdown loop that
4//! every consumer of a long-lived stream (a gRPC server-stream, a payment
5//! backend's event stream) would otherwise hand-roll. It is transport-agnostic:
6//! the implementor supplies how to open a fresh stream and how to handle each
7//! item, holding its connection state as fields rather than cloning it into
8//! per-call closures.
9
10use std::fmt;
11use std::future::Future;
12
13use futures::{pin_mut, Stream, StreamExt};
14
15/// Capped exponential backoff for [`SupervisedStream`] reconnect attempts.
16///
17/// Only consecutive connect failures grow the delay; a successful connect resets
18/// it. So an endpoint that keeps refusing is backed off exponentially, while one
19/// that recovers is not held to a delay earned by earlier failures.
20#[derive(Debug, Clone, Copy)]
21pub struct BackoffPolicy {
22 /// Delay before the first reconnect, and the floor a successful connect
23 /// resets the growing backoff to. Must be non-zero, else the doubling
24 /// (`0 * 2 == 0`) never grows and reconnects busy-loop.
25 pub initial_connect_backoff: std::time::Duration,
26 /// Cap on the delay while backing off. Must be at least `initial`.
27 pub max_connect_backoff: std::time::Duration,
28}
29
30/// A supervised, self-reconnecting streaming subscription.
31///
32/// Implementors own the connection state (clients, channels, publishers) as
33/// fields, so the reconnect/backoff/shutdown loop can hand each item to
34/// [`on_message`](Self::on_message) without the per-item state-cloning a
35/// closure-based supervisor forces. The provided [`supervise`](Self::supervise)
36/// method owns that loop; an implementor supplies only how to connect, how to
37/// handle an item, and (optionally) how to tear down.
38#[async_trait::async_trait]
39pub trait SupervisedStream: Send {
40 /// Item the stream yields and [`on_message`](Self::on_message) consumes.
41 type Item: Send;
42 /// Error a failed connect attempt yields. Logged, then retried.
43 type ConnectError: fmt::Display + Send;
44 /// Error the stream may yield per item. Logged, then reconnected.
45 type StreamError: fmt::Display + Send;
46 /// The stream a successful [`connect`](Self::connect) opens.
47 type Stream: Stream<Item = Result<Self::Item, Self::StreamError>> + Send;
48
49 /// Names this subscription in the supervisor's reconnect/close logs.
50 fn name(&self) -> &str;
51
52 /// Backoff policy for reconnect attempts.
53 fn backoff_policy(&self) -> BackoffPolicy;
54
55 /// Open a fresh stream of items.
56 async fn connect(&mut self) -> Result<Self::Stream, Self::ConnectError>;
57
58 /// Handle one delivered item. Awaited to completion, so a slow handler
59 /// holds the read loop; offload work that must not block it.
60 async fn on_message(&mut self, item: Self::Item);
61
62 /// Teardown run once before [`supervise`](Self::supervise) returns, on every
63 /// exit path. Cancel tokens or release resources here.
64 async fn on_shutdown(&mut self) {}
65
66 /// Keep the subscription alive across reconnects until `shutdown` resolves.
67 ///
68 /// Every item [`connect`](Self::connect) yields is handed to
69 /// [`on_message`](Self::on_message). Reconnect timing follows
70 /// [`BackoffPolicy`]: an opened stream that later closes or errors reconnects
71 /// at the floor, since the connection itself was healthy.
72 ///
73 /// `shutdown` stops the supervisor promptly whenever it is waiting: to
74 /// connect, for the next item, or during a backoff. It does not interrupt an
75 /// in-flight `on_message`. [`on_shutdown`](Self::on_shutdown) runs on every
76 /// exit path.
77 async fn supervise<S>(&mut self, shutdown: S)
78 where
79 S: Future<Output = ()> + Send,
80 {
81 // Clamp a degenerate policy rather than trusting the implementor: a
82 // zero `initial` would busy-loop (`0 * 2 == 0`) and a `max` below
83 // `initial` would clamp below the floor.
84 let policy = self.backoff_policy();
85 let initial = policy
86 .initial_connect_backoff
87 .max(std::time::Duration::from_millis(1));
88 let max = policy.max_connect_backoff.max(initial);
89
90 pin_mut!(shutdown);
91 let mut backoff = initial;
92
93 'outer: loop {
94 let connect_result = tokio::select! {
95 biased;
96 _ = &mut shutdown => break 'outer,
97 result = self.connect() => result,
98 };
99
100 let wait = match connect_result {
101 Ok(stream) => {
102 // Reset on a healthy connection, not per message, so an
103 // idle-but-open stream that later drops still reconnects at
104 // the floor.
105 backoff = initial;
106 pin_mut!(stream);
107 loop {
108 let next = tokio::select! {
109 biased;
110 _ = &mut shutdown => break 'outer,
111 next = stream.next() => next,
112 };
113
114 match next {
115 Some(Ok(item)) => self.on_message(item).await,
116 Some(Err(err)) => {
117 tracing::warn!(name = self.name(), "Stream error: {err}");
118 break;
119 }
120 None => {
121 tracing::debug!(name = self.name(), "Stream closed by the server");
122 break;
123 }
124 }
125 }
126 // An opened stream closed or errored. Wait the floor, not
127 // the growing backoff: the connection was working.
128 initial
129 }
130 Err(err) => {
131 tracing::warn!(name = self.name(), "Could not open stream: {err}");
132 // Wait the current backoff, then grow it. Saturating so a
133 // large `initial` cannot overflow; `max` clamps it back down.
134 let wait = backoff;
135 backoff = backoff.saturating_mul(2).min(max);
136 wait
137 }
138 };
139
140 // Shutdown during the wait ends the loop immediately.
141 tokio::select! {
142 biased;
143 _ = &mut shutdown => break 'outer,
144 _ = tokio::time::sleep(wait) => {}
145 }
146 }
147
148 self.on_shutdown().await;
149 }
150}
151
152#[cfg(test)]
153mod tests {
154 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
155 use std::sync::{Arc, Mutex};
156 use std::time::Duration;
157
158 use futures::stream;
159 use tokio::sync::Notify;
160 use tokio::time::Instant;
161
162 use super::*;
163
164 /// Stream error type used across the tests. `&'static str` is `Display`.
165 type TestErr = &'static str;
166 /// Concrete stream type each `connect` returns, so the `Ok` and `Err` arms
167 /// share one type without boxing.
168 type ItemStream = stream::Iter<std::vec::IntoIter<Result<u32, TestErr>>>;
169 /// One scripted connect outcome: a failed connect, or a stream of items.
170 type ConnectStep = Result<Vec<Result<u32, TestErr>>, TestErr>;
171
172 /// A scripted [`SupervisedStream`] implementor. Each connect consumes the
173 /// next `steps` entry (the last entry repeats), and the stop conditions fire
174 /// `shutdown` from `connect` or `on_message` so a test drives the loop to a
175 /// deterministic end.
176 struct Harness {
177 policy: BackoffPolicy,
178 steps: Vec<ConnectStep>,
179 shutdown: Arc<Notify>,
180 connects: Arc<AtomicUsize>,
181 received: Arc<Mutex<Vec<u32>>>,
182 shutdown_ran: Arc<AtomicBool>,
183 /// Fire shutdown from `connect` once this many attempts have started.
184 stop_after_connects: Option<usize>,
185 /// Fire shutdown from `on_message` when this item arrives.
186 stop_on_item: Option<u32>,
187 /// Fire shutdown from `on_message` once this many items are received.
188 stop_at_len: Option<usize>,
189 }
190
191 impl Harness {
192 fn new(policy: BackoffPolicy, steps: Vec<ConnectStep>) -> Self {
193 Self {
194 policy,
195 steps,
196 shutdown: Arc::new(Notify::new()),
197 connects: Arc::new(AtomicUsize::new(0)),
198 received: Arc::new(Mutex::new(Vec::new())),
199 shutdown_ran: Arc::new(AtomicBool::new(false)),
200 stop_after_connects: None,
201 stop_on_item: None,
202 stop_at_len: None,
203 }
204 }
205 }
206
207 // The connect counter is bumped inside `connect`'s body, not in the loop.
208 // `tokio::select!` evaluates the `self.connect()` branch expression eagerly
209 // every iteration, even on the pass where `shutdown` wins, so only the poll
210 // of the future (running the body) marks a real connection attempt.
211 #[async_trait::async_trait]
212 impl SupervisedStream for Harness {
213 type Item = u32;
214 type ConnectError = TestErr;
215 type StreamError = TestErr;
216 type Stream = ItemStream;
217
218 fn name(&self) -> &str {
219 "test"
220 }
221
222 fn backoff_policy(&self) -> BackoffPolicy {
223 self.policy
224 }
225
226 async fn connect(&mut self) -> Result<Self::Stream, TestErr> {
227 let n = self.connects.fetch_add(1, Ordering::SeqCst);
228 if let Some(k) = self.stop_after_connects {
229 if n + 1 >= k {
230 self.shutdown.notify_one();
231 }
232 }
233 let idx = n.min(self.steps.len() - 1);
234 self.steps[idx].clone().map(stream::iter)
235 }
236
237 async fn on_message(&mut self, item: u32) {
238 let mut v = self.received.lock().expect("lock");
239 v.push(item);
240 if self.stop_on_item == Some(item) {
241 self.shutdown.notify_one();
242 }
243 if self.stop_at_len.is_some_and(|l| v.len() >= l) {
244 self.shutdown.notify_one();
245 }
246 }
247
248 async fn on_shutdown(&mut self) {
249 self.shutdown_ran.store(true, Ordering::SeqCst);
250 }
251 }
252
253 #[tokio::test(start_paused = true)]
254 async fn shutdown_before_first_connect_never_connects() {
255 let mut h = Harness::new(
256 BackoffPolicy {
257 initial_connect_backoff: Duration::from_millis(10),
258 max_connect_backoff: Duration::from_secs(1),
259 },
260 vec![Ok(vec![])],
261 );
262 // Already signalled: the very first select must pick shutdown.
263 h.shutdown.notify_one();
264 let connects = Arc::clone(&h.connects);
265 let shutdown = Arc::clone(&h.shutdown);
266
267 h.supervise(async move { shutdown.notified().await }).await;
268
269 assert_eq!(connects.load(Ordering::SeqCst), 0);
270 }
271
272 #[tokio::test(start_paused = true)]
273 async fn forwards_items_and_reconnects_until_shutdown() {
274 // Each connection yields two items, so reaching four proves a reconnect.
275 let mut h = Harness::new(
276 BackoffPolicy {
277 initial_connect_backoff: Duration::from_millis(10),
278 max_connect_backoff: Duration::from_secs(1),
279 },
280 vec![Ok(vec![Ok(0), Ok(1)]), Ok(vec![Ok(2), Ok(3)])],
281 );
282 h.stop_at_len = Some(4);
283 let connects = Arc::clone(&h.connects);
284 let received = Arc::clone(&h.received);
285 let shutdown = Arc::clone(&h.shutdown);
286
287 h.supervise(async move { shutdown.notified().await }).await;
288
289 assert_eq!(*received.lock().expect("lock"), vec![0, 1, 2, 3]);
290 assert_eq!(connects.load(Ordering::SeqCst), 2);
291 }
292
293 #[tokio::test(start_paused = true)]
294 async fn item_error_reconnects_and_skips_rest_of_stream() {
295 // The error breaks the first stream before `11` is reached.
296 let mut h = Harness::new(
297 BackoffPolicy {
298 initial_connect_backoff: Duration::from_millis(10),
299 max_connect_backoff: Duration::from_secs(1),
300 },
301 vec![
302 Ok(vec![Ok(10), Err("mid-stream"), Ok(11)]),
303 Ok(vec![Ok(20)]),
304 ],
305 );
306 h.stop_on_item = Some(20);
307 let received = Arc::clone(&h.received);
308 let shutdown = Arc::clone(&h.shutdown);
309
310 h.supervise(async move { shutdown.notified().await }).await;
311
312 // `11` is never delivered: the error terminated that stream first.
313 assert_eq!(*received.lock().expect("lock"), vec![10, 20]);
314 }
315
316 #[tokio::test(start_paused = true)]
317 async fn opened_stream_error_waits_floor_not_grown_backoff() {
318 // Two connect failures grow the backoff, then a stream opens and
319 // immediately errors. The successful connect resets the backoff, so the
320 // wait after the stream error is the fixed floor, not the grown delay:
321 // only connect failures back off, an opened stream that errors does not.
322 let mut h = Harness::new(
323 BackoffPolicy {
324 initial_connect_backoff: Duration::from_millis(100),
325 max_connect_backoff: Duration::from_secs(10),
326 },
327 vec![
328 // Fails: sleep 100ms floor, backoff doubles to 200ms.
329 Err("connect refused"),
330 // Fails: sleep 200ms, backoff doubles to 400ms.
331 Err("connect refused"),
332 // Opens then errors: reset to the floor, then wait the fixed
333 // 100ms, not the 400ms the failures had reached.
334 Ok(vec![Err("mid-stream")]),
335 ],
336 );
337 h.stop_after_connects = Some(4);
338 let attempts = Arc::clone(&h.connects);
339 let shutdown = Arc::clone(&h.shutdown);
340 let start = Instant::now();
341
342 h.supervise(async move { shutdown.notified().await }).await;
343
344 assert_eq!(attempts.load(Ordering::SeqCst), 4);
345 // 100 (fail) + 200 (fail) + 100 (floor after the stream error) = 400ms.
346 // If a stream error grew the backoff, the third wait would have been
347 // 400ms, for 700ms total.
348 assert_eq!(start.elapsed(), Duration::from_millis(400));
349 }
350
351 #[tokio::test(start_paused = true)]
352 async fn connect_failures_back_off_exponentially() {
353 let mut h = Harness::new(
354 BackoffPolicy {
355 initial_connect_backoff: Duration::from_millis(100),
356 max_connect_backoff: Duration::from_millis(400),
357 },
358 vec![Err("connect refused")],
359 );
360 // Stop after the fourth failed attempt.
361 h.stop_after_connects = Some(4);
362 let attempts = Arc::clone(&h.connects);
363 let shutdown = Arc::clone(&h.shutdown);
364 let start = Instant::now();
365
366 h.supervise(async move { shutdown.notified().await }).await;
367
368 assert_eq!(attempts.load(Ordering::SeqCst), 4);
369 // Backoff sleeps precede each doubling, so the delays between the four
370 // attempts are 100 + 200 + 400 (capped) = 700ms. The paused clock only
371 // advances for the elapsed sleeps.
372 assert_eq!(start.elapsed(), Duration::from_millis(700));
373 }
374
375 #[tokio::test(start_paused = true)]
376 async fn successful_connect_resets_backoff_even_without_messages() {
377 // Two connect failures grow the backoff, then a stream opens but never
378 // delivers a message before closing. The reset happens on the successful
379 // connect, not on a delivery, so the disconnect waits the 100ms floor
380 // rather than the elevated backoff.
381 let mut h = Harness::new(
382 BackoffPolicy {
383 initial_connect_backoff: Duration::from_millis(100),
384 max_connect_backoff: Duration::from_secs(10),
385 },
386 vec![
387 // Fails: sleep 100ms floor, backoff doubles to 200ms.
388 Err("connect refused"),
389 // Fails: sleep 200ms, backoff doubles to 400ms.
390 Err("connect refused"),
391 // Opens but yields nothing and closes: reset to the floor, then
392 // wait the fixed 100ms, not the 400ms the failures had reached.
393 Ok(vec![]),
394 ],
395 );
396 h.stop_after_connects = Some(4);
397 let attempts = Arc::clone(&h.connects);
398 let shutdown = Arc::clone(&h.shutdown);
399 let start = Instant::now();
400
401 h.supervise(async move { shutdown.notified().await }).await;
402
403 assert_eq!(attempts.load(Ordering::SeqCst), 4);
404 // 100 (fail) + 200 (fail) + 100 (floor after the empty stream) = 400ms.
405 // Under a per-failure backoff that ignored the successful connect, the
406 // third wait would have been 400ms, for 700ms total.
407 assert_eq!(start.elapsed(), Duration::from_millis(400));
408 }
409
410 #[tokio::test(start_paused = true)]
411 async fn max_below_initial_is_clamped_to_the_floor() {
412 // `max` below `initial` is a caller mistake; the supervisor clamps it up
413 // to `initial` so the delay never drops below the floor.
414 let mut h = Harness::new(
415 BackoffPolicy {
416 initial_connect_backoff: Duration::from_millis(200),
417 max_connect_backoff: Duration::from_millis(100),
418 },
419 vec![Err("connect refused")],
420 );
421 // Stop after the third failed attempt, so two backoff sleeps elapse.
422 h.stop_after_connects = Some(3);
423 let attempts = Arc::clone(&h.connects);
424 let shutdown = Arc::clone(&h.shutdown);
425 let start = Instant::now();
426
427 h.supervise(async move { shutdown.notified().await }).await;
428
429 assert_eq!(attempts.load(Ordering::SeqCst), 3);
430 // Both sleeps are the 200ms floor: without the clamp the second would be
431 // `min(400, 100) = 100ms`, giving 300ms total instead of 400ms.
432 assert_eq!(start.elapsed(), Duration::from_millis(400));
433 }
434
435 #[tokio::test(start_paused = true)]
436 async fn on_shutdown_runs_after_supervise_returns() {
437 let mut h = Harness::new(
438 BackoffPolicy {
439 initial_connect_backoff: Duration::from_millis(10),
440 max_connect_backoff: Duration::from_secs(1),
441 },
442 vec![Ok(vec![])],
443 );
444 h.shutdown.notify_one();
445 let shutdown_ran = Arc::clone(&h.shutdown_ran);
446 let shutdown = Arc::clone(&h.shutdown);
447
448 h.supervise(async move { shutdown.notified().await }).await;
449
450 assert!(shutdown_ran.load(Ordering::SeqCst));
451 }
452}