chio_supervisor/config.rs
1//! Supervisor policy: restart budget, backoff schedule, and the per-iteration
2//! outcome the worker body reports back to the loop.
3
4use crate::time::as_millis_u64;
5use std::time::Duration;
6
7/// Restart and trip policy for one supervised worker.
8#[derive(Debug, Clone)]
9pub struct SupervisorConfig {
10 /// Stable identifier used in log lines, thread names, and health reasons.
11 pub name: &'static str,
12 /// A tripped flag on a TCB-critical surface fails evaluations closed.
13 pub tcb_critical: bool,
14 /// Consecutive restarts before the flag trips to `Degraded`.
15 pub trip_after: u32,
16 /// Consecutive restarts before terminal `Failed`, after which the supervisor
17 /// stops respawning and leaves the flag set for surfaces to read.
18 pub max_restarts: u32,
19 /// First backoff delay; subsequent delays double up to `max_backoff`.
20 pub base_backoff: Duration,
21 /// Ceiling on the exponential backoff delay.
22 pub max_backoff: Duration,
23}
24
25impl SupervisorConfig {
26 /// A conventional policy: trip to degraded after a few consecutive failures,
27 /// give up after a larger budget, exponential backoff bounded at a minute.
28 #[must_use]
29 pub fn new(name: &'static str, tcb_critical: bool) -> Self {
30 Self {
31 name,
32 tcb_critical,
33 trip_after: 3,
34 max_restarts: 10,
35 base_backoff: Duration::from_millis(100),
36 max_backoff: Duration::from_secs(60),
37 }
38 }
39}
40
41/// Result of one worker-body invocation, reported back to the supervisor loop.
42pub enum SupervisedOutcome {
43 /// The worker observed the shutdown signal and exited cleanly. Do not restart.
44 Shutdown,
45 /// One iteration completed successfully (a one-shot tick such as a single cluster
46 /// sync round). Loop again without recording a failure and without backing off.
47 /// The worker is expected to have already stamped its own healthy heartbeat via
48 /// [`crate::HealthFlag::record_ok`], so a healthy tick is never misclassified as a
49 /// restart-worthy failure.
50 Continue,
51 /// The worker returned or failed and should be restarted with backoff.
52 Restart,
53}
54
55/// Exponential backoff for the `count`-th consecutive restart:
56/// `min(max_backoff, base_backoff * 2^(count - 1))`, computed with saturating
57/// arithmetic so a large `count` can never overflow or panic.
58#[must_use]
59pub(crate) fn backoff_delay(config: &SupervisorConfig, count: u32) -> Duration {
60 let shift = count.saturating_sub(1);
61 let factor = 1u64.checked_shl(shift).unwrap_or(u64::MAX);
62 let base_ms = as_millis_u64(config.base_backoff);
63 let uncapped = base_ms.saturating_mul(factor);
64 let capped = uncapped.min(as_millis_u64(config.max_backoff));
65 Duration::from_millis(capped)
66}
67
68#[cfg(all(test, not(loom)))]
69mod tests {
70 use super::*;
71
72 fn config() -> SupervisorConfig {
73 SupervisorConfig {
74 name: "test",
75 tcb_critical: false,
76 trip_after: 3,
77 max_restarts: 5,
78 base_backoff: Duration::from_millis(100),
79 max_backoff: Duration::from_secs(10),
80 }
81 }
82
83 #[test]
84 fn backoff_doubles_from_base() {
85 let cfg = config();
86 assert_eq!(backoff_delay(&cfg, 1), Duration::from_millis(100));
87 assert_eq!(backoff_delay(&cfg, 2), Duration::from_millis(200));
88 assert_eq!(backoff_delay(&cfg, 3), Duration::from_millis(400));
89 }
90
91 #[test]
92 fn backoff_saturates_at_max_and_never_overflows() {
93 let cfg = config();
94 assert_eq!(backoff_delay(&cfg, 100), cfg.max_backoff);
95 // A count large enough to overflow the shift must still saturate cleanly.
96 assert_eq!(backoff_delay(&cfg, u32::MAX), cfg.max_backoff);
97 }
98
99 #[test]
100 fn default_policy_is_sane() {
101 let cfg = SupervisorConfig::new("writer", true);
102 assert!(cfg.tcb_critical);
103 assert!(cfg.trip_after <= cfg.max_restarts);
104 assert!(cfg.base_backoff <= cfg.max_backoff);
105 }
106}