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        self.try_claim().map(RequestClaim::publish)
62    }
63
64    /// Claims one request slot without publishing executable work.
65    ///
66    /// Runtime holds this linear claim across Admission commitment and turns it
67    /// into a [`RequestGuard`] only when the owned envelope is ready to submit.
68    pub(crate) fn try_claim(&self) -> Result<RequestClaim> {
69        let mut current = self.shared.state.load(Ordering::Acquire);
70        loop {
71            match phase(current) {
72                ApplicationPhase::Ready => {
73                    if count(current) == COUNT_MASK {
74                        return Err(SaddleError::new(
75                            ErrorKind::Internal,
76                            "runtime.request_count_overflow",
77                            "request accounting capacity exhausted",
78                        ));
79                    }
80                    match self.shared.state.compare_exchange_weak(
81                        current,
82                        current + 1,
83                        Ordering::AcqRel,
84                        Ordering::Acquire,
85                    ) {
86                        Ok(_) => {
87                            return Ok(RequestClaim {
88                                shared: Some(Arc::clone(&self.shared)),
89                            });
90                        }
91                        Err(observed) => current = observed,
92                    }
93                }
94                ApplicationPhase::Starting => {
95                    return Err(SaddleError::new(
96                        ErrorKind::Unavailable,
97                        "runtime.not_ready",
98                        "application is not ready",
99                    ));
100                }
101                ApplicationPhase::Draining => {
102                    return Err(SaddleError::new(
103                        ErrorKind::Unavailable,
104                        "runtime.shutting_down",
105                        "application is shutting down",
106                    ));
107                }
108                ApplicationPhase::Stopped => {
109                    return Err(SaddleError::new(
110                        ErrorKind::Unavailable,
111                        "runtime.stopped",
112                        "application is stopped",
113                    ));
114                }
115            }
116        }
117    }
118
119    pub(crate) fn mark_ready(&self) {
120        let result = self.shared.state.compare_exchange(
121            encode(ApplicationPhase::Starting, 0),
122            encode(ApplicationPhase::Ready, 0),
123            Ordering::AcqRel,
124            Ordering::Acquire,
125        );
126        debug_assert!(result.is_ok());
127    }
128
129    pub(crate) fn begin_draining(&self) {
130        let mut current = self.shared.state.load(Ordering::Acquire);
131        while matches!(
132            phase(current),
133            ApplicationPhase::Starting | ApplicationPhase::Ready
134        ) {
135            let draining = encode(ApplicationPhase::Draining, count(current));
136            match self.shared.state.compare_exchange_weak(
137                current,
138                draining,
139                Ordering::AcqRel,
140                Ordering::Acquire,
141            ) {
142                Ok(_) => {
143                    current = draining;
144                    break;
145                }
146                Err(observed) => current = observed,
147            }
148        }
149        if count(current) == 0 {
150            self.shared.drained.notify_one();
151        }
152    }
153
154    pub(crate) async fn wait_until_drained(&self) {
155        loop {
156            if count(self.shared.state.load(Ordering::Acquire)) == 0 {
157                return;
158            }
159            self.shared.drained.notified().await;
160        }
161    }
162
163    pub(crate) fn mark_stopped(&self) {
164        let result = self.shared.state.compare_exchange(
165            encode(ApplicationPhase::Draining, 0),
166            encode(ApplicationPhase::Stopped, 0),
167            Ordering::AcqRel,
168            Ordering::Acquire,
169        );
170        debug_assert!(result.is_ok());
171    }
172}
173
174const fn encode(phase: ApplicationPhase, count: usize) -> usize {
175    ((phase as usize) << PHASE_SHIFT) | count
176}
177
178const fn phase(state: usize) -> ApplicationPhase {
179    match state >> PHASE_SHIFT {
180        0 => ApplicationPhase::Starting,
181        1 => ApplicationPhase::Ready,
182        2 => ApplicationPhase::Draining,
183        3 => ApplicationPhase::Stopped,
184        _ => unreachable!(),
185    }
186}
187
188const fn count(state: usize) -> usize {
189    state & COUNT_MASK
190}
191
192/// An unpublished lifecycle claim held only by Runtime.
193#[derive(Debug)]
194pub(crate) struct RequestClaim {
195    shared: Option<Arc<Shared>>,
196}
197
198impl RequestClaim {
199    pub(crate) fn publish(mut self) -> RequestGuard {
200        RequestGuard {
201            shared: self.shared.take().expect("request claim publishes once"),
202        }
203    }
204}
205
206impl Drop for RequestClaim {
207    fn drop(&mut self) {
208        if let Some(shared) = self.shared.take() {
209            complete(&shared);
210        }
211    }
212}
213
214/// Proof that one request was admitted by Saddle before shutdown began.
215#[derive(Debug)]
216pub struct RequestGuard {
217    shared: Arc<Shared>,
218}
219
220impl Drop for RequestGuard {
221    fn drop(&mut self) {
222        complete(&self.shared);
223    }
224}
225
226fn complete(shared: &Shared) {
227    let previous = shared.state.fetch_sub(1, Ordering::AcqRel);
228    debug_assert!(count(previous) > 0);
229    if count(previous) == 1 && phase(previous) == ApplicationPhase::Draining {
230        shared.drained.notify_one();
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use std::sync::{Arc, Barrier};
237
238    use super::*;
239
240    fn test_runtime() -> tokio::runtime::Runtime {
241        tokio::runtime::Builder::new_current_thread()
242            .build()
243            .expect("test runtime must build")
244    }
245
246    #[test]
247    fn only_ready_applications_accept_requests() {
248        let requests = RequestLifecycle::new();
249        assert_eq!(
250            requests.try_accept().unwrap_err().code(),
251            "runtime.not_ready"
252        );
253
254        requests.mark_ready();
255        let request = requests.try_accept().expect("ready request is admitted");
256        requests.begin_draining();
257
258        assert_eq!(requests.phase(), ApplicationPhase::Draining);
259        assert_eq!(
260            requests.try_accept().unwrap_err().code(),
261            "runtime.shutting_down"
262        );
263
264        drop(request);
265        test_runtime().block_on(requests.wait_until_drained());
266        requests.mark_stopped();
267        assert_eq!(requests.phase(), ApplicationPhase::Stopped);
268    }
269
270    #[test]
271    fn draining_waits_for_every_admitted_request() {
272        test_runtime().block_on(async {
273            let requests = RequestLifecycle::new();
274            requests.mark_ready();
275            let first = requests.try_accept().unwrap();
276            let second = requests.try_accept().unwrap();
277            requests.begin_draining();
278
279            let requests_for_waiter = requests.clone();
280            let waiter = tokio::spawn(async move {
281                requests_for_waiter.wait_until_drained().await;
282            });
283            tokio::task::yield_now().await;
284            assert!(!waiter.is_finished());
285
286            drop(first);
287            tokio::task::yield_now().await;
288            assert!(!waiter.is_finished());
289
290            drop(second);
291            waiter.await.unwrap();
292        });
293    }
294
295    #[test]
296    fn admission_racing_with_drain_never_leaks_a_request() {
297        const WORKERS: usize = 8;
298        let requests = RequestLifecycle::new();
299        requests.mark_ready();
300        let barrier = Arc::new(Barrier::new(WORKERS + 1));
301        let workers: Vec<_> = (0..WORKERS)
302            .map(|_| {
303                let requests = requests.clone();
304                let barrier = Arc::clone(&barrier);
305                std::thread::spawn(move || {
306                    let admitted_before_drain = requests.try_accept().unwrap();
307                    barrier.wait();
308                    loop {
309                        match requests.try_accept() {
310                            Ok(request) => drop(request),
311                            Err(error) => {
312                                assert_eq!(error.code(), "runtime.shutting_down");
313                                drop(admitted_before_drain);
314                                break;
315                            }
316                        }
317                    }
318                })
319            })
320            .collect();
321
322        barrier.wait();
323        requests.begin_draining();
324        for worker in workers {
325            worker.join().unwrap();
326        }
327        test_runtime().block_on(requests.wait_until_drained());
328        assert_eq!(count(requests.shared.state.load(Ordering::Acquire)), 0);
329    }
330
331    #[test]
332    fn guard_completion_is_safe_from_another_thread() {
333        let requests = RequestLifecycle::new();
334        requests.mark_ready();
335        let guard = requests.try_accept().unwrap();
336        requests.begin_draining();
337
338        std::thread::spawn(move || drop(guard)).join().unwrap();
339        test_runtime().block_on(requests.wait_until_drained());
340
341        // Keep this assertion explicit: the thread above is runtime-internal
342        // test coverage, not a business-facing execution API.
343        assert_eq!(Arc::strong_count(&requests.shared), 1);
344    }
345}