arcature 2026.2.0

Arcature application framework: a high-level Application facade over the certified Arcature subsystems, with the low-level Axum/Tower escape hatch preserved.
Documentation
//! Application-registered readiness gates (AP2.1-10).
//!
//! [`ReadinessCheck`] is the seam an application uses to declare "my required
//! dependencies are ready" — beyond the engine's own startup. The engine
//! sets the lifecycle state to [`Ready`](super::state::LifecycleState::Ready)
//! once subsystems connect, but a production app often wants to gate
//! readiness on an additional condition (a warmup query, a cache primed, a
//! feature flag fetched, a migration-compatibility probe). The application
//! registers one or more [`ReadinessCheck`] closures via
//! [`Lifecycle::register_readiness`](super::Lifecycle::register_readiness)
//! inside its `state_fn`; the `/up/ready` endpoint returns 200 only when the
//! state is `Ready` **and** every registered check returns `true`
//! (PROGRAM.md AP2.1-10: "Readiness must NOT become true before required app
//! dependencies are ready").
//!
//! # Design
//!
//! A check is `Arc<dyn Fn() -> bool + Send + Sync>` — a cheap, clonable,
//! synchronous predicate. It is *not* async: the health endpoint is on the
//! hot path and must not block the runtime on a per-request `await`. A check
//! that needs async work (a warmup query) performs that work in a background
//! task and flips an `AtomicBool` the check reads synchronously. This keeps
//! the health endpoint latency bounded and the readiness decision O(1).
//!
//! The checks are stored in a `Vec` behind an [`ArcSwap`]-free design: the
//! list is built during startup (the `state_fn` runs once, before serving)
//! and read concurrently afterward. A [`std::sync::RwLock`] guards the list
//! — writes happen only at startup (a handful of `register_readiness`
//! calls), reads happen on every `/up/ready` request. The read lock is
//! cheap and uncontended after startup. No global mutable state: the
//! [`ReadinessChecks`] instance lives inside the [`Lifecycle`](super::Lifecycle)
//! handle, which is request-owned (passed in `state_fn`, cloned into
//! `AppState`), never a process-global singleton (AGENTS.md §20).

use std::sync::Arc;

/// A synchronous readiness predicate registered by the application.
///
/// Returns `true` when the dependency the check represents is ready. The
/// `/up/ready` endpoint returns 200 only when the lifecycle state is `Ready`
/// and *every* registered check returns `true`.
///
/// Checks must be cheap and non-blocking (see the module docs): perform any
/// async warmup in a background task and read an `AtomicBool` here.
pub type ReadinessCheck = Arc<dyn Fn() -> bool + Send + Sync>;

/// The collection of readiness checks for one [`Lifecycle`](super::Lifecycle).
///
/// Built during startup (the application's `state_fn` calls
/// `register_readiness`); read on every `/up/ready` request. The read path
/// takes a read lock; after startup the lock is uncontended.
#[derive(Default)]
pub(crate) struct ReadinessChecks {
    checks: std::sync::RwLock<Vec<ReadinessCheck>>,
}

impl ReadinessChecks {
    /// Register a readiness check. Called from the application's `state_fn`
    /// during startup (before the lifecycle is marked `Ready`). Registering
    /// after serving begins is allowed but races the first `/up/ready`
    /// reads — prefer registering in `state_fn`.
    pub(crate) fn register(&self, check: ReadinessCheck) {
        let mut guard = self
            .checks
            .write()
            .expect("readiness-checks lock poisoned: a registration task panicked");
        guard.push(check);
    }

    /// `true` iff every registered check returns `true`. An empty collection
    /// returns `true` (an app with no extra gates is ready as soon as the
    /// engine is). A poisoned lock returns `false` — a panicked check task
    /// must not make an unready app appear ready (fail-closed).
    pub(crate) fn all_pass(&self) -> bool {
        let guard = self.checks.read();
        match guard {
            Ok(checks) => checks.iter().all(|check| check()),
            Err(_) => false,
        }
    }

    /// The number of registered checks (diagnostics / tests).
    #[cfg(test)]
    pub(crate) fn len(&self) -> usize {
        self.checks.read().map(|g| g.len()).unwrap_or(0)
    }
}

#[cfg(test)]
mod tests {
    use super::ReadinessChecks;
    use std::sync::Arc;
    use std::sync::atomic::{AtomicBool, Ordering};

    #[test]
    fn empty_checks_are_ready() {
        let checks = ReadinessChecks::default();
        assert!(checks.all_pass());
        assert_eq!(checks.len(), 0);
    }

    #[test]
    fn all_checks_must_pass() {
        let checks = ReadinessChecks::default();
        let a = Arc::new(AtomicBool::new(true));
        let b = Arc::new(AtomicBool::new(false));
        let a_for_check = a.clone();
        let b_for_check = b.clone();
        checks.register(Arc::new(move || a_for_check.load(Ordering::SeqCst)));
        checks.register(Arc::new(move || b_for_check.load(Ordering::SeqCst)));
        // b is false → not ready.
        assert!(!checks.all_pass());
        // Flip b true → ready.
        b.store(true, Ordering::SeqCst);
        assert!(checks.all_pass());
        // Flip a false → not ready again.
        a.store(false, Ordering::SeqCst);
        assert!(!checks.all_pass());
    }
}