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 // W-4 containment: managed worker PROCESSES stop with the server — AFTER
186 // the drain window, never before it. Draining exists to let in-flight work
187 // finish, and killing the workers doing that work would invert it; by the
188 // time this runs the activities have either completed or been parked for
189 // restart recovery (#207), and the next boot reconciles the fleet back up.
190 //
191 // It runs on the FORCED path too. A second signal asks for an immediate
192 // exit, and this does bound that by the operator's own `stop_grace` — but a
193 // worker outliving the server that owns it is worse than a bounded moment,
194 // and the alternative (relying on the drop guard as the process unwinds)
195 // reaps without ever verifying that it did.
196 //
197 // Deliberately NOT allowed to change the process exit contract (#72/#207):
198 // a failure here is reported in full, loudly, and shutdown proceeds.
199 // Failing the exit on a worker that would not die would turn a routine
200 // deploy red, which is how operators learn to ignore failures.
201 stop_managed_workers(&state).await;
202
203 if matches!(outcome, ShutdownOutcome::Forced) {
204 return Ok(outcome);
205 }
206
207 state.shutdown()?;
208 Ok(outcome)
209}
210
211async fn wait_for_drain_or_timeout(
212 state: &ServerState,
213 drain: &DrainState,
214 timeout: Duration,
215) -> Result<ShutdownOutcome, ServerError> {
216 match tokio::time::timeout(timeout, drain.wait_for_empty(state)).await {
217 Ok(result) => {
218 result?;
219 info!("activity drain completed cleanly");
220 Ok(ShutdownOutcome::Clean)
221 }
222 Err(_elapsed) => {
223 // #207 drain-timeout backstop: PARK the remaining in-flight
224 // dispatches for restart recovery instead of synthesizing
225 // transport-loss failures. Nothing is recorded, so the
226 // durable log converges on the kill -9 shape and post-restart
227 // replay re-dispatches every parked ordinal. A park that itself
228 // fails leaves in-flight state unhanded — the one remaining
229 // FAILURE-worthy drain outcome.
230 match state
231 .heartbeat_tracker()
232 .park_all_in_flight_workers(state.worker_registry(), state.pending_activities())
233 {
234 Ok(reports) => {
235 log_parked_workers(&reports);
236 Ok(ShutdownOutcome::Parked)
237 }
238 Err(park_error) => {
239 error!(
240 %park_error,
241 "activity drain timed out and parking the remaining in-flight \
242 activities failed; exiting with the failure drain outcome"
243 );
244 Ok(ShutdownOutcome::TimedOut)
245 }
246 }
247 }
248 }
249}
250
251/// Stop every supervised managed worker, and say exactly what happened.
252///
253/// An empty failure list is the no-orphan claim: each stop returned only after
254/// a signal-zero probe found the worker's process group empty. Anything else is
255/// logged per worker with the observation that contradicted it — never summed
256/// into a single count that could read as calm.
257async fn stop_managed_workers(state: &ServerState) {
258 let failures = state.worker_supervisor().shutdown().await;
259 if failures.is_empty() {
260 info!("managed workers stopped; every process group confirmed empty");
261 return;
262 }
263 for failure in &failures {
264 error!(%failure, "a managed worker could not be confirmed stopped at shutdown");
265 }
266 error!(
267 unstopped = failures.len(),
268 "shutdown could not prove every managed worker stopped; check for orphaned processes"
269 );
270}
271
272fn log_parked_workers(reports: &[LostWorkerReport]) {
273 let parked_tasks: usize = reports.iter().map(|report| report.tasks.len()).sum();
274 if parked_tasks == 0 {
275 info!("activity drain timed out with no tracked in-flight activities to park");
276 } else {
277 info!(
278 parked_workers = reports.len(),
279 parked_tasks,
280 "activity drain timed out; remaining activities parked for restart recovery"
281 );
282 }
283}
284
285#[cfg(test)]
286mod tests {
287 use std::process::ExitCode;
288 use std::time::Duration;
289
290 use super::{DrainState, ShutdownOutcome};
291
292 /// How long a woken waiter is allowed to take. Generous, and not a
293 /// behavioural bound: a correct latch resolves in microseconds and a broken
294 /// one never resolves, so this only decides how long a failure takes to
295 /// report.
296 const WAKE_BUDGET: Duration = Duration::from_secs(5);
297
298 #[test]
299 fn begin_is_idempotent_and_sets_draining() {
300 let drain = DrainState::default();
301
302 assert!(!drain.is_draining());
303 assert!(drain.begin());
304 assert!(drain.is_draining());
305 assert!(!drain.begin());
306 }
307
308 /// #72: a waiter already parked on the latch is woken by `begin`.
309 ///
310 /// This is the ordering the bridge's park depends on and the one a
311 /// notification-only signal gets wrong: the waiter registers first and the
312 /// latch flips afterwards, so nothing it could poll has changed yet. If the
313 /// wake is ever lost here, a dispatch parked for a worker becomes
314 /// unwakeable and the process cannot exit.
315 #[tokio::test]
316 async fn begin_wakes_a_waiter_that_registered_before_the_latch_flipped() {
317 let drain = DrainState::default();
318 let waiting = drain.clone();
319 let waiter = tokio::spawn(async move { waiting.wait_for_drain().await });
320 // Let the waiter reach its await before the latch is touched.
321 tokio::task::yield_now().await;
322 assert!(!drain.is_draining());
323 assert!(drain.begin());
324
325 let woken = tokio::time::timeout(WAKE_BUDGET, waiter).await;
326 assert!(
327 matches!(woken, Ok(Ok(()))),
328 "a waiter registered before `begin` was not woken: {woken:?}"
329 );
330 }
331
332 /// The other half of the same race: a waiter arriving AFTER the latch
333 /// flipped must not wait for a notification that has already been sent.
334 #[tokio::test]
335 async fn wait_for_drain_resolves_at_once_once_drain_has_begun() {
336 let drain = DrainState::default();
337 assert!(drain.begin());
338
339 let resolved = tokio::time::timeout(WAKE_BUDGET, drain.wait_for_drain()).await;
340 assert!(
341 resolved.is_ok(),
342 "a waiter arriving after `begin` blocked instead of resolving"
343 );
344 }
345
346 /// #207 exit contract: a fully-parked drain is a SUCCESS (parked state is
347 /// recoverable by design); FAILURE is reserved for a park that itself
348 /// failed; a forced exit keeps 130. `ExitCode` carries no `PartialEq`, so
349 /// the mapping is asserted through its debug representation.
350 #[test]
351 fn exit_codes_map_parked_to_success_and_timed_out_to_failure() {
352 let debug = |code: ExitCode| format!("{code:?}");
353 assert_eq!(
354 debug(ShutdownOutcome::Clean.exit_code()),
355 debug(ExitCode::SUCCESS)
356 );
357 assert_eq!(
358 debug(ShutdownOutcome::Parked.exit_code()),
359 debug(ExitCode::SUCCESS)
360 );
361 assert_eq!(
362 debug(ShutdownOutcome::TimedOut.exit_code()),
363 debug(ExitCode::FAILURE)
364 );
365 assert_eq!(
366 debug(ShutdownOutcome::Forced.exit_code()),
367 debug(ExitCode::from(130))
368 );
369 }
370}