1use std::{fmt::Display, future::Future, sync::Arc};
53
54use nautilus_common::live::dst::time::{self, Duration};
55use nautilus_network::{
56 backoff::ExponentialBackoff,
57 retry::{RetryConfig, RetryManager},
58};
59use tokio_util::sync::CancellationToken;
60
61use super::snapshot::SnapshotGate;
62
63const ATTEMPTS_MAX: u32 = 8;
65const ELAPSED_MAX_MS: u64 = 180_000;
66const OPERATION_TIMEOUT_MS: Option<u64> = None;
67
68const RETRY_DELAY_INITIAL_MS: u64 = 1_000;
69const RETRY_DELAY_MAX_MS: u64 = 10_000;
70const RETRY_BACKOFF_FACTOR: f64 = 2.0;
71const RETRY_JITTER_MAX_MS: u64 = 1_000;
72const RETRY_FIRST_IMMEDIATE: bool = true;
73
74const RETRY_DELAY_CEILING_INITIAL_MS: u64 = 60_000;
76const RETRY_DELAY_CEILING_MAX_MS: u64 = 900_000;
77const RETRY_JITTER_CEILING_MAX_MS: u64 = 5_000;
78
79#[derive(Debug, Clone)]
81pub enum BookRecoveryOutcome<E> {
82 Pending,
83 Accepted,
84 Rejected(E),
85}
86
87#[derive(Debug)]
89pub struct BookRecovery<E> {
90 pub cancellation: CancellationToken,
91 pub outcome: tokio::sync::watch::Sender<BookRecoveryOutcome<E>>,
92 pub gate: SnapshotGate,
93 reconnected: tokio::sync::Notify,
94}
95
96impl<E: Clone> Default for BookRecovery<E> {
97 fn default() -> Self {
98 Self {
99 cancellation: CancellationToken::new(),
100 outcome: tokio::sync::watch::channel(BookRecoveryOutcome::Pending).0,
101 gate: SnapshotGate::default(),
102 reconnected: tokio::sync::Notify::new(),
103 }
104 }
105}
106
107impl<E: Clone> BookRecovery<E> {
108 #[must_use]
110 pub fn begin_replacement(&self) -> bool {
111 let mut gate = self.gate.lock();
112
113 if self.cancellation.is_cancelled() || self.is_accepted() {
114 return false;
115 }
116
117 gate.close();
118 true
119 }
120
121 #[must_use]
123 pub fn is_accepted(&self) -> bool {
124 matches!(*self.outcome.borrow(), BookRecoveryOutcome::Accepted)
125 }
126
127 #[must_use]
129 pub fn is_running(&self) -> bool {
130 !self.is_accepted() && !self.cancellation.is_cancelled()
131 }
132
133 pub fn accept(&self) -> bool {
135 let gate = self.gate.lock();
136 if gate.is_closed() || (self.cancellation.is_cancelled() && !self.is_accepted()) {
137 return false;
138 }
139
140 self.outcome.send_replace(BookRecoveryOutcome::Accepted);
141 true
142 }
143}
144
145impl<E: Clone + std::error::Error> BookRecovery<E> {
146 #[allow(
156 clippy::missing_panics_doc,
157 reason = "the ceiling backoff configuration is static and valid"
158 )]
159 pub async fn run<F, Fut>(
160 &self,
161 book: impl Display,
162 snapshot_timeout: Duration,
163 replace: F,
164 should_retry: impl Fn(&E) -> bool,
165 create_error: impl Fn(String) -> E,
166 timeout_error: impl Fn() -> E,
167 ) where
168 F: Fn(CancellationToken, SnapshotGate) -> Fut,
169 Fut: Future<Output = Result<(), E>>,
170 {
171 let attempt = || self.attempt(snapshot_timeout, &replace, &create_error, &timeout_error);
172
173 let budget = RetryManager::<E>::new(RetryConfig {
174 max_retries: ATTEMPTS_MAX - 1,
175 initial_delay_ms: RETRY_DELAY_INITIAL_MS,
176 max_delay_ms: RETRY_DELAY_MAX_MS,
177 backoff_factor: RETRY_BACKOFF_FACTOR,
178 jitter_ms: RETRY_JITTER_MAX_MS,
179 immediate_first: RETRY_FIRST_IMMEDIATE,
180 operation_timeout_ms: OPERATION_TIMEOUT_MS,
181 max_elapsed_ms: Some(ELAPSED_MAX_MS),
182 });
183
184 let result = budget
185 .invocation("book recovery", attempt, should_retry, |e| {
186 create_error(e.to_string())
187 })
188 .cancellation_token(&self.cancellation)
189 .execute()
190 .await;
191
192 if let Err(e) = result {
193 if self.cancellation.is_cancelled() {
194 return;
195 }
196
197 log::error!(
198 "Book recovery for {book} did not complete within its retry budget; retrying at \
199 intervals growing from {}s to {}s until a snapshot is accepted: {e}",
200 RETRY_DELAY_CEILING_INITIAL_MS / 1_000,
201 RETRY_DELAY_CEILING_MAX_MS / 1_000,
202 );
203
204 let attempt_max =
206 snapshot_timeout.max(Duration::from_millis(RETRY_DELAY_CEILING_INITIAL_MS));
207
208 let attempts = async {
209 let mut backoff = ExponentialBackoff::new(
210 Duration::from_millis(RETRY_DELAY_CEILING_INITIAL_MS),
211 Duration::from_millis(RETRY_DELAY_CEILING_MAX_MS),
212 RETRY_BACKOFF_FACTOR,
213 RETRY_JITTER_CEILING_MAX_MS,
214 false,
215 )
216 .expect("static ceiling backoff configuration is valid");
217
218 loop {
219 tokio::select! {
221 biased;
222 () = self.reconnected.notified() => {}
223 () = time::sleep(backoff.next_duration()) => {}
224 }
225
226 let result = time::timeout(attempt_max, attempt())
227 .await
228 .unwrap_or_else(|_| Err(timeout_error()));
229
230 match result {
231 Ok(()) => return,
232 Err(e) => log::warn!("Book recovery attempt for {book} failed: {e}"),
233 }
234 }
235 };
236
237 let mut outcome = self.outcome.subscribe();
238
239 tokio::select! {
242 biased;
243 () = self.cancellation.cancelled() => return,
244 _ = outcome.wait_for(|o| matches!(o, BookRecoveryOutcome::Accepted)) => {}
245 () = attempts => {}
246 }
247 }
248
249 if self.is_accepted() {
250 log::info!("Book recovery completed for {book}");
251 }
252 }
253
254 async fn attempt<F, Fut>(
255 &self,
256 snapshot_timeout: Duration,
257 replace: &F,
258 create_error: &impl Fn(String) -> E,
259 timeout_error: &impl Fn() -> E,
260 ) -> Result<(), E>
261 where
262 F: Fn(CancellationToken, SnapshotGate) -> Fut,
263 Fut: Future<Output = Result<(), E>>,
264 {
265 let mut outcome = self.outcome.subscribe();
266
267 if !self.begin_replacement() {
268 return Ok(());
269 }
270
271 self.outcome.send_if_modified(|outcome| {
272 if matches!(outcome, BookRecoveryOutcome::Rejected(_)) {
273 *outcome = BookRecoveryOutcome::Pending;
274 true
275 } else {
276 false
277 }
278 });
279
280 let cancel = self.cancellation.child_token();
281 let _guard = cancel.clone().drop_guard();
282 replace(cancel, self.gate.clone()).await?;
283
284 let wait = async {
285 loop {
286 match outcome.borrow_and_update().clone() {
287 BookRecoveryOutcome::Accepted => return Ok(()),
288 BookRecoveryOutcome::Rejected(e) => return Err(e),
289 BookRecoveryOutcome::Pending => {}
290 }
291
292 outcome
293 .changed()
294 .await
295 .map_err(|e| create_error(e.to_string()))?;
296 }
297 };
298
299 if snapshot_timeout.is_zero() {
300 wait.await
301 } else {
302 time::timeout(snapshot_timeout, wait)
303 .await
304 .unwrap_or_else(|_| Err(timeout_error()))
305 }
306 }
307}
308
309#[derive(Debug)]
311pub struct BookRecoveryState<E> {
312 recovery: Option<Arc<BookRecovery<E>>>,
313}
314
315impl<E> Default for BookRecoveryState<E> {
316 fn default() -> Self {
317 Self { recovery: None }
318 }
319}
320
321impl<E: Clone> BookRecoveryState<E> {
322 pub fn claim(&mut self) -> Option<Arc<BookRecovery<E>>> {
324 if self.is_running() {
325 return None;
326 }
327
328 self.reset();
329 let recovery = Arc::new(BookRecovery::default());
330 self.recovery = Some(Arc::clone(&recovery));
331 Some(recovery)
332 }
333
334 #[must_use]
336 pub fn current(&self) -> Option<&Arc<BookRecovery<E>>> {
337 self.recovery.as_ref()
338 }
339
340 #[must_use]
342 pub fn is_running(&self) -> bool {
343 self.recovery.as_ref().is_some_and(|r| r.is_running())
344 }
345
346 pub fn reset_on_reconnect(&mut self) {
354 if let Some(recovery) = self
355 .recovery
356 .as_ref()
357 .filter(|recovery| recovery.is_running())
358 {
359 recovery.reconnected.notify_one();
360 } else {
361 self.reset();
362 }
363 }
364
365 pub fn reset(&mut self) {
367 if let Some(recovery) = self.recovery.take() {
368 recovery.cancellation.cancel();
369 }
370 }
371}
372
373impl<E> Drop for BookRecoveryState<E> {
374 fn drop(&mut self) {
375 if let Some(recovery) = &self.recovery {
376 recovery.cancellation.cancel();
377 }
378 }
379}
380
381#[cfg(test)]
382mod tests {
383 use std::sync::atomic::{AtomicUsize, Ordering};
384
385 use nautilus_network::error::SendError;
386 use rstest::rstest;
387
388 use super::*;
389
390 #[rstest]
391 fn replacement_owner_cannot_accept_after_reset() {
392 let mut state = BookRecoveryState::<SendError>::default();
393 let old = state.claim().unwrap();
394 let duplicate = state.claim();
395 state.reset();
396 let current = state.claim().unwrap();
397
398 assert!(duplicate.is_none());
399 assert!(old.cancellation.is_cancelled());
400 assert!(!old.accept());
401 assert!(Arc::ptr_eq(state.current().unwrap(), ¤t));
402 assert!(current.accept());
403 assert!(!state.is_running());
404 }
405
406 #[rstest]
407 fn closed_gate_suppresses_snapshot_until_opened() {
408 let mut state = BookRecoveryState::<SendError>::default();
409 let recovery = state.claim().unwrap();
410 assert!(recovery.begin_replacement());
411
412 let while_closed = recovery.accept();
413 recovery.gate.open();
414 let after_open = recovery.accept();
415
416 assert!(!while_closed);
417 assert!(after_open);
418 assert!(state.claim().is_some());
419 }
420
421 #[rstest]
422 fn claim_replaces_cancelled_episode() {
423 let mut state = BookRecoveryState::<SendError>::default();
424 let first = state.claim().unwrap();
425 first.cancellation.cancel();
426
427 let second = state.claim().unwrap();
428
429 assert!(!Arc::ptr_eq(&first, &second));
430 assert!(state.is_running());
431 }
432
433 #[rstest]
434 fn reconnect_keeps_running_episode() {
435 let mut state = BookRecoveryState::<SendError>::default();
436 let recovery = state.claim().unwrap();
437 assert!(recovery.begin_replacement());
438
439 state.reset_on_reconnect();
440
441 assert!(Arc::ptr_eq(state.current().unwrap(), &recovery));
442 assert!(!recovery.cancellation.is_cancelled());
443 }
444
445 #[rstest]
446 fn reconnect_resets_cancelled_episode() {
447 let mut state = BookRecoveryState::<SendError>::default();
448 let recovery = state.claim().unwrap();
449 recovery.cancellation.cancel();
450
451 state.reset_on_reconnect();
452
453 assert!(state.current().is_none());
454 }
455
456 #[rstest]
457 fn reconnect_resets_accepted_episode() {
458 let mut state = BookRecoveryState::<SendError>::default();
459 let recovery = state.claim().unwrap();
460 assert!(recovery.accept());
461
462 state.reset_on_reconnect();
463
464 assert!(state.current().is_none());
465 }
466
467 #[tokio::test(start_paused = true)]
468 async fn missing_snapshots_spend_budget_then_retry_at_ceiling() {
469 let recovery = BookRecovery::<SendError>::default();
470 let attempts = AtomicUsize::new(0);
471 let started = time::Instant::now();
472
473 recovery
474 .run(
475 "BOOK",
476 Duration::from_secs(1),
477 |_, gate| {
478 let attempt = attempts.fetch_add(1, Ordering::SeqCst) + 1;
479 gate.open();
480
481 if attempt == 10 {
483 assert!(recovery.accept());
484 }
485
486 async { Ok(()) }
487 },
488 |_| true,
489 SendError::BrokenPipe,
490 || SendError::Timeout,
491 )
492 .await;
493
494 assert_eq!(attempts.load(Ordering::SeqCst), 10);
495 assert!(recovery.is_accepted());
496 assert!(started.elapsed() >= Duration::from_secs(180));
497 }
498
499 #[tokio::test(start_paused = true)]
500 async fn elapsed_budget_cancels_pending_send_and_continues() {
501 let recovery = BookRecovery::<SendError>::default();
502 let attempts = AtomicUsize::new(0);
503 let child = parking_lot::Mutex::new(None);
504
505 let result = time::timeout(
506 Duration::from_secs(200),
507 recovery.run(
508 "BOOK",
509 Duration::from_secs(1),
510 |cancel, _| {
511 attempts.fetch_add(1, Ordering::SeqCst);
512 *child.lock() = Some(cancel);
513 std::future::pending::<Result<(), SendError>>()
514 },
515 |_| true,
516 SendError::BrokenPipe,
517 || SendError::Timeout,
518 ),
519 )
520 .await;
521
522 assert!(result.is_err());
523 assert_eq!(attempts.load(Ordering::SeqCst), 1);
524 assert!(recovery.is_running());
525 assert!(child.lock().as_ref().unwrap().is_cancelled());
526 }
527
528 #[tokio::test(start_paused = true)]
529 async fn non_retryable_error_moves_to_ceiling() {
530 let recovery = BookRecovery::<SendError>::default();
531 let attempts = AtomicUsize::new(0);
532
533 let result = time::timeout(
534 Duration::from_secs(59),
535 recovery.run(
536 "BOOK",
537 Duration::from_secs(1),
538 |_, _| {
539 attempts.fetch_add(1, Ordering::SeqCst);
540 async { Err(SendError::Closed) }
541 },
542 |_| false,
543 SendError::BrokenPipe,
544 || SendError::Timeout,
545 ),
546 )
547 .await;
548
549 assert!(result.is_err());
550 assert_eq!(attempts.load(Ordering::SeqCst), 1);
551 assert!(recovery.is_running());
552 }
553
554 #[rstest]
556 #[case::disabled_snapshot_deadline(Duration::ZERO, false)]
557 #[case::stalled_write(Duration::from_secs(10), true)]
558 #[tokio::test(start_paused = true)]
559 async fn ceiling_attempts_are_bounded_and_continue(
560 #[case] snapshot_timeout: Duration,
561 #[case] stall_write: bool,
562 ) {
563 let recovery = BookRecovery::<SendError>::default();
564 let attempts = AtomicUsize::new(0);
565
566 let result = time::timeout(
567 Duration::from_secs(700),
568 recovery.run(
569 "BOOK",
570 snapshot_timeout,
571 |_, gate| {
572 attempts.fetch_add(1, Ordering::SeqCst);
573 let stall = stall_write;
574 async move {
575 if stall {
576 std::future::pending::<()>().await;
577 }
578
579 gate.open();
580 Ok(())
581 }
582 },
583 |_| true,
584 SendError::BrokenPipe,
585 || SendError::Timeout,
586 ),
587 )
588 .await;
589
590 assert!(result.is_err());
592 assert_eq!(attempts.load(Ordering::SeqCst), 3);
593 assert!(recovery.is_running());
594 }
595
596 #[tokio::test(start_paused = true)]
597 async fn snapshot_between_ceiling_attempts_completes_run() {
598 let recovery = BookRecovery::<SendError>::default();
599 let attempts = AtomicUsize::new(0);
600 let started = time::Instant::now();
601
602 let run = recovery.run(
603 "BOOK",
604 Duration::from_secs(1),
605 |_, gate| {
606 attempts.fetch_add(1, Ordering::SeqCst);
607 gate.open();
608 async { Err(SendError::Closed) }
609 },
610 |_| false,
611 SendError::BrokenPipe,
612 || SendError::Timeout,
613 );
614
615 let late_snapshot = async {
616 time::sleep(Duration::from_secs(10)).await;
617 assert!(recovery.accept());
618 };
619
620 tokio::join!(run, late_snapshot);
621
622 assert_eq!(attempts.load(Ordering::SeqCst), 1);
623 assert!(recovery.is_accepted());
624 assert_eq!(started.elapsed(), Duration::from_secs(10));
625 }
626
627 #[tokio::test(start_paused = true)]
629 async fn reconnect_wakes_episode_waiting_at_ceiling() {
630 let mut state = BookRecoveryState::<SendError>::default();
631 let recovery = state.claim().unwrap();
632 let attempts = AtomicUsize::new(0);
633
634 let run = recovery.run(
635 "BOOK",
636 Duration::from_secs(1),
637 |_, _| {
638 attempts.fetch_add(1, Ordering::SeqCst);
639 async { Err(SendError::Closed) }
640 },
641 |_| false,
642 SendError::BrokenPipe,
643 || SendError::Timeout,
644 );
645
646 let reconnect = async {
647 time::sleep(Duration::from_secs(10)).await;
648 let before = attempts.load(Ordering::SeqCst);
649 state.reset_on_reconnect();
650 time::sleep(Duration::from_secs(1)).await;
651 let after = attempts.load(Ordering::SeqCst);
652 recovery.cancellation.cancel();
653 (before, after)
654 };
655
656 let ((), (before, after)) = tokio::join!(run, reconnect);
657
658 assert_eq!(before, 1);
659 assert_eq!(after, 2);
660 assert!(Arc::ptr_eq(state.current().unwrap(), &recovery));
661 }
662
663 #[tokio::test(start_paused = true)]
664 async fn cancellation_at_ceiling_ends_run() {
665 let recovery = BookRecovery::<SendError>::default();
666 let attempts = AtomicUsize::new(0);
667
668 let run = recovery.run(
669 "BOOK",
670 Duration::from_secs(1),
671 |_, _| {
672 attempts.fetch_add(1, Ordering::SeqCst);
673 async { Err(SendError::Closed) }
674 },
675 |_| false,
676 SendError::BrokenPipe,
677 || SendError::Timeout,
678 );
679
680 let cancel = async {
681 time::sleep(Duration::from_secs(30)).await;
682 recovery.cancellation.cancel();
683 };
684
685 tokio::join!(run, cancel);
686
687 assert_eq!(attempts.load(Ordering::SeqCst), 1);
688 assert!(!recovery.is_running());
689 assert!(!recovery.is_accepted());
690 }
691
692 #[tokio::test(start_paused = true)]
693 async fn snapshot_during_send_completes_without_retry() {
694 let recovery = BookRecovery::<SendError>::default();
695 let attempts = AtomicUsize::new(0);
696
697 recovery
698 .run(
699 "BOOK",
700 Duration::from_secs(1),
701 |_, gate| {
702 attempts.fetch_add(1, Ordering::SeqCst);
703 assert!(!recovery.accept());
704 gate.open();
705 assert!(recovery.accept());
706 async { Ok(()) }
707 },
708 |_| true,
709 SendError::BrokenPipe,
710 || SendError::Timeout,
711 )
712 .await;
713
714 assert_eq!(attempts.load(Ordering::SeqCst), 1);
715 assert!(recovery.is_accepted());
716 }
717
718 #[tokio::test(start_paused = true)]
719 async fn dropping_owner_cancels_pending_attempt() {
720 let mut state = BookRecoveryState::<SendError>::default();
721 let recovery = state.claim().unwrap();
722 let child = parking_lot::Mutex::new(None);
723
724 let operation = recovery.run(
725 "BOOK",
726 Duration::ZERO,
727 |cancel, _| {
728 *child.lock() = Some(cancel);
729 std::future::pending::<Result<(), SendError>>()
730 },
731 |_| true,
732 SendError::BrokenPipe,
733 || SendError::Timeout,
734 );
735
736 tokio::pin!(operation);
737 tokio::select! {
738 () = &mut operation => panic!("unexpected completion"),
739 () = tokio::task::yield_now() => {},
740 }
741 drop(state);
742 operation.await;
743
744 assert!(child.lock().as_ref().unwrap().is_cancelled());
745 }
746}