1use std::collections::VecDeque;
5use std::pin::Pin;
6use std::task::{Context, Poll};
7use std::time::Duration;
8
9use bytes::Bytes;
10use futures_core::Stream;
11use futures_core::future::BoxFuture;
12use futures_core::stream::BoxStream;
13use serde::de::DeserializeOwned;
14use tokio::time::{Instant, Sleep};
15
16use super::StreamConfig;
17use super::json_lines::JsonLines;
18use crate::error::Error;
19
20pub(crate) type ByteStream = BoxStream<'static, reqwest::Result<Bytes>>;
21type Lines<T> = JsonLines<ByteStream, T>;
22type ConnectFuture = BoxFuture<'static, Result<ByteStream, Error>>;
23type BackfillFuture<T> = BoxFuture<'static, Result<Vec<T>, Error>>;
24type Terminal<T> = Option<Poll<Option<Result<T, Error>>>>;
26
27pub(crate) trait StreamKind: Send + Unpin + 'static {
29 type Item: DeserializeOwned + Send + Unpin + 'static;
30
31 fn connect(&mut self, reconnect: bool) -> ConnectFuture;
35
36 fn filter(&mut self, _item: &Self::Item) -> bool {
39 true
40 }
41
42 fn backfill(&mut self) -> Option<BackfillFuture<Self::Item>> {
45 None
46 }
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
51#[non_exhaustive]
52pub struct StreamStats {
53 pub reconnects: u64,
56 pub failed_attempts: u64,
58}
59
60enum State<T> {
61 Connecting(ConnectFuture),
62 Backfilling {
63 lines: Lines<T>,
64 future: BackfillFuture<T>,
65 },
66 Draining {
67 lines: Lines<T>,
68 pending: VecDeque<T>,
69 },
70 Streaming {
71 lines: Lines<T>,
72 watchdog: Pin<Box<Sleep>>,
73 },
74 Sleeping(Pin<Box<Sleep>>),
75 Done,
76}
77
78pub(crate) struct ManagedStream<K: StreamKind> {
79 kind: K,
80 config: StreamConfig,
81 state: State<K::Item>,
82 stats: StreamStats,
83 attempts_since_success: u32,
84 current_delay: Duration,
85 connected_at: Option<Instant>,
86}
87
88impl<K: StreamKind> ManagedStream<K> {
89 pub(crate) fn new(kind: K, config: StreamConfig, initial: ByteStream) -> Self {
93 let heartbeat_timeout = config.heartbeat_timeout;
94 ManagedStream {
95 kind,
96 current_delay: config.backoff_initial,
97 config,
98 state: State::Streaming {
99 lines: JsonLines::new(initial),
100 watchdog: Box::pin(tokio::time::sleep(heartbeat_timeout)),
101 },
102 stats: StreamStats::default(),
103 attempts_since_success: 0,
104 connected_at: Some(Instant::now()),
105 }
106 }
107
108 pub(crate) fn stats(&self) -> StreamStats {
109 self.stats
110 }
111
112 fn connection_lost(&mut self, error: Option<Error>) -> Terminal<K::Item> {
115 #[cfg(feature = "tracing")]
116 tracing::debug!(error = ?error, "stream connection lost");
117
118 if !self.config.auto_reconnect {
119 self.state = State::Done;
120 return Some(Poll::Ready(error.map(Err)));
121 }
122 if let Some(connected_at) = self.connected_at.take() {
125 if connected_at.elapsed() >= self.config.backoff_reset_after {
126 self.attempts_since_success = 0;
127 self.current_delay = self.config.backoff_initial;
128 }
129 }
130 self.schedule_reconnect(error)
131 }
132
133 fn connect_failed(&mut self, error: Error) -> Terminal<K::Item> {
135 self.stats.failed_attempts += 1;
136
137 #[cfg(feature = "tracing")]
138 tracing::debug!(error = %error, "stream reconnect attempt failed");
139
140 if is_fatal(&error) {
141 self.state = State::Done;
142 return Some(Poll::Ready(Some(Err(error))));
143 }
144 self.schedule_reconnect(Some(error))
145 }
146
147 fn schedule_reconnect(&mut self, error: Option<Error>) -> Terminal<K::Item> {
148 if let Some(max) = self.config.max_reconnect_attempts {
149 if self.attempts_since_success >= max {
150 self.state = State::Done;
151 return Some(Poll::Ready(Some(Err(error.unwrap_or_else(|| {
152 Error::Stream("reconnect attempts exhausted".into())
153 })))));
154 }
155 }
156 self.attempts_since_success += 1;
157 let delay = jitter(self.current_delay);
158 self.current_delay = (self.current_delay * 2).min(self.config.backoff_max);
159
160 #[cfg(feature = "tracing")]
161 tracing::debug!(delay = ?delay, attempt = self.attempts_since_success, "stream reconnect scheduled");
162
163 self.state = State::Sleeping(Box::pin(tokio::time::sleep(delay)));
164 None
165 }
166}
167
168fn is_fatal(error: &Error) -> bool {
171 match error {
172 Error::Api { status, .. } => status.is_client_error(),
173 Error::Config(_) => true,
174 _ => false,
175 }
176}
177
178fn jitter(delay: Duration) -> Duration {
181 let nanos = Instant::now().elapsed().subsec_nanos() as u64 ^ delay.as_nanos() as u64;
182 let factor = 0.75 + (nanos % 1000) as f64 / 2000.0; delay.mul_f64(factor)
184}
185
186impl<K: StreamKind> Stream for ManagedStream<K> {
187 type Item = Result<K::Item, Error>;
188
189 fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
190 let this = self.get_mut();
191 loop {
192 match &mut this.state {
193 State::Connecting(future) => match future.as_mut().poll(cx) {
194 Poll::Ready(Ok(bytes)) => {
195 this.stats.reconnects += 1;
196 this.connected_at = Some(Instant::now());
197 let lines = JsonLines::new(bytes);
198
199 #[cfg(feature = "tracing")]
200 tracing::debug!(reconnects = this.stats.reconnects, "stream reconnected");
201
202 this.state = match this.kind.backfill() {
203 Some(future) => State::Backfilling { lines, future },
204 None => State::Streaming {
205 lines,
206 watchdog: Box::pin(tokio::time::sleep(
207 this.config.heartbeat_timeout,
208 )),
209 },
210 };
211 }
212 Poll::Ready(Err(e)) => {
213 if let Some(result) = this.connect_failed(e) {
214 return result;
215 }
216 }
217 Poll::Pending => return Poll::Pending,
218 },
219 State::Backfilling { future, .. } => match future.as_mut().poll(cx) {
220 Poll::Ready(result) => {
221 let State::Backfilling { lines, .. } =
222 std::mem::replace(&mut this.state, State::Done)
223 else {
224 unreachable!()
225 };
226 match result {
227 Ok(items) => {
228 this.state = State::Draining {
229 lines,
230 pending: items.into(),
231 };
232 }
233 Err(e) => {
234 this.state = State::Streaming {
237 lines,
238 watchdog: Box::pin(tokio::time::sleep(
239 this.config.heartbeat_timeout,
240 )),
241 };
242 return Poll::Ready(Some(Err(e)));
243 }
244 }
245 }
246 Poll::Pending => return Poll::Pending,
247 },
248 State::Draining { pending, .. } => match pending.pop_front() {
249 Some(item) => {
250 if this.kind.filter(&item) {
251 return Poll::Ready(Some(Ok(item)));
252 }
253 }
254 None => {
255 let State::Draining { lines, .. } =
256 std::mem::replace(&mut this.state, State::Done)
257 else {
258 unreachable!()
259 };
260 this.state = State::Streaming {
261 lines,
262 watchdog: Box::pin(tokio::time::sleep(this.config.heartbeat_timeout)),
263 };
264 }
265 },
266 State::Streaming { lines, watchdog } => {
267 match Pin::new(lines).poll_next(cx) {
268 Poll::Ready(Some(Ok(item))) => {
269 watchdog
270 .as_mut()
271 .reset(Instant::now() + this.config.heartbeat_timeout);
272 if this.kind.filter(&item) {
273 return Poll::Ready(Some(Ok(item)));
274 }
275 }
276 Poll::Ready(Some(Err(e @ Error::Decode { .. }))) => {
277 return Poll::Ready(Some(Err(e)));
280 }
281 Poll::Ready(Some(Err(e))) => {
282 if let Some(result) = this.connection_lost(Some(e)) {
283 return result;
284 }
285 }
286 Poll::Ready(None) => {
287 if let Some(result) = this.connection_lost(None) {
288 return result;
289 }
290 }
291 Poll::Pending => match watchdog.as_mut().poll(cx) {
292 Poll::Ready(()) => {
293 let stale = Error::Stream(format!(
294 "no data within {:?} (heartbeats expected every 5s); connection considered stale",
295 this.config.heartbeat_timeout
296 ));
297 if let Some(result) = this.connection_lost(Some(stale)) {
298 return result;
299 }
300 }
301 Poll::Pending => return Poll::Pending,
302 },
303 }
304 }
305 State::Sleeping(sleep) => match sleep.as_mut().poll(cx) {
306 Poll::Ready(()) => {
307 this.state = State::Connecting(this.kind.connect(true));
308 }
309 Poll::Pending => return Poll::Pending,
310 },
311 State::Done => return Poll::Ready(None),
312 }
313 }
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320 use futures_util::StreamExt;
321 use std::sync::{Arc, Mutex};
322
323 enum Outcome {
325 Fail,
327 FailFatal,
329 Chunks(Vec<&'static [u8]>),
331 ChunksThenHang(Vec<&'static [u8]>),
333 Spaced { count: u32, gap: Duration },
336 }
337
338 struct MockKind {
339 script: std::collections::VecDeque<Outcome>,
340 connects: Arc<Mutex<Vec<Instant>>>,
341 }
342
343 impl MockKind {
344 fn new(script: Vec<Outcome>) -> (Self, Arc<Mutex<Vec<Instant>>>) {
345 let connects = Arc::new(Mutex::new(Vec::new()));
346 (
347 MockKind {
348 script: script.into(),
349 connects: Arc::clone(&connects),
350 },
351 connects,
352 )
353 }
354 }
355
356 fn bytes_from(outcome: Outcome) -> Result<ByteStream, Error> {
357 use futures_util::stream;
358 match outcome {
359 Outcome::Fail => Err(Error::Stream("connection refused".into())),
360 Outcome::FailFatal => Err(Error::Api {
361 status: reqwest::StatusCode::UNAUTHORIZED,
362 request_id: None,
363 body: crate::error::ApiErrorBody::from_text("nope".into()),
364 }),
365 Outcome::Chunks(chunks) => {
366 Ok(stream::iter(chunks.into_iter().map(|c| Ok(Bytes::from_static(c)))).boxed())
367 }
368 Outcome::ChunksThenHang(chunks) => Ok(stream::iter(
369 chunks.into_iter().map(|c| Ok(Bytes::from_static(c))),
370 )
371 .chain(stream::pending())
372 .boxed()),
373 Outcome::Spaced { count, gap } => Ok(stream::unfold(0u32, move |i| async move {
374 if i >= count {
375 return None;
376 }
377 tokio::time::sleep(gap).await;
378 Some((Ok(Bytes::from_static(b"{\"type\":\"HEARTBEAT\"}\n")), i + 1))
379 })
380 .boxed()),
381 }
382 }
383
384 impl StreamKind for MockKind {
385 type Item = serde_json::Value;
386
387 fn connect(&mut self, _reconnect: bool) -> ConnectFuture {
388 self.connects.lock().unwrap().push(Instant::now());
389 let outcome = self.script.pop_front().expect("script exhausted");
390 Box::pin(async move { bytes_from(outcome) })
391 }
392 }
393
394 fn config() -> StreamConfig {
395 StreamConfig::default()
396 }
397
398 fn managed(
399 script: Vec<Outcome>,
400 initial: Outcome,
401 config: StreamConfig,
402 ) -> (ManagedStream<MockKind>, Arc<Mutex<Vec<Instant>>>) {
403 let (kind, connects) = MockKind::new(script);
404 let initial = bytes_from(initial).unwrap();
405 (ManagedStream::new(kind, config, initial), connects)
406 }
407
408 #[tokio::test(start_paused = true)]
409 async fn backoff_escalates_with_cap_during_outage() {
410 let mut cfg = config();
414 cfg.max_reconnect_attempts = Some(10);
415 let script = (0..10).map(|_| Outcome::Fail).collect();
416 let (stream, connects) = managed(script, Outcome::Chunks(vec![]), cfg);
417 let start = Instant::now();
418 let items: Vec<_> = stream.collect().await;
419 assert_eq!(items.len(), 1);
420 assert!(items[0].is_err(), "expected terminal error");
421
422 let connects = connects.lock().unwrap();
423 assert_eq!(connects.len(), 10);
424 let mut previous = start;
425 for (i, at) in connects.iter().enumerate() {
426 let expected = Duration::from_secs(1 << i).min(Duration::from_secs(300));
427 let delta = at.duration_since(previous);
428 assert!(
429 delta >= expected.mul_f64(0.74) && delta <= expected.mul_f64(1.26),
430 "attempt {i}: delta {delta:?}, expected ~{expected:?}"
431 );
432 previous = *at;
433 }
434 let last = connects[9].duration_since(connects[8]);
437 assert!(last >= Duration::from_secs(225) && last <= Duration::from_secs(375));
438 }
439
440 #[tokio::test(start_paused = true)]
441 async fn stable_connection_resets_backoff() {
442 let script = vec![
446 Outcome::Fail,
447 Outcome::Fail,
448 Outcome::Spaced {
449 count: 13,
450 gap: Duration::from_secs(5),
451 }, Outcome::Fail,
453 Outcome::Chunks(vec![b"{\"ok\":1}\n"]),
454 ];
455 let mut cfg = config();
456 cfg.max_reconnect_attempts = Some(100);
457 let (stream, connects) = managed(script, Outcome::Chunks(vec![]), cfg);
458 let items: Vec<_> = stream.take(14).collect().await;
461 assert_eq!(items.iter().filter(|r| r.is_ok()).count(), 14);
462
463 let connects = connects.lock().unwrap();
464 let healthy_end = connects[2] + Duration::from_secs(65);
467 let delta = connects[3].duration_since(healthy_end);
468 assert!(
469 delta <= Duration::from_millis(1300),
470 "backoff was not reset after stable connection: {delta:?}"
471 );
472 }
473
474 #[tokio::test(start_paused = true)]
475 async fn short_lived_connection_keeps_escalating() {
476 let script = vec![
480 Outcome::Fail,
481 Outcome::Chunks(vec![]), Outcome::Fail,
483 Outcome::Chunks(vec![b"{\"ok\":1}\n"]),
484 ];
485 let (stream, connects) = managed(script, Outcome::Chunks(vec![]), config());
486 let items: Vec<_> = stream.take(1).collect().await;
487 assert!(items[0].is_ok());
488
489 let connects = connects.lock().unwrap();
490 let delta = connects[2].duration_since(connects[1]);
494 assert!(
495 delta >= Duration::from_millis(2900),
496 "backoff reset after an unstable connection: {delta:?}"
497 );
498 }
499
500 #[tokio::test(start_paused = true)]
501 async fn watchdog_detects_stale_connection() {
502 let script = vec![Outcome::Chunks(vec![b"{\"n\":2}\n"])];
505 let (stream, connects) = managed(
506 script,
507 Outcome::ChunksThenHang(vec![b"{\"n\":1}\n"]),
508 config(),
509 );
510 let start = Instant::now();
511 let items: Vec<_> = stream.take(2).collect().await;
512 assert_eq!(items.iter().filter(|r| r.is_ok()).count(), 2);
513
514 let connects = connects.lock().unwrap();
515 let delta = connects[0].duration_since(start);
516 assert!(
518 delta >= Duration::from_millis(10_700) && delta <= Duration::from_millis(11_300),
519 "unexpected stale detection timing: {delta:?}"
520 );
521 }
522
523 #[tokio::test(start_paused = true)]
524 async fn fatal_error_ends_stream() {
525 let script = vec![Outcome::FailFatal];
526 let (stream, _) = managed(script, Outcome::Chunks(vec![]), config());
527 let items: Vec<_> = stream.collect().await;
528 assert_eq!(items.len(), 1);
529 match &items[0] {
530 Err(Error::Api { status, .. }) => assert_eq!(status.as_u16(), 401),
531 other => panic!("expected fatal Api error, got {other:?}"),
532 }
533 }
534
535 #[tokio::test(start_paused = true)]
536 async fn auto_reconnect_disabled_ends_on_eof() {
537 let mut cfg = config();
538 cfg.auto_reconnect = false;
539 let (stream, connects) = managed(vec![], Outcome::Chunks(vec![b"{\"n\":1}\n"]), cfg);
540 let items: Vec<_> = stream.collect().await;
541 assert_eq!(items.len(), 1);
542 assert!(items[0].is_ok());
543 assert!(connects.lock().unwrap().is_empty(), "must not reconnect");
544 }
545
546 #[tokio::test(start_paused = true)]
547 async fn stats_count_reconnects() {
548 let script = vec![Outcome::Fail, Outcome::Chunks(vec![b"{\"n\":1}\n"])];
549 let mut cfg = config();
550 cfg.auto_reconnect = true;
551 let (mut stream, _) = managed(script, Outcome::Chunks(vec![]), cfg);
552 let first = stream.next().await.unwrap();
553 assert!(first.is_ok());
554 let stats = stream.stats();
555 assert_eq!(stats.reconnects, 1);
556 assert_eq!(stats.failed_attempts, 1);
557 }
558}