Skip to main content

liminal_server/health/
checks.rs

1use std::sync::atomic::{AtomicBool, Ordering};
2use std::sync::{Arc, OnceLock};
3
4/// Process liveness state returned by the liveness probe.
5#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
6#[serde(rename_all = "snake_case")]
7pub enum HealthState {
8    /// The server process is alive.
9    Healthy,
10    /// The server process is not alive.
11    Unhealthy,
12}
13
14/// Result of the server liveness probe.
15#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
16pub struct HealthStatus {
17    /// Liveness status for the process.
18    pub status: HealthState,
19    /// Optional operator-facing liveness detail.
20    pub message: Option<String>,
21}
22
23impl HealthStatus {
24    /// Returns the healthy liveness status used while the process is running.
25    #[must_use]
26    pub const fn healthy() -> Self {
27        Self {
28            status: HealthState::Healthy,
29            message: None,
30        }
31    }
32
33    /// Returns an unhealthy liveness status with explanatory detail.
34    #[must_use]
35    pub fn unhealthy(message: impl Into<String>) -> Self {
36        Self {
37            status: HealthState::Unhealthy,
38            message: Some(message.into()),
39        }
40    }
41}
42
43/// Returns the server process liveness status.
44///
45/// This is deliberately a liveness probe, not a readiness probe: if the process
46/// can call this function, the process is alive and the result is healthy. That
47/// contract is intact and correct, and P0 #56 did NOT change it.
48///
49/// It does mean this function cannot answer "is the server serving?", and on the
50/// field estate it was read as if it could: a boot whose admission authority was
51/// latched refused 82,166 consecutive connections while this probe stayed green
52/// throughout, which it was right to do — the process was alive. The question
53/// that was actually being asked belongs to [`readiness_check`], which since
54/// P0 #56 reports [`ReadinessCondition::AdmissionAvailable`] unmet when the
55/// server cannot admit a connection. An orchestrator that wants traffic steered
56/// away from a server that cannot serve must read READINESS.
57#[must_use]
58pub const fn health_check() -> HealthStatus {
59    HealthStatus::healthy()
60}
61
62/// Shared, lock-free view of whether the server can admit a connection.
63///
64/// Owned by the connection-incarnation authority (the thing that actually knows)
65/// and read by the readiness probe. A handle rather than a snapshot because the
66/// answer changes at runtime: the authority arms it false when a durable write
67/// goes ambiguous, and back to true when a resume replay re-establishes ground
68/// truth.
69#[derive(Clone, Debug)]
70pub struct AdmissionReadiness {
71    available: Arc<AtomicBool>,
72}
73
74impl AdmissionReadiness {
75    /// Creates a handle that starts available.
76    #[must_use]
77    pub fn available() -> Self {
78        Self {
79            available: Arc::new(AtomicBool::new(true)),
80        }
81    }
82
83    /// Whether the server can currently admit a connection.
84    #[must_use]
85    pub fn is_available(&self) -> bool {
86        self.available.load(Ordering::SeqCst)
87    }
88
89    /// Records whether the server can currently admit a connection.
90    pub fn set_available(&self, available: bool) {
91        self.available.store(available, Ordering::SeqCst);
92    }
93}
94
95impl Default for AdmissionReadiness {
96    fn default() -> Self {
97        Self::available()
98    }
99}
100
101/// Cluster readiness requirement for a startup snapshot.
102#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
103pub enum ClusterReadiness {
104    /// No cluster configuration is present, so membership is not required.
105    #[default]
106    NotConfigured,
107    /// Cluster configuration is present and membership must be established.
108    Configured {
109        /// Whether beamr distribution membership has been established.
110        membership_established: bool,
111    },
112}
113
114/// Startup state evaluated by the readiness probe.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub struct ReadinessState {
117    /// Whether configuration has loaded, environment overrides applied, and
118    /// validation completed successfully.
119    pub config_loaded: bool,
120    /// Whether the main wire protocol listener is bound and accepting traffic.
121    ///
122    /// A bound listener is not a serving one: a socket can be accepted and then
123    /// refused at the admission door, which is the P0 #56 failure. See
124    /// [`Self::admission_available`].
125    pub listener_bound: bool,
126    /// Conditional cluster startup state.
127    pub cluster: ClusterReadiness,
128    /// Whether the server can admit a connection at all (P0 #56).
129    ///
130    /// Distinct from [`Self::listener_bound`] because the field defect sat
131    /// exactly between the two: ports listening, accepts succeeding, and every
132    /// connection refused a moment later by a latched incarnation authority.
133    pub admission_available: bool,
134}
135
136impl ReadinessState {
137    /// Creates a startup readiness snapshot.
138    #[must_use]
139    pub const fn new(config_loaded: bool, listener_bound: bool, cluster: ClusterReadiness) -> Self {
140        Self::with_admission(config_loaded, listener_bound, cluster, true)
141    }
142
143    /// Creates a startup readiness snapshot including the admission gate.
144    #[must_use]
145    pub const fn with_admission(
146        config_loaded: bool,
147        listener_bound: bool,
148        cluster: ClusterReadiness,
149        admission_available: bool,
150    ) -> Self {
151        Self {
152            config_loaded,
153            listener_bound,
154            cluster,
155            admission_available,
156        }
157    }
158
159    /// Creates a fully ready snapshot for a non-clustered server.
160    #[must_use]
161    pub const fn ready_without_cluster() -> Self {
162        Self::new(true, true, ClusterReadiness::NotConfigured)
163    }
164
165    /// Creates a fully ready snapshot for a clustered server.
166    #[must_use]
167    pub const fn ready_with_cluster() -> Self {
168        Self::new(
169            true,
170            true,
171            ClusterReadiness::Configured {
172                membership_established: true,
173            },
174        )
175    }
176}
177
178impl Default for ReadinessState {
179    /// The pre-startup snapshot: nothing loaded, nothing bound, and admission
180    /// ASSUMED AVAILABLE.
181    ///
182    /// Not a derive, because `bool::default()` is false and that would be a
183    /// different claim: it would report a brand-new server as unable to admit
184    /// connections, which is not something anything has observed yet. The
185    /// admission gate is the one condition here that reports a RUNTIME fact
186    /// rather than a startup step, so its honest default is "no evidence
187    /// against", matching what `SharedReadinessState::snapshot` reports before
188    /// an admission source is installed.
189    fn default() -> Self {
190        Self::with_admission(false, false, ClusterReadiness::NotConfigured, true)
191    }
192}
193
194/// Readiness conditions that can prevent a server from receiving traffic.
195#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
196#[serde(rename_all = "snake_case")]
197pub enum ReadinessCondition {
198    /// Configuration has not completed loading and validation.
199    ConfigLoaded,
200    /// The main wire protocol listener is not bound and accepting traffic.
201    ListenerBound,
202    /// Cluster configuration is present but membership is not established.
203    ClusterMembershipEstablished,
204    /// The server cannot admit a connection (P0 #56). Ports may be listening
205    /// and accepts may be succeeding; what fails is everything after that.
206    AdmissionAvailable,
207}
208
209/// Result of the server readiness probe.
210#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
211pub struct ReadinessStatus {
212    /// True only when all applicable startup gates are satisfied.
213    pub ready: bool,
214    /// Startup gates that are not yet satisfied, in stable evaluation order.
215    pub unmet_conditions: Vec<ReadinessCondition>,
216}
217
218impl ReadinessStatus {
219    /// Creates a readiness status from unmet startup gates.
220    #[must_use]
221    pub fn from_unmet_conditions(unmet_conditions: Vec<ReadinessCondition>) -> Self {
222        Self {
223            ready: unmet_conditions.is_empty(),
224            unmet_conditions,
225        }
226    }
227}
228
229/// Thread-safe readiness state shared with the HTTP endpoint server.
230#[derive(Debug, Clone)]
231pub struct SharedReadinessState {
232    inner: Arc<ReadinessFlags>,
233}
234
235impl SharedReadinessState {
236    /// Creates a shared readiness state from an initial startup snapshot.
237    #[must_use]
238    pub fn new(initial: ReadinessState) -> Self {
239        Self {
240            inner: Arc::new(ReadinessFlags::from_state(initial)),
241        }
242    }
243
244    /// Returns a consistent snapshot of the current readiness flags.
245    #[must_use]
246    pub fn snapshot(&self) -> ReadinessState {
247        let cluster = if self.inner.cluster_configured.load(Ordering::SeqCst) {
248            ClusterReadiness::Configured {
249                membership_established: self
250                    .inner
251                    .cluster_membership_established
252                    .load(Ordering::SeqCst),
253            }
254        } else {
255            ClusterReadiness::NotConfigured
256        };
257
258        ReadinessState::with_admission(
259            self.inner.config_loaded.load(Ordering::SeqCst),
260            self.inner.listener_bound.load(Ordering::SeqCst),
261            cluster,
262            self.inner
263                .admission
264                .get()
265                .is_none_or(AdmissionReadiness::is_available),
266        )
267    }
268
269    /// Installs the admission-availability source readiness reports on.
270    ///
271    /// Called once, after the connection supervisor is built, because the
272    /// authority that owns the answer does not exist before then. A second call
273    /// is a no-op rather than a silent rebind.
274    pub fn track_admission(&self, admission: AdmissionReadiness) {
275        let _ = self.inner.admission.set(admission);
276    }
277
278    /// Updates whether configuration loading and validation completed.
279    pub fn set_config_loaded(&self, loaded: bool) {
280        self.inner.config_loaded.store(loaded, Ordering::SeqCst);
281    }
282
283    /// Updates whether the main wire protocol listener is bound.
284    pub fn set_listener_bound(&self, bound: bool) {
285        self.inner.listener_bound.store(bound, Ordering::SeqCst);
286    }
287
288    /// Updates whether cluster configuration is present.
289    pub fn set_cluster_configured(&self, configured: bool) {
290        self.inner
291            .cluster_configured
292            .store(configured, Ordering::SeqCst);
293        if !configured {
294            self.set_cluster_membership_established(false);
295        }
296    }
297
298    /// Updates whether clustered membership is established.
299    pub fn set_cluster_membership_established(&self, established: bool) {
300        self.inner
301            .cluster_membership_established
302            .store(established, Ordering::SeqCst);
303    }
304}
305
306impl Default for SharedReadinessState {
307    fn default() -> Self {
308        Self::new(ReadinessState::default())
309    }
310}
311
312#[derive(Debug)]
313struct ReadinessFlags {
314    config_loaded: AtomicBool,
315    listener_bound: AtomicBool,
316    cluster_configured: AtomicBool,
317    cluster_membership_established: AtomicBool,
318    /// Installed once, after the connection supervisor exists (P0 #56).
319    ///
320    /// A `OnceLock` rather than an `AtomicBool` because the authoritative flag
321    /// is OWNED by the incarnation authority — readiness reads it, it does not
322    /// keep its own copy that something would have to remember to update. An
323    /// uninstalled source reads as available, which is correct for a server
324    /// whose supervisor is not built yet: the health endpoint binds before the
325    /// supervisor exists so that liveness is answerable during startup.
326    admission: OnceLock<AdmissionReadiness>,
327}
328
329impl ReadinessFlags {
330    const fn from_state(state: ReadinessState) -> Self {
331        let (cluster_configured, cluster_membership_established) = match state.cluster {
332            ClusterReadiness::NotConfigured => (false, false),
333            ClusterReadiness::Configured {
334                membership_established,
335            } => (true, membership_established),
336        };
337
338        Self {
339            config_loaded: AtomicBool::new(state.config_loaded),
340            listener_bound: AtomicBool::new(state.listener_bound),
341            cluster_configured: AtomicBool::new(cluster_configured),
342            cluster_membership_established: AtomicBool::new(cluster_membership_established),
343            admission: OnceLock::new(),
344        }
345    }
346}
347
348/// Evaluates whether the server has completed every applicable startup gate.
349#[must_use]
350pub fn readiness_check(state: &ReadinessState) -> ReadinessStatus {
351    let mut unmet_conditions = Vec::new();
352
353    if !state.config_loaded {
354        unmet_conditions.push(ReadinessCondition::ConfigLoaded);
355    }
356
357    if !state.listener_bound {
358        unmet_conditions.push(ReadinessCondition::ListenerBound);
359    }
360
361    if state.cluster
362        == (ClusterReadiness::Configured {
363            membership_established: false,
364        })
365    {
366        unmet_conditions.push(ReadinessCondition::ClusterMembershipEstablished);
367    }
368
369    // P0 #56. Last in evaluation order because it is the only condition that can
370    // go unmet AFTER a successful startup: the other three are startup gates
371    // that, once met, stay met.
372    if !state.admission_available {
373        unmet_conditions.push(ReadinessCondition::AdmissionAvailable);
374    }
375
376    ReadinessStatus::from_unmet_conditions(unmet_conditions)
377}
378
379#[cfg(test)]
380mod tests {
381    use super::{
382        ClusterReadiness, HealthState, ReadinessCondition, ReadinessState, SharedReadinessState,
383        health_check, readiness_check,
384    };
385
386    #[test]
387    fn health_check_is_always_healthy_liveness() {
388        let status = health_check();
389
390        assert_eq!(status.status, HealthState::Healthy);
391        assert!(status.message.is_none());
392    }
393
394    #[test]
395    fn readiness_reports_missing_config() {
396        let state = ReadinessState::new(false, true, ClusterReadiness::NotConfigured);
397
398        let status = readiness_check(&state);
399
400        assert!(!status.ready);
401        assert_eq!(
402            status.unmet_conditions,
403            vec![ReadinessCondition::ConfigLoaded]
404        );
405    }
406
407    #[test]
408    fn readiness_reports_missing_listener() {
409        let state = ReadinessState::new(true, false, ClusterReadiness::NotConfigured);
410
411        let status = readiness_check(&state);
412
413        assert!(!status.ready);
414        assert_eq!(
415            status.unmet_conditions,
416            vec![ReadinessCondition::ListenerBound]
417        );
418    }
419
420    #[test]
421    fn readiness_requires_cluster_membership_when_configured() {
422        let state = ReadinessState::new(
423            true,
424            true,
425            ClusterReadiness::Configured {
426                membership_established: false,
427            },
428        );
429
430        let status = readiness_check(&state);
431
432        assert!(!status.ready);
433        assert_eq!(
434            status.unmet_conditions,
435            vec![ReadinessCondition::ClusterMembershipEstablished]
436        );
437    }
438
439    #[test]
440    fn readiness_ignores_cluster_membership_when_not_configured() {
441        let state = ReadinessState::ready_without_cluster();
442
443        let status = readiness_check(&state);
444
445        assert!(status.ready);
446        assert!(status.unmet_conditions.is_empty());
447    }
448
449    #[test]
450    fn readiness_is_ready_only_when_all_applicable_conditions_are_met() {
451        let state = ReadinessState::ready_with_cluster();
452
453        let status = readiness_check(&state);
454
455        assert!(status.ready);
456        assert!(status.unmet_conditions.is_empty());
457    }
458
459    #[test]
460    fn shared_readiness_state_snapshots_updates() {
461        let shared = SharedReadinessState::default();
462        shared.set_config_loaded(true);
463        shared.set_listener_bound(true);
464        shared.set_cluster_configured(true);
465        shared.set_cluster_membership_established(true);
466
467        assert_eq!(shared.snapshot(), ReadinessState::ready_with_cluster());
468    }
469}