Skip to main content

oxios_kernel/
readiness.rs

1//! Subsystem readiness tracking (RFC-024 SP4).
2//!
3//! A daemon can answer HTTP requests before every subsystem has finished
4//! initializing (state store loading, engine provider warm-up, etc.). Naive
5//! handling causes `500`/`Internal` errors for the first few hundred
6//! milliseconds of every restart, plus hangs when the orchestrator is
7//! permanently unavailable. This module gives callers a single atomic
8//! gate: a route is "ready" only when both the state store and the engine
9//! have reached `Ready` or `Degraded`.
10//!
11//! **Three-state model** (per subsystem):
12//! - `Warming` — startup, not yet `Ready`. Counts as "not ready".
13//! - `Ready` — fully operational. Counts as "ready".
14//! - `Degraded` — operational with limitations (e.g. engine initialized but no API key;
15//!   only a fallback model available). **Counts as "ready"** so a missing API key does
16//!   not lock the user out of `/api/status` for diagnosis.
17//! - `Failed` — startup aborted (engine init crashed). The state store is still useful
18//!   for inspection so it is allowed to become `Ready` independently; the engine `Failed`
19//!   state keeps the readiness gate closed and `/api/status` is the only API that
20//!   bypasses it (RFC-024 §7.1.1).
21//!
22//! **Deadline.** Callers set a deadline (default 30 s) after which any
23//! subsystem still in `Warming` is force-promoted to `Degraded` to prevent
24//! the gate from staying closed forever.
25
26use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
27use std::time::{SystemTime, UNIX_EPOCH};
28
29const STATE_WARMING: u8 = 0;
30const STATE_READY: u8 = 1;
31const STATE_DEGRADED: u8 = 2;
32const STATE_FAILED: u8 = 3;
33
34/// Coarse readiness of a single subsystem.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36pub enum SubsystemState {
37    /// Startup in progress.
38    Warming,
39    /// Fully operational.
40    Ready,
41    /// Operational with limitations (still counts as "ready" for the gate).
42    Degraded,
43    /// Startup aborted; the subsystem is not usable.
44    Failed,
45}
46
47impl SubsystemState {
48    fn to_u8(self) -> u8 {
49        match self {
50            Self::Warming => STATE_WARMING,
51            Self::Ready => STATE_READY,
52            Self::Degraded => STATE_DEGRADED,
53            Self::Failed => STATE_FAILED,
54        }
55    }
56    fn from_u8(v: u8) -> Self {
57        match v {
58            STATE_READY => Self::Ready,
59            STATE_DEGRADED => Self::Degraded,
60            STATE_FAILED => Self::Failed,
61            _ => Self::Warming,
62        }
63    }
64}
65
66// Manual Serialize/Deserialize without external derive (used by `KernelHandle::readiness`
67// in tests + status JSON).
68use serde::{Deserialize, Serialize};
69
70/// Readiness gate: tracks two subsystems (state store, engine) and exposes
71/// a single `is_ready()` that returns `true` when the daemon can safely
72/// serve protected API routes.
73pub struct ReadinessGate {
74    state_store: AtomicU8,
75    engine: AtomicU8,
76    /// Unix-epoch seconds at which still-Warming subsystems are force-promoted
77    /// to Degraded. `0` means "no deadline" (caller is responsible).
78    deadline_secs: AtomicU64,
79}
80
81impl ReadinessGate {
82    /// Create a new gate in `Warming` state for both subsystems. `deadline_secs`
83    /// is the wall-clock (Unix epoch) at which any still-Warming subsystem
84    /// is force-promoted to Degraded. Pass `0` to disable the deadline.
85    pub fn new(deadline_secs: u64) -> Self {
86        Self {
87            state_store: AtomicU8::new(STATE_WARMING),
88            engine: AtomicU8::new(STATE_WARMING),
89            deadline_secs: AtomicU64::new(deadline_secs),
90        }
91    }
92
93    /// Update the wall-clock deadline for force-promoting Warming → Degraded.
94    /// Pass `0` to disable enforcement.
95    pub fn set_deadline_secs(&self, secs: u64) {
96        self.deadline_secs.store(secs, Ordering::SeqCst);
97    }
98
99    /// Read the current deadline (Unix-epoch seconds, or `0` if disabled).
100    pub fn deadline_secs(&self) -> u64 {
101        self.deadline_secs.load(Ordering::SeqCst)
102    }
103
104    /// Update the state-store readiness. Bumps the `oxios_readiness_state`
105    /// gauge when the gate's `is_ready()` result changes (RFC-024 §11).
106    pub fn set_state_store(&self, s: SubsystemState) {
107        self.state_store.store(s.to_u8(), Ordering::SeqCst);
108        self.update_readiness_gauge();
109    }
110
111    /// Update the engine readiness. Bumps the `oxios_readiness_state`
112    /// gauge when the gate's `is_ready()` result changes (RFC-024 §11).
113    pub fn set_engine(&self, s: SubsystemState) {
114        self.engine.store(s.to_u8(), Ordering::SeqCst);
115        self.update_readiness_gauge();
116    }
117
118    /// Recompute the readiness gauge and write it if the boolean changed.
119    /// Cheap: one CAS read + one gauge write at most per state mutation.
120    fn update_readiness_gauge(&self) {
121        let ready = self.is_ready();
122        crate::metrics::get_metrics()
123            .readiness_state
124            .set(if ready { 1.0 } else { 0.0 });
125    }
126
127    /// Read the current state-store state.
128    pub fn state_store_state(&self) -> SubsystemState {
129        SubsystemState::from_u8(self.state_store.load(Ordering::SeqCst))
130    }
131
132    /// Read the current engine state.
133    pub fn engine_state(&self) -> SubsystemState {
134        SubsystemState::from_u8(self.engine.load(Ordering::SeqCst))
135    }
136
137    /// `true` when the gate is open: both subsystems are `Ready` or
138    /// `Degraded`. A `Failed` (or still-`Warming`) subsystem keeps the gate
139    /// closed. `Degraded` counts as ready so a missing API key (engine)
140    /// or a slow-but-functional state store does not lock the user out
141    /// after the deadline elapses (RFC-024 SP4).
142    pub fn is_ready(&self) -> bool {
143        let s = self.state_store_state();
144        let e = self.engine_state();
145        let s_ok = s == SubsystemState::Ready || s == SubsystemState::Degraded;
146        let e_ok = e == SubsystemState::Ready || e == SubsystemState::Degraded;
147        s_ok && e_ok
148    }
149
150    /// Force-promote any still-Warming subsystem to Degraded once the
151    /// deadline elapses. Idempotent. Should be called by the kernel
152    /// during init and by the readiness middleware to enforce a ceiling
153    /// on how long a misconfigured engine can lock the gate.
154    pub fn enforce_deadline(&self) {
155        let deadline = self.deadline_secs.load(Ordering::SeqCst);
156        if deadline == 0 {
157            return;
158        }
159        if self.is_ready() {
160            return;
161        }
162        let now = SystemTime::now()
163            .duration_since(UNIX_EPOCH)
164            .unwrap_or_default()
165            .as_secs();
166        if now < deadline {
167            return;
168        }
169        if self.state_store_state() == SubsystemState::Warming {
170            self.set_state_store(SubsystemState::Degraded);
171        }
172        if self.engine_state() == SubsystemState::Warming {
173            self.set_engine(SubsystemState::Degraded);
174        }
175    }
176}
177
178impl std::fmt::Debug for ReadinessGate {
179    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180        f.debug_struct("ReadinessGate")
181            .field("state_store", &self.state_store_state())
182            .field("engine", &self.engine_state())
183            .field("is_ready", &self.is_ready())
184            .finish()
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn new_starts_warming_and_not_ready() {
194        let g = ReadinessGate::new(0);
195        assert!(!g.is_ready());
196        assert_eq!(g.state_store_state(), SubsystemState::Warming);
197        assert_eq!(g.engine_state(), SubsystemState::Warming);
198    }
199
200    #[test]
201    fn both_ready_means_ready() {
202        let g = ReadinessGate::new(0);
203        g.set_state_store(SubsystemState::Ready);
204        g.set_engine(SubsystemState::Ready);
205        assert!(g.is_ready());
206    }
207
208    #[test]
209    fn engine_degraded_still_counts_as_ready() {
210        let g = ReadinessGate::new(0);
211        g.set_state_store(SubsystemState::Ready);
212        g.set_engine(SubsystemState::Degraded);
213        assert!(g.is_ready());
214    }
215
216    #[test]
217    fn engine_failed_keeps_gate_closed() {
218        let g = ReadinessGate::new(0);
219        g.set_state_store(SubsystemState::Ready);
220        g.set_engine(SubsystemState::Failed);
221        assert!(!g.is_ready());
222    }
223
224    #[test]
225    fn state_store_not_ready_keeps_gate_closed() {
226        let g = ReadinessGate::new(0);
227        g.set_engine(SubsystemState::Ready);
228        assert!(!g.is_ready());
229    }
230
231    #[test]
232    fn deadline_elapsed_promotes_warming_to_degraded() {
233        // Deadline in the past.
234        let g = ReadinessGate::new(1);
235        std::thread::sleep(std::time::Duration::from_millis(1100));
236        g.enforce_deadline();
237        assert_eq!(g.state_store_state(), SubsystemState::Degraded);
238        assert_eq!(g.engine_state(), SubsystemState::Degraded);
239        assert!(g.is_ready());
240    }
241
242    #[test]
243    fn deadline_not_yet_elapsed_keeps_warming() {
244        let deadline = SystemTime::now()
245            .duration_since(UNIX_EPOCH)
246            .unwrap()
247            .as_secs()
248            + 60;
249        let g = ReadinessGate::new(deadline);
250        g.enforce_deadline();
251        assert_eq!(g.state_store_state(), SubsystemState::Warming);
252        assert!(!g.is_ready());
253    }
254
255    #[test]
256    fn deadline_zero_disables_enforcement() {
257        let g = ReadinessGate::new(0);
258        g.enforce_deadline();
259        assert_eq!(g.state_store_state(), SubsystemState::Warming);
260    }
261
262    /// RFC-024 §11: `set_state_store` / `set_engine` must publish the
263    /// resulting boolean to the `oxios_readiness_state` gauge. We do
264    /// not assert an exact value (other tests in the binary may have
265    /// run first) — only that the gauge line appears in the export
266    /// with a valid 0.0 or 1.0 value, and that toggling the gate
267    /// changes it.
268    #[test]
269    fn readiness_gauge_tracks_gate_state() {
270        // Snapshot the gauge before mutating.
271        let before = current_readiness_gauge();
272
273        let g = ReadinessGate::new(0);
274        // Fresh gate is Warming — gauge should be 0.
275        g.set_state_store(SubsystemState::Ready);
276        g.set_engine(SubsystemState::Ready);
277        let both_ready = current_readiness_gauge();
278        assert!(
279            (both_ready - 1.0).abs() < f64::EPSILON,
280            "both subsystems Ready should yield gauge=1.0, got {both_ready}"
281        );
282
283        // Flip one to Failed → gauge should drop back to 0.
284        g.set_engine(SubsystemState::Failed);
285        let one_failed = current_readiness_gauge();
286        assert!(
287            one_failed < both_ready,
288            "engine Failed should drop the gauge (before={before}, after={one_failed})"
289        );
290    }
291
292    fn current_readiness_gauge() -> f64 {
293        // Find the `oxios_readiness_state` line in the registry export.
294        // We rely on `register_builtin_metrics` having been called by
295        // another test (or by a binary that links this crate); if the
296        // gauge has not been registered the assertion in the test
297        // will fail and the developer will see it immediately.
298        let export = crate::metrics::registry().export();
299        for line in export.lines() {
300            if let Some(rest) = line.strip_prefix("oxios_readiness_state ")
301                && let Ok(v) = rest.trim().parse::<f64>()
302            {
303                return v;
304            }
305        }
306        panic!(
307            "oxios_readiness_state gauge not found in registry export — \
308             did register_builtin_metrics run?"
309        );
310    }
311}