Skip to main content

aion_server/
shutdown.rs

1//! Graceful shutdown and single-node activity drain coordination.
2
3use std::process::ExitCode;
4use std::sync::Arc;
5use std::time::Duration;
6
7use tokio::sync::{Notify, watch};
8use tracing::{error, info, warn};
9
10use crate::ServerState;
11use crate::error::ServerError;
12use crate::worker::LostWorkerReport;
13
14/// Process exit selected by the shutdown coordinator.
15#[derive(Clone, Copy, Debug, Eq, PartialEq)]
16pub enum ShutdownOutcome {
17    /// Drain completed before the configured timeout.
18    Clean,
19    /// In-flight activities outlived the drain timeout and were parked for
20    /// restart recovery (#207): nothing recorded, nothing delivered — the
21    /// recoverable-by-design state, so a fully-parked drain is a SUCCESS. A
22    /// long-running activity (an agent round runs hours) outliving any sane
23    /// drain window is the expected case, and a non-zero exit on every routine
24    /// deploy would train operators to ignore failures.
25    Parked,
26    /// The drain timed out AND the park itself failed (lock poison, sink
27    /// error): in-flight state could not be handed to restart recovery.
28    TimedOut,
29    /// A second termination signal requested immediate process exit.
30    Forced,
31}
32
33impl ShutdownOutcome {
34    /// Convert the outcome to the process exit code required by the operations contract.
35    #[must_use]
36    pub fn exit_code(self) -> ExitCode {
37        match self {
38            Self::Clean | Self::Parked => ExitCode::SUCCESS,
39            Self::TimedOut => ExitCode::FAILURE,
40            Self::Forced => ExitCode::from(130),
41        }
42    }
43}
44
45/// Cloneable gate shared by transports, dispatchers, worker streams, and the
46/// shutdown coordinator.
47#[derive(Clone, Debug, Default)]
48pub struct DrainState {
49    inner: Arc<DrainStateInner>,
50}
51
52#[derive(Debug)]
53struct DrainStateInner {
54    /// The drain latch, held in a `watch` rather than an `AtomicBool` because
55    /// one gated seam cannot poll a flag: the bridge's park for an arriving
56    /// worker BLOCKS until a worker appears, so it needs this same latch in
57    /// awaitable form to stop blocking when the server starts draining.
58    ///
59    /// One latch answering both questions is the point. Two independent notions
60    /// of "we are shutting down" inside the dispatch path is exactly how a
61    /// drain gate and a dispatch parked behind it come to disagree — and the
62    /// disagreement is unobservable until a process refuses to exit.
63    draining: watch::Sender<bool>,
64    empty: Notify,
65}
66
67impl Default for DrainStateInner {
68    fn default() -> Self {
69        Self {
70            draining: watch::Sender::new(false),
71            empty: Notify::default(),
72        }
73    }
74}
75
76impl DrainState {
77    /// Return whether drain has begun and new workflow/activity starts must be rejected.
78    #[must_use]
79    pub fn is_draining(&self) -> bool {
80        *self.inner.draining.borrow()
81    }
82
83    /// Mark the server draining. Returns true for the first caller that changed the state.
84    #[must_use]
85    pub fn begin(&self) -> bool {
86        // Sets the latch AND wakes every awaiting seam in one write, so a park
87        // released by drain can never observe a latch that has not been set
88        // yet.
89        !self.inner.draining.send_replace(true)
90    }
91
92    /// Resolve as soon as drain has begun — [`Self::is_draining`] in awaitable
93    /// form, over the same latch.
94    ///
95    /// For the seam that must block on an external arrival (a worker
96    /// registering) and therefore cannot re-read a flag between iterations.
97    /// Waking here decides nothing on its own: the woken caller re-runs its
98    /// normal loop and meets [`Self::ensure_accepting`], which is still the only
99    /// place a drain refusal is produced.
100    pub async fn wait_for_drain(&self) {
101        let mut draining = self.inner.draining.subscribe();
102        while !*draining.borrow_and_update() {
103            if draining.changed().await.is_err() {
104                // Unreachable while this handle lives — it owns the `Arc` the
105                // sender sits in — but a closed latch must read as "drained"
106                // rather than block forever on a state that cannot recover.
107                break;
108            }
109        }
110    }
111
112    /// Reject a new unit of work if drain has already begun.
113    ///
114    /// # Errors
115    ///
116    /// Returns [`ServerError::WorkerDispatch`] with a stable drain message when work is closed.
117    pub fn ensure_accepting(
118        &self,
119        namespace: &str,
120        activity_type: &str,
121    ) -> Result<(), ServerError> {
122        if self.is_draining() {
123            Err(ServerError::worker_dispatch(
124                namespace.to_owned(),
125                activity_type.to_owned(),
126                "server is draining and not accepting new activity tasks",
127            ))
128        } else {
129            Ok(())
130        }
131    }
132
133    /// Wake waiters after in-flight accounting may have reached zero.
134    pub fn notify_activity_drained(&self) {
135        self.inner.empty.notify_waiters();
136    }
137
138    async fn wait_for_empty(&self, state: &ServerState) -> Result<(), ServerError> {
139        loop {
140            let in_flight = state.heartbeat_tracker().in_flight_count()?;
141            if in_flight == 0 {
142                return Ok(());
143            }
144            let notified = self.inner.empty.notified();
145            if state.heartbeat_tracker().in_flight_count()? == 0 {
146                return Ok(());
147            }
148            notified.await;
149        }
150    }
151}
152
153/// Run the graceful drain after the first termination signal.
154///
155/// The caller is responsible for stopping transports as soon as drain begins.
156///
157/// # Errors
158///
159/// Returns [`ServerError`] if worker-drain broadcast, in-flight accounting, timeout failure
160/// surfacing, or engine shutdown fails.
161pub async fn drain_after_first_signal(
162    state: ServerState,
163    second_signal: impl std::future::Future<Output = ()>,
164) -> Result<ShutdownOutcome, ServerError> {
165    let drain = state.drain_state().clone();
166    let first = drain.begin();
167    if first {
168        info!("shutdown signal received; beginning graceful drain");
169    }
170
171    let delivered_workers = state.worker_registry().broadcast_drain()?;
172    info!(delivered_workers, "sent drain request to connected workers");
173
174    let timeout = state.runtime_config().drain_timeout;
175    tokio::pin!(second_signal);
176
177    let outcome = tokio::select! {
178        () = &mut second_signal => {
179            warn!("second shutdown signal received; forcing immediate exit");
180            ShutdownOutcome::Forced
181        }
182        result = wait_for_drain_or_timeout(&state, &drain, timeout) => result?,
183    };
184
185    if matches!(outcome, ShutdownOutcome::Forced) {
186        return Ok(outcome);
187    }
188
189    state.shutdown()?;
190    Ok(outcome)
191}
192
193async fn wait_for_drain_or_timeout(
194    state: &ServerState,
195    drain: &DrainState,
196    timeout: Duration,
197) -> Result<ShutdownOutcome, ServerError> {
198    match tokio::time::timeout(timeout, drain.wait_for_empty(state)).await {
199        Ok(result) => {
200            result?;
201            info!("activity drain completed cleanly");
202            Ok(ShutdownOutcome::Clean)
203        }
204        Err(_elapsed) => {
205            // #207 drain-timeout backstop: PARK the remaining in-flight
206            // dispatches for restart recovery instead of synthesizing
207            // transport-loss failures. Nothing is recorded, so the
208            // durable log converges on the kill -9 shape and post-restart
209            // replay re-dispatches every parked ordinal. A park that itself
210            // fails leaves in-flight state unhanded — the one remaining
211            // FAILURE-worthy drain outcome.
212            match state
213                .heartbeat_tracker()
214                .park_all_in_flight_workers(state.worker_registry(), state.pending_activities())
215            {
216                Ok(reports) => {
217                    log_parked_workers(&reports);
218                    Ok(ShutdownOutcome::Parked)
219                }
220                Err(park_error) => {
221                    error!(
222                        %park_error,
223                        "activity drain timed out and parking the remaining in-flight \
224                         activities failed; exiting with the failure drain outcome"
225                    );
226                    Ok(ShutdownOutcome::TimedOut)
227                }
228            }
229        }
230    }
231}
232
233fn log_parked_workers(reports: &[LostWorkerReport]) {
234    let parked_tasks: usize = reports.iter().map(|report| report.tasks.len()).sum();
235    if parked_tasks == 0 {
236        info!("activity drain timed out with no tracked in-flight activities to park");
237    } else {
238        info!(
239            parked_workers = reports.len(),
240            parked_tasks,
241            "activity drain timed out; remaining activities parked for restart recovery"
242        );
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use std::process::ExitCode;
249    use std::time::Duration;
250
251    use super::{DrainState, ShutdownOutcome};
252
253    /// How long a woken waiter is allowed to take. Generous, and not a
254    /// behavioural bound: a correct latch resolves in microseconds and a broken
255    /// one never resolves, so this only decides how long a failure takes to
256    /// report.
257    const WAKE_BUDGET: Duration = Duration::from_secs(5);
258
259    #[test]
260    fn begin_is_idempotent_and_sets_draining() {
261        let drain = DrainState::default();
262
263        assert!(!drain.is_draining());
264        assert!(drain.begin());
265        assert!(drain.is_draining());
266        assert!(!drain.begin());
267    }
268
269    /// #72: a waiter already parked on the latch is woken by `begin`.
270    ///
271    /// This is the ordering the bridge's park depends on and the one a
272    /// notification-only signal gets wrong: the waiter registers first and the
273    /// latch flips afterwards, so nothing it could poll has changed yet. If the
274    /// wake is ever lost here, a dispatch parked for a worker becomes
275    /// unwakeable and the process cannot exit.
276    #[tokio::test]
277    async fn begin_wakes_a_waiter_that_registered_before_the_latch_flipped() {
278        let drain = DrainState::default();
279        let waiting = drain.clone();
280        let waiter = tokio::spawn(async move { waiting.wait_for_drain().await });
281        // Let the waiter reach its await before the latch is touched.
282        tokio::task::yield_now().await;
283        assert!(!drain.is_draining());
284        assert!(drain.begin());
285
286        let woken = tokio::time::timeout(WAKE_BUDGET, waiter).await;
287        assert!(
288            matches!(woken, Ok(Ok(()))),
289            "a waiter registered before `begin` was not woken: {woken:?}"
290        );
291    }
292
293    /// The other half of the same race: a waiter arriving AFTER the latch
294    /// flipped must not wait for a notification that has already been sent.
295    #[tokio::test]
296    async fn wait_for_drain_resolves_at_once_once_drain_has_begun() {
297        let drain = DrainState::default();
298        assert!(drain.begin());
299
300        let resolved = tokio::time::timeout(WAKE_BUDGET, drain.wait_for_drain()).await;
301        assert!(
302            resolved.is_ok(),
303            "a waiter arriving after `begin` blocked instead of resolving"
304        );
305    }
306
307    /// #207 exit contract: a fully-parked drain is a SUCCESS (parked state is
308    /// recoverable by design); FAILURE is reserved for a park that itself
309    /// failed; a forced exit keeps 130. `ExitCode` carries no `PartialEq`, so
310    /// the mapping is asserted through its debug representation.
311    #[test]
312    fn exit_codes_map_parked_to_success_and_timed_out_to_failure() {
313        let debug = |code: ExitCode| format!("{code:?}");
314        assert_eq!(
315            debug(ShutdownOutcome::Clean.exit_code()),
316            debug(ExitCode::SUCCESS)
317        );
318        assert_eq!(
319            debug(ShutdownOutcome::Parked.exit_code()),
320            debug(ExitCode::SUCCESS)
321        );
322        assert_eq!(
323            debug(ShutdownOutcome::TimedOut.exit_code()),
324            debug(ExitCode::FAILURE)
325        );
326        assert_eq!(
327            debug(ShutdownOutcome::Forced.exit_code()),
328            debug(ExitCode::from(130))
329        );
330    }
331}