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    /// At least one in-flight activity exceeded the drain timeout.
21    TimedOut,
22    /// A second termination signal requested immediate process exit.
23    Forced,
24}
25
26impl ShutdownOutcome {
27    /// Convert the outcome to the process exit code required by the operations contract.
28    #[must_use]
29    pub fn exit_code(self) -> ExitCode {
30        match self {
31            Self::Clean => ExitCode::SUCCESS,
32            Self::TimedOut => ExitCode::FAILURE,
33            Self::Forced => ExitCode::from(130),
34        }
35    }
36}
37
38/// Cloneable gate shared by transports, dispatchers, worker streams, and the
39/// shutdown coordinator.
40#[derive(Clone, Debug, Default)]
41pub struct DrainState {
42    inner: Arc<DrainStateInner>,
43}
44
45#[derive(Debug, Default)]
46struct DrainStateInner {
47    draining: AtomicBool,
48    empty: Notify,
49}
50
51impl DrainState {
52    /// Return whether drain has begun and new workflow/activity starts must be rejected.
53    #[must_use]
54    pub fn is_draining(&self) -> bool {
55        self.inner.draining.load(Ordering::Acquire)
56    }
57
58    /// Mark the server draining. Returns true for the first caller that changed the state.
59    #[must_use]
60    pub fn begin(&self) -> bool {
61        !self.inner.draining.swap(true, Ordering::AcqRel)
62    }
63
64    /// Reject a new unit of work if drain has already begun.
65    ///
66    /// # Errors
67    ///
68    /// Returns [`ServerError::WorkerDispatch`] with a stable drain message when work is closed.
69    pub fn ensure_accepting(
70        &self,
71        namespace: &str,
72        activity_type: &str,
73    ) -> Result<(), ServerError> {
74        if self.is_draining() {
75            Err(ServerError::worker_dispatch(
76                namespace.to_owned(),
77                activity_type.to_owned(),
78                "server is draining and not accepting new activity tasks",
79            ))
80        } else {
81            Ok(())
82        }
83    }
84
85    /// Wake waiters after in-flight accounting may have reached zero.
86    pub fn notify_activity_drained(&self) {
87        self.inner.empty.notify_waiters();
88    }
89
90    async fn wait_for_empty(&self, state: &ServerState) -> Result<(), ServerError> {
91        loop {
92            let in_flight = state.heartbeat_tracker().in_flight_count()?;
93            if in_flight == 0 {
94                return Ok(());
95            }
96            let notified = self.inner.empty.notified();
97            if state.heartbeat_tracker().in_flight_count()? == 0 {
98                return Ok(());
99            }
100            notified.await;
101        }
102    }
103}
104
105/// Run the graceful drain after the first termination signal.
106///
107/// The caller is responsible for stopping transports as soon as drain begins.
108///
109/// # Errors
110///
111/// Returns [`ServerError`] if worker-drain broadcast, in-flight accounting, timeout failure
112/// surfacing, or engine shutdown fails.
113pub async fn drain_after_first_signal(
114    state: ServerState,
115    second_signal: impl std::future::Future<Output = ()>,
116) -> Result<ShutdownOutcome, ServerError> {
117    let drain = state.drain_state().clone();
118    let first = drain.begin();
119    if first {
120        info!("shutdown signal received; beginning graceful drain");
121    }
122
123    let delivered_workers = state.worker_registry().broadcast_drain()?;
124    info!(delivered_workers, "sent drain request to connected workers");
125
126    let timeout = state.runtime_config().drain_timeout;
127    tokio::pin!(second_signal);
128
129    let outcome = tokio::select! {
130        () = &mut second_signal => {
131            warn!("second shutdown signal received; forcing immediate exit");
132            ShutdownOutcome::Forced
133        }
134        result = wait_for_drain_or_timeout(&state, &drain, timeout) => result?,
135    };
136
137    if matches!(outcome, ShutdownOutcome::Forced) {
138        return Ok(outcome);
139    }
140
141    state.shutdown()?;
142    Ok(outcome)
143}
144
145async fn wait_for_drain_or_timeout(
146    state: &ServerState,
147    drain: &DrainState,
148    timeout: Duration,
149) -> Result<ShutdownOutcome, ServerError> {
150    match tokio::time::timeout(timeout, drain.wait_for_empty(state)).await {
151        Ok(result) => {
152            result?;
153            info!("activity drain completed cleanly");
154            Ok(ShutdownOutcome::Clean)
155        }
156        Err(_elapsed) => {
157            let reports = state
158                .heartbeat_tracker()
159                .fail_all_in_flight_workers(state.worker_registry(), state.pending_activities())?;
160            log_lost_workers(&reports);
161            Ok(ShutdownOutcome::TimedOut)
162        }
163    }
164}
165
166fn log_lost_workers(reports: &[LostWorkerReport]) {
167    let failed_tasks: usize = reports.iter().map(|report| report.tasks.len()).sum();
168    if failed_tasks == 0 {
169        info!("activity drain timed out with no tracked in-flight activity failures");
170    } else {
171        error!(
172            failed_workers = reports.len(),
173            failed_tasks,
174            "activity drain timed out; remaining activities surfaced as retryable lost-worker failures"
175        );
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::DrainState;
182
183    #[test]
184    fn begin_is_idempotent_and_sets_draining() {
185        let drain = DrainState::default();
186
187        assert!(!drain.is_draining());
188        assert!(drain.begin());
189        assert!(drain.is_draining());
190        assert!(!drain.begin());
191    }
192}