Skip to main content

rullst_core/
lifecycle.rs

1//! Bounded process lifecycle, readiness, and graceful request draining.
2
3use axum::{
4    Router,
5    extract::{Request, State},
6    http::{StatusCode, header},
7    middleware::{Next, from_fn_with_state},
8    response::{IntoResponse, Response},
9};
10use std::collections::BTreeMap;
11use std::fmt;
12use std::sync::atomic::{AtomicU8, AtomicUsize, Ordering};
13use std::sync::{Arc, RwLock};
14use std::time::Duration;
15use tokio::sync::Notify;
16mod body;
17
18const PHASE_STARTING: u8 = 0;
19const PHASE_READY: u8 = 1;
20const PHASE_DRAINING: u8 = 2;
21const PHASE_STOPPED: u8 = 3;
22const MAX_REQUIRED_COMPONENTS: usize = 32;
23const MAX_COMPONENT_NAME_BYTES: usize = 64;
24const MAX_DRAIN_WAIT: Duration = Duration::from_secs(600);
25
26/// Monotonic phase of one Rullst application process.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
28#[serde(rename_all = "snake_case")]
29#[non_exhaustive]
30pub enum ApplicationPhase {
31    /// Startup is still in progress.
32    Starting,
33    /// Startup finished; readiness still depends on every required component.
34    Ready,
35    /// New application requests are rejected while accepted requests finish.
36    Draining,
37    /// The process lifecycle has ended.
38    Stopped,
39}
40
41impl ApplicationPhase {
42    fn from_u8(value: u8) -> Self {
43        match value {
44            PHASE_READY => Self::Ready,
45            PHASE_DRAINING => Self::Draining,
46            PHASE_STOPPED => Self::Stopped,
47            _ => Self::Starting,
48        }
49    }
50}
51
52/// Typed lifecycle construction, transition, and drain failures.
53#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
54#[non_exhaustive]
55pub enum ApplicationLifecycleError {
56    /// A readiness component label is empty, too long, or ambiguous.
57    #[error("invalid readiness component label `{0}`")]
58    InvalidComponentLabel(String),
59    /// A readiness component was registered more than once.
60    #[error("duplicate readiness component label `{0}`")]
61    DuplicateComponent(String),
62    /// The readiness component is not part of the immutable registry.
63    #[error("unknown readiness component label `{0}`")]
64    UnknownComponent(String),
65    /// Too many readiness components were requested.
66    #[error("readiness accepts at most {MAX_REQUIRED_COMPONENTS} required components")]
67    TooManyComponents,
68    /// A transition would move the lifecycle backwards.
69    #[error("cannot transition application lifecycle from {from:?} to {to:?}")]
70    InvalidTransition {
71        /// Current phase.
72        from: ApplicationPhase,
73        /// Requested phase.
74        to: ApplicationPhase,
75    },
76    /// The readiness registry lock was poisoned and is denied rather than trusted.
77    #[error("readiness component state is unavailable")]
78    StateUnavailable,
79    /// A drain wait must have a bounded, non-zero deadline.
80    #[error("drain wait must be greater than zero and at most 600 seconds")]
81    InvalidDrainWait,
82    /// Accepted requests remained active until the configured deadline.
83    #[error("graceful drain timed out with {in_flight} request(s) still active")]
84    DrainTimedOut {
85        /// Number of requests still executing when the deadline elapsed.
86        in_flight: usize,
87    },
88    /// The in-flight counter cannot safely accept another request.
89    #[error("in-flight request capacity is exhausted")]
90    RequestCapacityExhausted,
91    /// Startup, a dependency gate, or draining currently denies admission.
92    #[error("application phase {phase:?} is not accepting requests")]
93    RequestNotAdmitted {
94        /// Phase observed when admission was denied.
95        phase: ApplicationPhase,
96    },
97}
98
99struct LifecycleInner {
100    phase: AtomicU8,
101    in_flight: AtomicUsize,
102    components: RwLock<BTreeMap<String, bool>>,
103    drained: Notify,
104}
105
106/// Shared process-local readiness and graceful-drain coordinator.
107///
108/// Required component labels are immutable and intentionally absent from the
109/// public health payload. Applications update them only after performing their
110/// own authenticated dependency checks. This type does not discover services,
111/// coordinate replicas, or authorize domain requests.
112#[derive(Clone)]
113pub struct ApplicationLifecycle {
114    inner: Arc<LifecycleInner>,
115}
116
117impl fmt::Debug for ApplicationLifecycle {
118    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
119        let snapshot = self.snapshot();
120        formatter
121            .debug_struct("ApplicationLifecycle")
122            .field("phase", &snapshot.phase)
123            .field("required_components", &snapshot.required_components)
124            .field("unready_components", &snapshot.unready_components)
125            .field("in_flight_requests", &snapshot.in_flight_requests)
126            .finish_non_exhaustive()
127    }
128}
129
130impl Default for ApplicationLifecycle {
131    fn default() -> Self {
132        Self::new()
133    }
134}
135
136impl ApplicationLifecycle {
137    /// Creates a lifecycle in the `Starting` phase without required components.
138    pub fn new() -> Self {
139        Self {
140            inner: Arc::new(LifecycleInner {
141                phase: AtomicU8::new(PHASE_STARTING),
142                in_flight: AtomicUsize::new(0),
143                components: RwLock::new(BTreeMap::new()),
144                drained: Notify::new(),
145            }),
146        }
147    }
148
149    /// Creates a lifecycle with an immutable bounded set of required components.
150    pub fn with_required_components<I, S>(components: I) -> Result<Self, ApplicationLifecycleError>
151    where
152        I: IntoIterator<Item = S>,
153        S: Into<String>,
154    {
155        let mut required = BTreeMap::new();
156        for component in components {
157            if required.len() >= MAX_REQUIRED_COMPONENTS {
158                return Err(ApplicationLifecycleError::TooManyComponents);
159            }
160            let component = component.into();
161            validate_component_label(&component)?;
162            if required.insert(component.clone(), false).is_some() {
163                return Err(ApplicationLifecycleError::DuplicateComponent(component));
164            }
165        }
166
167        Ok(Self {
168            inner: Arc::new(LifecycleInner {
169                phase: AtomicU8::new(PHASE_STARTING),
170                in_flight: AtomicUsize::new(0),
171                components: RwLock::new(required),
172                drained: Notify::new(),
173            }),
174        })
175    }
176
177    /// Returns the current monotonic phase.
178    pub fn phase(&self) -> ApplicationPhase {
179        ApplicationPhase::from_u8(self.inner.phase.load(Ordering::Acquire))
180    }
181
182    /// Marks startup complete without overriding required component states.
183    pub fn mark_ready(&self) -> Result<(), ApplicationLifecycleError> {
184        match self.inner.phase.compare_exchange(
185            PHASE_STARTING,
186            PHASE_READY,
187            Ordering::AcqRel,
188            Ordering::Acquire,
189        ) {
190            Ok(_) | Err(PHASE_READY) => Ok(()),
191            Err(value) => Err(ApplicationLifecycleError::InvalidTransition {
192                from: ApplicationPhase::from_u8(value),
193                to: ApplicationPhase::Ready,
194            }),
195        }
196    }
197
198    /// Updates one pre-registered dependency readiness bit.
199    pub fn set_component_ready(
200        &self,
201        component: impl Into<String>,
202        ready: bool,
203    ) -> Result<(), ApplicationLifecycleError> {
204        let component = component.into();
205        validate_component_label(&component)?;
206        let mut components = self
207            .inner
208            .components
209            .write()
210            .map_err(|_| ApplicationLifecycleError::StateUnavailable)?;
211        let state = components
212            .get_mut(&component)
213            .ok_or_else(|| ApplicationLifecycleError::UnknownComponent(component.clone()))?;
214        *state = ready;
215        Ok(())
216    }
217
218    /// Begins a monotonic drain and rejects all subsequent application requests.
219    pub fn begin_draining(&self) -> Result<(), ApplicationLifecycleError> {
220        loop {
221            let current = self.inner.phase.load(Ordering::Acquire);
222            match current {
223                PHASE_DRAINING => return Ok(()),
224                PHASE_STOPPED => {
225                    return Err(ApplicationLifecycleError::InvalidTransition {
226                        from: ApplicationPhase::Stopped,
227                        to: ApplicationPhase::Draining,
228                    });
229                }
230                PHASE_STARTING | PHASE_READY => {
231                    if self
232                        .inner
233                        .phase
234                        .compare_exchange(
235                            current,
236                            PHASE_DRAINING,
237                            Ordering::AcqRel,
238                            Ordering::Acquire,
239                        )
240                        .is_ok()
241                    {
242                        if self.in_flight_requests() == 0 {
243                            self.inner.drained.notify_waiters();
244                        }
245                        return Ok(());
246                    }
247                }
248                _ => continue,
249            }
250        }
251    }
252
253    /// Marks the lifecycle stopped. This terminal transition is idempotent.
254    pub fn mark_stopped(&self) {
255        self.inner.phase.store(PHASE_STOPPED, Ordering::Release);
256        self.inner.drained.notify_waiters();
257    }
258
259    /// Returns a secret-minimized readiness snapshot.
260    pub fn snapshot(&self) -> ReadinessSnapshot {
261        let phase = self.phase();
262        let in_flight_requests = self.in_flight_requests();
263        let component_counts = self.inner.components.read().ok().map(|components| {
264            let unready = components.values().filter(|ready| !**ready).count();
265            (components.len(), unready)
266        });
267
268        let (required_components, unready_components, state_available) = match component_counts {
269            Some((required, unready)) => (required, unready, true),
270            None => (0, 0, false),
271        };
272        let ready = phase == ApplicationPhase::Ready && state_available && unready_components == 0;
273
274        ReadinessSnapshot {
275            phase,
276            ready,
277            state_available,
278            required_components,
279            unready_components,
280            in_flight_requests,
281        }
282    }
283
284    /// Returns admitted requests whose handler or HTTP response body is still active.
285    /// Upgraded connections and detached background work require separate tracking.
286    pub fn in_flight_requests(&self) -> usize {
287        self.inner.in_flight.load(Ordering::Acquire)
288    }
289
290    /// Waits for admitted handlers and response bodies to finish or be dropped.
291    /// This does not wait for client acknowledgement or upgraded connections.
292    pub async fn wait_for_drain(&self, timeout: Duration) -> Result<(), ApplicationLifecycleError> {
293        if timeout.is_zero() || timeout > MAX_DRAIN_WAIT {
294            return Err(ApplicationLifecycleError::InvalidDrainWait);
295        }
296        let phase = self.phase();
297        if !matches!(
298            phase,
299            ApplicationPhase::Draining | ApplicationPhase::Stopped
300        ) {
301            return Err(ApplicationLifecycleError::InvalidTransition {
302                from: phase,
303                to: ApplicationPhase::Draining,
304            });
305        }
306
307        let deadline = tokio::time::Instant::now() + timeout;
308        loop {
309            let notified = self.inner.drained.notified();
310            tokio::pin!(notified);
311            // Register before observing the counter so a final request cannot
312            // reach zero between the observation and waiter registration.
313            notified.as_mut().enable();
314            if self.in_flight_requests() == 0 {
315                return Ok(());
316            }
317            if tokio::time::timeout_at(deadline, notified).await.is_err() {
318                // A terminal notification racing the deadline still wins when
319                // the authoritative counter proves the drain completed.
320                if self.in_flight_requests() == 0 {
321                    return Ok(());
322                }
323                return Err(ApplicationLifecycleError::DrainTimedOut {
324                    in_flight: self.in_flight_requests(),
325                });
326            }
327        }
328    }
329
330    fn try_admit(&self) -> Result<ApplicationRequestGuard, ApplicationLifecycleError> {
331        loop {
332            if !self.snapshot().ready {
333                return Err(ApplicationLifecycleError::RequestNotAdmitted {
334                    phase: self.phase(),
335                });
336            }
337            let active = self.inner.in_flight.load(Ordering::Acquire);
338            let next = active
339                .checked_add(1)
340                .ok_or(ApplicationLifecycleError::RequestCapacityExhausted)?;
341            if self
342                .inner
343                .in_flight
344                .compare_exchange(active, next, Ordering::AcqRel, Ordering::Acquire)
345                .is_err()
346            {
347                continue;
348            }
349            if self.snapshot().ready {
350                return Ok(ApplicationRequestGuard {
351                    inner: Arc::clone(&self.inner),
352                });
353            }
354            release_request(&self.inner);
355            return Err(ApplicationLifecycleError::RequestNotAdmitted {
356                phase: self.phase(),
357            });
358        }
359    }
360}
361
362/// Bounded readiness counters safe for public orchestration probes.
363#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
364#[non_exhaustive]
365pub struct ReadinessSnapshot {
366    /// Current process phase.
367    pub phase: ApplicationPhase,
368    /// Whether the process accepts application requests.
369    pub ready: bool,
370    /// Whether component state could be read without lock corruption.
371    pub state_available: bool,
372    /// Number of immutable required component slots.
373    pub required_components: usize,
374    /// Number of required components currently marked unavailable.
375    pub unready_components: usize,
376    /// Number of admitted handlers or ordinary HTTP response bodies still active.
377    pub in_flight_requests: usize,
378}
379
380struct ApplicationRequestGuard {
381    inner: Arc<LifecycleInner>,
382}
383
384impl Drop for ApplicationRequestGuard {
385    fn drop(&mut self) {
386        release_request(&self.inner);
387    }
388}
389
390fn release_request(inner: &LifecycleInner) {
391    let previous = inner.in_flight.fetch_sub(1, Ordering::AcqRel);
392    if previous == 1 && inner.phase.load(Ordering::Acquire) >= PHASE_DRAINING {
393        inner.drained.notify_waiters();
394    }
395}
396
397fn validate_component_label(component: &str) -> Result<(), ApplicationLifecycleError> {
398    if component.is_empty()
399        || component.len() > MAX_COMPONENT_NAME_BYTES
400        || !component
401            .bytes()
402            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
403    {
404        return Err(ApplicationLifecycleError::InvalidComponentLabel(
405            component.to_string(),
406        ));
407    }
408    Ok(())
409}
410
411/// Applies lifecycle admission to application routes.
412///
413/// Exact `GET`/`HEAD /health` and `/ready` probes bypass admission so an
414/// orchestrator can observe startup and drain state. All other requests receive
415/// a bounded `503` once startup/dependency readiness is false or draining starts.
416pub fn apply_lifecycle(router: Router, lifecycle: ApplicationLifecycle) -> Router {
417    router.layer(from_fn_with_state(lifecycle, lifecycle_middleware))
418}
419
420/// Reports whether `request` is an exact `GET`/`HEAD /health` or `/ready` probe.
421///
422/// Lifecycle admission, the server's rate limiter and its Traffic Shield all
423/// let these probes through so orchestrators observe the process itself.
424pub(crate) fn is_health_probe<B>(request: &axum::http::Request<B>) -> bool {
425    matches!(
426        request.method(),
427        &axum::http::Method::GET | &axum::http::Method::HEAD
428    ) && matches!(request.uri().path(), "/health" | "/ready")
429}
430
431async fn lifecycle_middleware(
432    State(lifecycle): State<ApplicationLifecycle>,
433    request: Request,
434    next: Next,
435) -> Response {
436    if is_health_probe(&request) {
437        return next.run(request).await;
438    }
439
440    match lifecycle.try_admit() {
441        Ok(guard) => body::track(next.run(request).await, guard),
442        Err(_) => (
443            StatusCode::SERVICE_UNAVAILABLE,
444            [
445                (header::CACHE_CONTROL, "no-store"),
446                (header::RETRY_AFTER, "1"),
447                (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
448            ],
449            "Service is not ready to accept application requests.",
450        )
451            .into_response(),
452    }
453}
454
455#[cfg(test)]
456#[path = "lifecycle_tests.rs"]
457pub(crate) mod tests;