Skip to main content

saddle_runtime/
request.rs

1use std::sync::{
2    Arc,
3    atomic::{AtomicUsize, Ordering},
4};
5
6use saddle_core::{ErrorKind, Result, SaddleError};
7use tokio::sync::Notify;
8
9/// The externally visible phase of the Saddle application lifecycle.
10#[derive(Clone, Copy, Debug, Eq, PartialEq)]
11pub enum ApplicationPhase {
12    Starting = 0,
13    Ready = 1,
14    Draining = 2,
15    Stopped = 3,
16}
17
18/// Read-only process health observer backed by the application's unique
19/// request-lifecycle state. It carries no request-admission authority.
20#[derive(Clone, Debug)]
21pub struct ApplicationHealth {
22    shared: Arc<Shared>,
23}
24
25/// One atomic view of process liveness and readiness.
26///
27/// Liveness remains true while the managed runtime can complete startup or
28/// drain admitted work. Readiness is true only after all components have
29/// started and becomes false before shutdown begins stopping components.
30#[derive(Clone, Copy, Debug, Eq, PartialEq)]
31pub struct ApplicationHealthSnapshot {
32    phase: ApplicationPhase,
33}
34
35impl ApplicationHealthSnapshot {
36    pub const fn phase(self) -> ApplicationPhase {
37        self.phase
38    }
39
40    pub const fn is_live(self) -> bool {
41        !matches!(self.phase, ApplicationPhase::Stopped)
42    }
43
44    pub const fn is_ready(self) -> bool {
45        matches!(self.phase, ApplicationPhase::Ready)
46    }
47}
48
49impl ApplicationHealth {
50    /// Reads phase, liveness and readiness from one atomic lifecycle value.
51    pub fn snapshot(&self) -> ApplicationHealthSnapshot {
52        ApplicationHealthSnapshot {
53            phase: phase(self.shared.state.load(Ordering::Acquire)),
54        }
55    }
56}
57
58const PHASE_SHIFT: u32 = usize::BITS - 2;
59const COUNT_MASK: usize = (1 << PHASE_SHIFT) - 1;
60
61#[derive(Debug)]
62struct Shared {
63    /// The top two bits hold `ApplicationPhase`; the remaining bits hold the
64    /// number of admitted requests. Updating both in one CAS closes the race
65    /// between request admission and the transition to draining.
66    state: AtomicUsize,
67    drained: Notify,
68}
69
70/// Coordinates request admission with graceful application shutdown.
71///
72/// Service adapters must acquire a [`RequestGuard`] before dispatching an
73/// accepted request. Once shutdown begins, new acquisitions fail while guards
74/// already issued remain valid until the corresponding request completes.
75#[derive(Clone, Debug)]
76pub struct RequestLifecycle {
77    shared: Arc<Shared>,
78}
79
80impl RequestLifecycle {
81    pub(crate) fn new() -> Self {
82        Self {
83            shared: Arc::new(Shared {
84                state: AtomicUsize::new(encode(ApplicationPhase::Starting, 0)),
85                drained: Notify::new(),
86            }),
87        }
88    }
89
90    /// Returns the application's current lifecycle phase.
91    pub fn phase(&self) -> ApplicationPhase {
92        phase(self.shared.state.load(Ordering::Acquire))
93    }
94
95    pub(crate) fn health(&self) -> ApplicationHealth {
96        ApplicationHealth {
97            shared: Arc::clone(&self.shared),
98        }
99    }
100
101    /// Admits one request if the application is ready and accepting work.
102    ///
103    /// The returned guard must be held for the complete request execution. Its
104    /// `Drop` implementation records completion even when the request future is
105    /// cancelled or unwinds.
106    pub fn try_accept(&self) -> Result<RequestGuard> {
107        self.try_claim().map(RequestClaim::publish)
108    }
109
110    /// Claims one request slot without publishing executable work.
111    ///
112    /// Runtime holds this linear claim across Admission commitment and turns it
113    /// into a [`RequestGuard`] only when the owned envelope is ready to submit.
114    pub(crate) fn try_claim(&self) -> Result<RequestClaim> {
115        let mut current = self.shared.state.load(Ordering::Acquire);
116        loop {
117            match phase(current) {
118                ApplicationPhase::Ready => {
119                    if count(current) == COUNT_MASK {
120                        return Err(SaddleError::new(
121                            ErrorKind::Internal,
122                            "runtime.request_count_overflow",
123                            "request accounting capacity exhausted",
124                        ));
125                    }
126                    match self.shared.state.compare_exchange_weak(
127                        current,
128                        current + 1,
129                        Ordering::AcqRel,
130                        Ordering::Acquire,
131                    ) {
132                        Ok(_) => {
133                            return Ok(RequestClaim {
134                                shared: Some(Arc::clone(&self.shared)),
135                            });
136                        }
137                        Err(observed) => current = observed,
138                    }
139                }
140                ApplicationPhase::Starting => {
141                    return Err(SaddleError::new(
142                        ErrorKind::Unavailable,
143                        "runtime.not_ready",
144                        "application is not ready",
145                    ));
146                }
147                ApplicationPhase::Draining => {
148                    return Err(SaddleError::new(
149                        ErrorKind::Unavailable,
150                        "runtime.shutting_down",
151                        "application is shutting down",
152                    ));
153                }
154                ApplicationPhase::Stopped => {
155                    return Err(SaddleError::new(
156                        ErrorKind::Unavailable,
157                        "runtime.stopped",
158                        "application is stopped",
159                    ));
160                }
161            }
162        }
163    }
164
165    pub(crate) fn mark_ready(&self) {
166        let result = self.shared.state.compare_exchange(
167            encode(ApplicationPhase::Starting, 0),
168            encode(ApplicationPhase::Ready, 0),
169            Ordering::AcqRel,
170            Ordering::Acquire,
171        );
172        debug_assert!(result.is_ok());
173    }
174
175    pub(crate) fn begin_draining(&self) {
176        let mut current = self.shared.state.load(Ordering::Acquire);
177        while matches!(
178            phase(current),
179            ApplicationPhase::Starting | ApplicationPhase::Ready
180        ) {
181            let draining = encode(ApplicationPhase::Draining, count(current));
182            match self.shared.state.compare_exchange_weak(
183                current,
184                draining,
185                Ordering::AcqRel,
186                Ordering::Acquire,
187            ) {
188                Ok(_) => {
189                    current = draining;
190                    break;
191                }
192                Err(observed) => current = observed,
193            }
194        }
195        if count(current) == 0 {
196            self.shared.drained.notify_one();
197        }
198    }
199
200    pub(crate) async fn wait_until_drained(&self) {
201        loop {
202            if count(self.shared.state.load(Ordering::Acquire)) == 0 {
203                return;
204            }
205            self.shared.drained.notified().await;
206        }
207    }
208
209    pub(crate) fn mark_stopped(&self) {
210        let result = self.shared.state.compare_exchange(
211            encode(ApplicationPhase::Draining, 0),
212            encode(ApplicationPhase::Stopped, 0),
213            Ordering::AcqRel,
214            Ordering::Acquire,
215        );
216        debug_assert!(result.is_ok());
217    }
218}
219
220const fn encode(phase: ApplicationPhase, count: usize) -> usize {
221    ((phase as usize) << PHASE_SHIFT) | count
222}
223
224const fn phase(state: usize) -> ApplicationPhase {
225    match state >> PHASE_SHIFT {
226        0 => ApplicationPhase::Starting,
227        1 => ApplicationPhase::Ready,
228        2 => ApplicationPhase::Draining,
229        3 => ApplicationPhase::Stopped,
230        _ => unreachable!(),
231    }
232}
233
234const fn count(state: usize) -> usize {
235    state & COUNT_MASK
236}
237
238/// An unpublished lifecycle claim held only by Runtime.
239#[derive(Debug)]
240pub(crate) struct RequestClaim {
241    shared: Option<Arc<Shared>>,
242}
243
244impl RequestClaim {
245    pub(crate) fn publish(mut self) -> RequestGuard {
246        RequestGuard {
247            shared: self.shared.take().expect("request claim publishes once"),
248        }
249    }
250}
251
252impl Drop for RequestClaim {
253    fn drop(&mut self) {
254        if let Some(shared) = self.shared.take() {
255            complete(&shared);
256        }
257    }
258}
259
260/// Proof that one request was admitted by Saddle before shutdown began.
261#[derive(Debug)]
262pub struct RequestGuard {
263    shared: Arc<Shared>,
264}
265
266impl Drop for RequestGuard {
267    fn drop(&mut self) {
268        complete(&self.shared);
269    }
270}
271
272fn complete(shared: &Shared) {
273    let previous = shared.state.fetch_sub(1, Ordering::AcqRel);
274    debug_assert!(count(previous) > 0);
275    if count(previous) == 1 && phase(previous) == ApplicationPhase::Draining {
276        shared.drained.notify_one();
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use std::sync::{Arc, Barrier};
283
284    use super::*;
285
286    fn test_runtime() -> tokio::runtime::Runtime {
287        tokio::runtime::Builder::new_current_thread()
288            .build()
289            .expect("test runtime must build")
290    }
291
292    #[test]
293    fn only_ready_applications_accept_requests() {
294        let requests = RequestLifecycle::new();
295        assert_eq!(
296            requests.try_accept().unwrap_err().code(),
297            "runtime.not_ready"
298        );
299
300        requests.mark_ready();
301        let request = requests.try_accept().expect("ready request is admitted");
302        requests.begin_draining();
303
304        assert_eq!(requests.phase(), ApplicationPhase::Draining);
305        assert_eq!(
306            requests.try_accept().unwrap_err().code(),
307            "runtime.shutting_down"
308        );
309
310        drop(request);
311        test_runtime().block_on(requests.wait_until_drained());
312        requests.mark_stopped();
313        assert_eq!(requests.phase(), ApplicationPhase::Stopped);
314    }
315
316    #[test]
317    fn draining_waits_for_every_admitted_request() {
318        test_runtime().block_on(async {
319            let requests = RequestLifecycle::new();
320            requests.mark_ready();
321            let first = requests.try_accept().unwrap();
322            let second = requests.try_accept().unwrap();
323            requests.begin_draining();
324
325            let requests_for_waiter = requests.clone();
326            let waiter = tokio::spawn(async move {
327                requests_for_waiter.wait_until_drained().await;
328            });
329            tokio::task::yield_now().await;
330            assert!(!waiter.is_finished());
331
332            drop(first);
333            tokio::task::yield_now().await;
334            assert!(!waiter.is_finished());
335
336            drop(second);
337            waiter.await.unwrap();
338        });
339    }
340
341    #[test]
342    fn admission_racing_with_drain_never_leaks_a_request() {
343        const WORKERS: usize = 8;
344        let requests = RequestLifecycle::new();
345        requests.mark_ready();
346        let barrier = Arc::new(Barrier::new(WORKERS + 1));
347        let workers: Vec<_> = (0..WORKERS)
348            .map(|_| {
349                let requests = requests.clone();
350                let barrier = Arc::clone(&barrier);
351                std::thread::spawn(move || {
352                    let admitted_before_drain = requests.try_accept().unwrap();
353                    barrier.wait();
354                    loop {
355                        match requests.try_accept() {
356                            Ok(request) => drop(request),
357                            Err(error) => {
358                                assert_eq!(error.code(), "runtime.shutting_down");
359                                drop(admitted_before_drain);
360                                break;
361                            }
362                        }
363                    }
364                })
365            })
366            .collect();
367
368        barrier.wait();
369        requests.begin_draining();
370        for worker in workers {
371            worker.join().unwrap();
372        }
373        test_runtime().block_on(requests.wait_until_drained());
374        assert_eq!(count(requests.shared.state.load(Ordering::Acquire)), 0);
375    }
376
377    #[test]
378    fn guard_completion_is_safe_from_another_thread() {
379        let requests = RequestLifecycle::new();
380        requests.mark_ready();
381        let guard = requests.try_accept().unwrap();
382        requests.begin_draining();
383
384        std::thread::spawn(move || drop(guard)).join().unwrap();
385        test_runtime().block_on(requests.wait_until_drained());
386
387        // Keep this assertion explicit: the thread above is runtime-internal
388        // test coverage, not a business-facing execution API.
389        assert_eq!(Arc::strong_count(&requests.shared), 1);
390    }
391}