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::sync::atomic::{AtomicBool, Ordering};
6use std::time::Duration;
7
8use tokio::sync::Notify;
9use tracing::{error, info, warn};
10
11use crate::ServerState;
12use crate::error::ServerError;
13use crate::worker::LostWorkerReport;
14
15/// Process exit selected by the shutdown coordinator.
16#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17pub enum ShutdownOutcome {
18    /// Drain completed before the configured timeout.
19    Clean,
20    /// In-flight activities outlived the drain timeout and were parked for
21    /// restart recovery (#207): nothing recorded, nothing delivered — the
22    /// recoverable-by-design state, so a fully-parked drain is a SUCCESS. A
23    /// long-running activity (an agent round runs hours) outliving any sane
24    /// drain window is the expected case, and a non-zero exit on every routine
25    /// deploy would train operators to ignore failures.
26    Parked,
27    /// The drain timed out AND the park itself failed (lock poison, sink
28    /// error): in-flight state could not be handed to restart recovery.
29    TimedOut,
30    /// A second termination signal requested immediate process exit.
31    Forced,
32}
33
34impl ShutdownOutcome {
35    /// Convert the outcome to the process exit code required by the operations contract.
36    #[must_use]
37    pub fn exit_code(self) -> ExitCode {
38        match self {
39            Self::Clean | Self::Parked => ExitCode::SUCCESS,
40            Self::TimedOut => ExitCode::FAILURE,
41            Self::Forced => ExitCode::from(130),
42        }
43    }
44}
45
46/// Cloneable gate shared by transports, dispatchers, worker streams, and the
47/// shutdown coordinator.
48#[derive(Clone, Debug, Default)]
49pub struct DrainState {
50    inner: Arc<DrainStateInner>,
51}
52
53#[derive(Debug, Default)]
54struct DrainStateInner {
55    draining: AtomicBool,
56    empty: Notify,
57}
58
59impl DrainState {
60    /// Return whether drain has begun and new workflow/activity starts must be rejected.
61    #[must_use]
62    pub fn is_draining(&self) -> bool {
63        self.inner.draining.load(Ordering::Acquire)
64    }
65
66    /// Mark the server draining. Returns true for the first caller that changed the state.
67    #[must_use]
68    pub fn begin(&self) -> bool {
69        !self.inner.draining.swap(true, Ordering::AcqRel)
70    }
71
72    /// Reject a new unit of work if drain has already begun.
73    ///
74    /// # Errors
75    ///
76    /// Returns [`ServerError::WorkerDispatch`] with a stable drain message when work is closed.
77    pub fn ensure_accepting(
78        &self,
79        namespace: &str,
80        activity_type: &str,
81    ) -> Result<(), ServerError> {
82        if self.is_draining() {
83            Err(ServerError::worker_dispatch(
84                namespace.to_owned(),
85                activity_type.to_owned(),
86                "server is draining and not accepting new activity tasks",
87            ))
88        } else {
89            Ok(())
90        }
91    }
92
93    /// Wake waiters after in-flight accounting may have reached zero.
94    pub fn notify_activity_drained(&self) {
95        self.inner.empty.notify_waiters();
96    }
97
98    async fn wait_for_empty(&self, state: &ServerState) -> Result<(), ServerError> {
99        loop {
100            let in_flight = state.heartbeat_tracker().in_flight_count()?;
101            if in_flight == 0 {
102                return Ok(());
103            }
104            let notified = self.inner.empty.notified();
105            if state.heartbeat_tracker().in_flight_count()? == 0 {
106                return Ok(());
107            }
108            notified.await;
109        }
110    }
111}
112
113/// Run the graceful drain after the first termination signal.
114///
115/// The caller is responsible for stopping transports as soon as drain begins.
116///
117/// # Errors
118///
119/// Returns [`ServerError`] if worker-drain broadcast, in-flight accounting, timeout failure
120/// surfacing, or engine shutdown fails.
121pub async fn drain_after_first_signal(
122    state: ServerState,
123    second_signal: impl std::future::Future<Output = ()>,
124) -> Result<ShutdownOutcome, ServerError> {
125    let drain = state.drain_state().clone();
126    let first = drain.begin();
127    if first {
128        info!("shutdown signal received; beginning graceful drain");
129    }
130
131    let delivered_workers = state.worker_registry().broadcast_drain()?;
132    info!(delivered_workers, "sent drain request to connected workers");
133
134    let timeout = state.runtime_config().drain_timeout;
135    tokio::pin!(second_signal);
136
137    let outcome = tokio::select! {
138        () = &mut second_signal => {
139            warn!("second shutdown signal received; forcing immediate exit");
140            ShutdownOutcome::Forced
141        }
142        result = wait_for_drain_or_timeout(&state, &drain, timeout) => result?,
143    };
144
145    if matches!(outcome, ShutdownOutcome::Forced) {
146        return Ok(outcome);
147    }
148
149    state.shutdown()?;
150    Ok(outcome)
151}
152
153async fn wait_for_drain_or_timeout(
154    state: &ServerState,
155    drain: &DrainState,
156    timeout: Duration,
157) -> Result<ShutdownOutcome, ServerError> {
158    match tokio::time::timeout(timeout, drain.wait_for_empty(state)).await {
159        Ok(result) => {
160            result?;
161            info!("activity drain completed cleanly");
162            Ok(ShutdownOutcome::Clean)
163        }
164        Err(_elapsed) => {
165            // #207 drain-timeout backstop: PARK the remaining in-flight
166            // dispatches for restart recovery instead of synthesizing
167            // retryable lost-worker failures. Nothing is recorded, so the
168            // durable log converges on the kill -9 shape and post-restart
169            // replay re-dispatches every parked ordinal. A park that itself
170            // fails leaves in-flight state unhanded — the one remaining
171            // FAILURE-worthy drain outcome.
172            match state
173                .heartbeat_tracker()
174                .park_all_in_flight_workers(state.worker_registry(), state.pending_activities())
175            {
176                Ok(reports) => {
177                    log_parked_workers(&reports);
178                    Ok(ShutdownOutcome::Parked)
179                }
180                Err(park_error) => {
181                    error!(
182                        %park_error,
183                        "activity drain timed out and parking the remaining in-flight \
184                         activities failed; exiting with the failure drain outcome"
185                    );
186                    Ok(ShutdownOutcome::TimedOut)
187                }
188            }
189        }
190    }
191}
192
193fn log_parked_workers(reports: &[LostWorkerReport]) {
194    let parked_tasks: usize = reports.iter().map(|report| report.tasks.len()).sum();
195    if parked_tasks == 0 {
196        info!("activity drain timed out with no tracked in-flight activities to park");
197    } else {
198        info!(
199            parked_workers = reports.len(),
200            parked_tasks,
201            "activity drain timed out; remaining activities parked for restart recovery"
202        );
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use std::process::ExitCode;
209
210    use super::{DrainState, ShutdownOutcome};
211
212    #[test]
213    fn begin_is_idempotent_and_sets_draining() {
214        let drain = DrainState::default();
215
216        assert!(!drain.is_draining());
217        assert!(drain.begin());
218        assert!(drain.is_draining());
219        assert!(!drain.begin());
220    }
221
222    /// #207 exit contract: a fully-parked drain is a SUCCESS (parked state is
223    /// recoverable by design); FAILURE is reserved for a park that itself
224    /// failed; a forced exit keeps 130. `ExitCode` carries no `PartialEq`, so
225    /// the mapping is asserted through its debug representation.
226    #[test]
227    fn exit_codes_map_parked_to_success_and_timed_out_to_failure() {
228        let debug = |code: ExitCode| format!("{code:?}");
229        assert_eq!(
230            debug(ShutdownOutcome::Clean.exit_code()),
231            debug(ExitCode::SUCCESS)
232        );
233        assert_eq!(
234            debug(ShutdownOutcome::Parked.exit_code()),
235            debug(ExitCode::SUCCESS)
236        );
237        assert_eq!(
238            debug(ShutdownOutcome::TimedOut.exit_code()),
239            debug(ExitCode::FAILURE)
240        );
241        assert_eq!(
242            debug(ShutdownOutcome::Forced.exit_code()),
243            debug(ExitCode::from(130))
244        );
245    }
246}