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
18const PHASE_SHIFT: u32 = usize::BITS - 2;
19const COUNT_MASK: usize = (1 << PHASE_SHIFT) - 1;
20
21#[derive(Debug)]
22struct Shared {
23    /// The top two bits hold `ApplicationPhase`; the remaining bits hold the
24    /// number of admitted requests. Updating both in one CAS closes the race
25    /// between request admission and the transition to draining.
26    state: AtomicUsize,
27    drained: Notify,
28}
29
30/// Coordinates request admission with graceful application shutdown.
31///
32/// Service adapters must acquire a [`RequestGuard`] before dispatching an
33/// accepted request. Once shutdown begins, new acquisitions fail while guards
34/// already issued remain valid until the corresponding request completes.
35#[derive(Clone, Debug)]
36pub struct RequestLifecycle {
37    shared: Arc<Shared>,
38}
39
40impl RequestLifecycle {
41    pub(crate) fn new() -> Self {
42        Self {
43            shared: Arc::new(Shared {
44                state: AtomicUsize::new(encode(ApplicationPhase::Starting, 0)),
45                drained: Notify::new(),
46            }),
47        }
48    }
49
50    /// Returns the application's current lifecycle phase.
51    pub fn phase(&self) -> ApplicationPhase {
52        phase(self.shared.state.load(Ordering::Acquire))
53    }
54
55    /// Admits one request if the application is ready and accepting work.
56    ///
57    /// The returned guard must be held for the complete request execution. Its
58    /// `Drop` implementation records completion even when the request future is
59    /// cancelled or unwinds.
60    pub fn try_accept(&self) -> Result<RequestGuard> {
61        let mut current = self.shared.state.load(Ordering::Acquire);
62        loop {
63            match phase(current) {
64                ApplicationPhase::Ready => {
65                    if count(current) == COUNT_MASK {
66                        return Err(SaddleError::new(
67                            ErrorKind::Internal,
68                            "runtime.request_count_overflow",
69                            "request accounting capacity exhausted",
70                        ));
71                    }
72                    match self.shared.state.compare_exchange_weak(
73                        current,
74                        current + 1,
75                        Ordering::AcqRel,
76                        Ordering::Acquire,
77                    ) {
78                        Ok(_) => {
79                            return Ok(RequestGuard {
80                                shared: Arc::clone(&self.shared),
81                            });
82                        }
83                        Err(observed) => current = observed,
84                    }
85                }
86                ApplicationPhase::Starting => {
87                    return Err(SaddleError::new(
88                        ErrorKind::Unavailable,
89                        "runtime.not_ready",
90                        "application is not ready",
91                    ));
92                }
93                ApplicationPhase::Draining => {
94                    return Err(SaddleError::new(
95                        ErrorKind::Unavailable,
96                        "runtime.shutting_down",
97                        "application is shutting down",
98                    ));
99                }
100                ApplicationPhase::Stopped => {
101                    return Err(SaddleError::new(
102                        ErrorKind::Unavailable,
103                        "runtime.stopped",
104                        "application is stopped",
105                    ));
106                }
107            }
108        }
109    }
110
111    pub(crate) fn mark_ready(&self) {
112        let result = self.shared.state.compare_exchange(
113            encode(ApplicationPhase::Starting, 0),
114            encode(ApplicationPhase::Ready, 0),
115            Ordering::AcqRel,
116            Ordering::Acquire,
117        );
118        debug_assert!(result.is_ok());
119    }
120
121    pub(crate) fn begin_draining(&self) {
122        let mut current = self.shared.state.load(Ordering::Acquire);
123        while matches!(
124            phase(current),
125            ApplicationPhase::Starting | ApplicationPhase::Ready
126        ) {
127            let draining = encode(ApplicationPhase::Draining, count(current));
128            match self.shared.state.compare_exchange_weak(
129                current,
130                draining,
131                Ordering::AcqRel,
132                Ordering::Acquire,
133            ) {
134                Ok(_) => {
135                    current = draining;
136                    break;
137                }
138                Err(observed) => current = observed,
139            }
140        }
141        if count(current) == 0 {
142            self.shared.drained.notify_one();
143        }
144    }
145
146    pub(crate) async fn wait_until_drained(&self) {
147        loop {
148            if count(self.shared.state.load(Ordering::Acquire)) == 0 {
149                return;
150            }
151            self.shared.drained.notified().await;
152        }
153    }
154
155    pub(crate) fn mark_stopped(&self) {
156        let result = self.shared.state.compare_exchange(
157            encode(ApplicationPhase::Draining, 0),
158            encode(ApplicationPhase::Stopped, 0),
159            Ordering::AcqRel,
160            Ordering::Acquire,
161        );
162        debug_assert!(result.is_ok());
163    }
164}
165
166const fn encode(phase: ApplicationPhase, count: usize) -> usize {
167    ((phase as usize) << PHASE_SHIFT) | count
168}
169
170const fn phase(state: usize) -> ApplicationPhase {
171    match state >> PHASE_SHIFT {
172        0 => ApplicationPhase::Starting,
173        1 => ApplicationPhase::Ready,
174        2 => ApplicationPhase::Draining,
175        3 => ApplicationPhase::Stopped,
176        _ => unreachable!(),
177    }
178}
179
180const fn count(state: usize) -> usize {
181    state & COUNT_MASK
182}
183
184/// Proof that one request was admitted by Saddle before shutdown began.
185#[derive(Debug)]
186pub struct RequestGuard {
187    shared: Arc<Shared>,
188}
189
190impl Drop for RequestGuard {
191    fn drop(&mut self) {
192        let previous = self.shared.state.fetch_sub(1, Ordering::AcqRel);
193        debug_assert!(count(previous) > 0);
194        if count(previous) == 1 && phase(previous) == ApplicationPhase::Draining {
195            self.shared.drained.notify_one();
196        }
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use std::sync::{Arc, Barrier};
203
204    use super::*;
205
206    fn test_runtime() -> tokio::runtime::Runtime {
207        tokio::runtime::Builder::new_current_thread()
208            .build()
209            .expect("test runtime must build")
210    }
211
212    #[test]
213    fn only_ready_applications_accept_requests() {
214        let requests = RequestLifecycle::new();
215        assert_eq!(
216            requests.try_accept().unwrap_err().code(),
217            "runtime.not_ready"
218        );
219
220        requests.mark_ready();
221        let request = requests.try_accept().expect("ready request is admitted");
222        requests.begin_draining();
223
224        assert_eq!(requests.phase(), ApplicationPhase::Draining);
225        assert_eq!(
226            requests.try_accept().unwrap_err().code(),
227            "runtime.shutting_down"
228        );
229
230        drop(request);
231        test_runtime().block_on(requests.wait_until_drained());
232        requests.mark_stopped();
233        assert_eq!(requests.phase(), ApplicationPhase::Stopped);
234    }
235
236    #[test]
237    fn draining_waits_for_every_admitted_request() {
238        test_runtime().block_on(async {
239            let requests = RequestLifecycle::new();
240            requests.mark_ready();
241            let first = requests.try_accept().unwrap();
242            let second = requests.try_accept().unwrap();
243            requests.begin_draining();
244
245            let requests_for_waiter = requests.clone();
246            let waiter = tokio::spawn(async move {
247                requests_for_waiter.wait_until_drained().await;
248            });
249            tokio::task::yield_now().await;
250            assert!(!waiter.is_finished());
251
252            drop(first);
253            tokio::task::yield_now().await;
254            assert!(!waiter.is_finished());
255
256            drop(second);
257            waiter.await.unwrap();
258        });
259    }
260
261    #[test]
262    fn admission_racing_with_drain_never_leaks_a_request() {
263        const WORKERS: usize = 8;
264        let requests = RequestLifecycle::new();
265        requests.mark_ready();
266        let barrier = Arc::new(Barrier::new(WORKERS + 1));
267        let workers: Vec<_> = (0..WORKERS)
268            .map(|_| {
269                let requests = requests.clone();
270                let barrier = Arc::clone(&barrier);
271                std::thread::spawn(move || {
272                    let admitted_before_drain = requests.try_accept().unwrap();
273                    barrier.wait();
274                    loop {
275                        match requests.try_accept() {
276                            Ok(request) => drop(request),
277                            Err(error) => {
278                                assert_eq!(error.code(), "runtime.shutting_down");
279                                drop(admitted_before_drain);
280                                break;
281                            }
282                        }
283                    }
284                })
285            })
286            .collect();
287
288        barrier.wait();
289        requests.begin_draining();
290        for worker in workers {
291            worker.join().unwrap();
292        }
293        test_runtime().block_on(requests.wait_until_drained());
294        assert_eq!(count(requests.shared.state.load(Ordering::Acquire)), 0);
295    }
296
297    #[test]
298    fn guard_completion_is_safe_from_another_thread() {
299        let requests = RequestLifecycle::new();
300        requests.mark_ready();
301        let guard = requests.try_accept().unwrap();
302        requests.begin_draining();
303
304        std::thread::spawn(move || drop(guard)).join().unwrap();
305        test_runtime().block_on(requests.wait_until_drained());
306
307        // Keep this assertion explicit: the thread above is runtime-internal
308        // test coverage, not a business-facing execution API.
309        assert_eq!(Arc::strong_count(&requests.shared), 1);
310    }
311}