Skip to main content

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}