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::{AttemptKey, LostWorkerReport};
13
14/// Process exit selected by the shutdown coordinator.
15///
16/// Serialized by name into the shutdown outcome record
17/// ([`crate::control::outcome`]), so the face an operator reads from
18/// `aion server stop` is this exact enum, not a re-derivation.
19#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
20pub enum ShutdownOutcome {
21 /// Drain completed before the configured timeout.
22 Clean,
23 /// In-flight activities outlived the drain timeout and were parked for
24 /// restart recovery (#207): nothing recorded, nothing delivered — the
25 /// recoverable-by-design state, so a fully-parked drain is a SUCCESS. A
26 /// long-running activity (an agent round runs hours) outliving any sane
27 /// drain window is the expected case, and a non-zero exit on every routine
28 /// deploy would train operators to ignore failures.
29 Parked,
30 /// The drain timed out AND the park itself failed (lock poison, sink
31 /// error): in-flight state could not be handed to restart recovery.
32 TimedOut,
33 /// A second termination signal requested immediate process exit.
34 Forced,
35}
36
37impl ShutdownOutcome {
38 /// Convert the outcome to the process exit code required by the operations contract.
39 #[must_use]
40 pub fn exit_code(self) -> ExitCode {
41 match self {
42 Self::Clean | Self::Parked => ExitCode::SUCCESS,
43 Self::TimedOut => ExitCode::FAILURE,
44 Self::Forced => ExitCode::from(130),
45 }
46 }
47}
48
49/// Everything one graceful shutdown observed, for the outcome record the
50/// dying server writes into the death note ([`crate::control::outcome`]).
51/// The process exit code still collapses this to the #207 contract via
52/// [`ShutdownOutcome::exit_code`]; the report is how the rest crosses the
53/// process boundary.
54#[derive(Debug)]
55pub struct ShutdownReport {
56 /// The drain's face.
57 pub outcome: ShutdownOutcome,
58 /// The window that governed the wait.
59 pub drain_timeout: Duration,
60 /// How many connected workers received the drain request.
61 pub delivered_drain_requests: usize,
62 /// Parked in-flight work at drain timeout, by worker. Empty on a clean
63 /// drain and on a forced exit (a second signal skips the accounting).
64 pub parked: Vec<LostWorkerReport>,
65 /// Declared-body commands still executing when the drain ended (timeout or
66 /// forced exit). A declared body is in-flight work no worker holds and no
67 /// heartbeat tracks; these attempts are parked by silence exactly like the
68 /// worker dispatches above — nothing is recorded, the engine teardown ends
69 /// their process groups with the server, and post-restart replay
70 /// re-dispatches each one. Empty on a clean drain: the drain gate waits for
71 /// this census too.
72 pub parked_declared: Vec<AttemptKey>,
73 /// Managed workers proven stopped, by deployment name.
74 pub managed_workers_stopped: Vec<String>,
75 /// Managed workers that could not be proven stopped, each rendered with
76 /// the observation that contradicted the stop.
77 pub managed_workers_unstopped: Vec<String>,
78}
79
80/// Cloneable gate shared by transports, dispatchers, worker streams, and the
81/// shutdown coordinator.
82#[derive(Clone, Debug, Default)]
83pub struct DrainState {
84 inner: Arc<DrainStateInner>,
85}
86
87#[derive(Debug)]
88struct DrainStateInner {
89 /// The drain latch, held in a `watch` rather than an `AtomicBool` because
90 /// one gated seam cannot poll a flag: the bridge's park for an arriving
91 /// worker BLOCKS until a worker appears, so it needs this same latch in
92 /// awaitable form to stop blocking when the server starts draining.
93 ///
94 /// One latch answering both questions is the point. Two independent notions
95 /// of "we are shutting down" inside the dispatch path is exactly how a
96 /// drain gate and a dispatch parked behind it come to disagree — and the
97 /// disagreement is unobservable until a process refuses to exit.
98 draining: watch::Sender<bool>,
99 empty: Notify,
100}
101
102impl Default for DrainStateInner {
103 fn default() -> Self {
104 Self {
105 draining: watch::Sender::new(false),
106 empty: Notify::default(),
107 }
108 }
109}
110
111impl DrainState {
112 /// Return whether drain has begun and new workflow/activity starts must be rejected.
113 #[must_use]
114 pub fn is_draining(&self) -> bool {
115 *self.inner.draining.borrow()
116 }
117
118 /// Mark the server draining. Returns true for the first caller that changed the state.
119 #[must_use]
120 pub fn begin(&self) -> bool {
121 // Sets the latch AND wakes every awaiting seam in one write, so a park
122 // released by drain can never observe a latch that has not been set
123 // yet.
124 !self.inner.draining.send_replace(true)
125 }
126
127 /// Resolve as soon as drain has begun — [`Self::is_draining`] in awaitable
128 /// form, over the same latch.
129 ///
130 /// For the seam that must block on an external arrival (a worker
131 /// registering) and therefore cannot re-read a flag between iterations.
132 /// Waking here decides nothing on its own: the woken caller re-runs its
133 /// normal loop and meets [`Self::ensure_accepting`], which is still the only
134 /// place a drain refusal is produced.
135 pub async fn wait_for_drain(&self) {
136 let mut draining = self.inner.draining.subscribe();
137 while !*draining.borrow_and_update() {
138 if draining.changed().await.is_err() {
139 // Unreachable while this handle lives — it owns the `Arc` the
140 // sender sits in — but a closed latch must read as "drained"
141 // rather than block forever on a state that cannot recover.
142 break;
143 }
144 }
145 }
146
147 /// Reject a new unit of work if drain has already begun.
148 ///
149 /// # Errors
150 ///
151 /// Returns [`ServerError::WorkerDispatch`] with a stable drain message when work is closed.
152 pub fn ensure_accepting(
153 &self,
154 namespace: &str,
155 activity_type: &str,
156 ) -> Result<(), ServerError> {
157 if self.is_draining() {
158 Err(ServerError::worker_dispatch(
159 namespace.to_owned(),
160 activity_type.to_owned(),
161 "server is draining and not accepting new activity tasks",
162 ))
163 } else {
164 Ok(())
165 }
166 }
167
168 /// Wake waiters after in-flight accounting may have reached zero.
169 pub fn notify_activity_drained(&self) {
170 self.inner.empty.notify_waiters();
171 }
172
173 /// Whether both in-flight censuses read empty: the heartbeat tracker's
174 /// (worker-held activities) AND the declared-body registry's (commands this
175 /// server is executing itself, which no worker holds and no heartbeat
176 /// tracks). Draining on the first census alone is how a server once
177 /// reported `Clean` over a live declared command.
178 fn nothing_in_flight(state: &ServerState) -> Result<bool, ServerError> {
179 Ok(state.heartbeat_tracker().in_flight_count()? == 0
180 && state.declared_attempts().executing()?.is_empty())
181 }
182
183 async fn wait_for_empty(&self, state: &ServerState) -> Result<(), ServerError> {
184 loop {
185 if Self::nothing_in_flight(state)? {
186 return Ok(());
187 }
188 let notified = self.inner.empty.notified();
189 if Self::nothing_in_flight(state)? {
190 return Ok(());
191 }
192 notified.await;
193 }
194 }
195}
196
197/// Run the graceful drain after the first termination signal.
198///
199/// The caller is responsible for stopping transports as soon as drain begins.
200///
201/// # Errors
202///
203/// Returns [`ServerError`] if worker-drain broadcast, in-flight accounting, timeout failure
204/// surfacing, or engine shutdown fails.
205pub async fn drain_after_first_signal(
206 state: ServerState,
207 second_signal: impl std::future::Future<Output = ()>,
208) -> Result<ShutdownReport, ServerError> {
209 let drain = state.drain_state().clone();
210 let first = drain.begin();
211 if first {
212 info!("shutdown signal received; beginning graceful drain");
213 }
214
215 let delivered_workers = state.worker_registry().broadcast_drain()?;
216 info!(delivered_workers, "sent drain request to connected workers");
217
218 let timeout = state.runtime_config().drain_timeout;
219 tokio::pin!(second_signal);
220
221 let (outcome, parked, parked_declared) = tokio::select! {
222 () = &mut second_signal => {
223 warn!("second shutdown signal received; forcing immediate exit");
224 // A forced exit skips the worker-park accounting, but the
225 // declared-command census is a read this process can still afford:
226 // these commands die with the server either way, and a report that
227 // cannot name them is the report the next operator debugs against.
228 let declared = state.declared_attempts().executing().unwrap_or_else(|census_error| {
229 error!(
230 %census_error,
231 "forced exit could not read the declared-command census; the \
232 outcome record will not name the commands that died with the server"
233 );
234 Vec::new()
235 });
236 (ShutdownOutcome::Forced, Vec::new(), declared)
237 }
238 result = wait_for_drain_or_timeout(&state, &drain, timeout) => result?,
239 };
240
241 // W-4 containment: managed worker PROCESSES stop with the server — AFTER
242 // the drain window, never before it. Draining exists to let in-flight work
243 // finish, and killing the workers doing that work would invert it; by the
244 // time this runs the activities have either completed or been parked for
245 // restart recovery (#207), and the next boot reconciles the fleet back up.
246 //
247 // It runs on the FORCED path too. A second signal asks for an immediate
248 // exit, and this does bound that by the operator's own `stop_grace` — but a
249 // worker outliving the server that owns it is worse than a bounded moment,
250 // and the alternative (relying on the drop guard as the process unwinds)
251 // reaps without ever verifying that it did.
252 //
253 // Deliberately NOT allowed to change the process exit contract (#72/#207):
254 // a failure here is reported in full, loudly, and shutdown proceeds.
255 // Failing the exit on a worker that would not die would turn a routine
256 // deploy red, which is how operators learn to ignore failures.
257 let managed = stop_managed_workers(&state).await;
258
259 let report = ShutdownReport {
260 outcome,
261 drain_timeout: timeout,
262 delivered_drain_requests: delivered_workers,
263 parked,
264 parked_declared,
265 managed_workers_stopped: managed.stopped,
266 managed_workers_unstopped: managed.failures.iter().map(ToString::to_string).collect(),
267 };
268 if matches!(outcome, ShutdownOutcome::Forced) {
269 return Ok(report);
270 }
271
272 state.shutdown()?;
273 Ok(report)
274}
275
276async fn wait_for_drain_or_timeout(
277 state: &ServerState,
278 drain: &DrainState,
279 timeout: Duration,
280) -> Result<(ShutdownOutcome, Vec<LostWorkerReport>, Vec<AttemptKey>), ServerError> {
281 match tokio::time::timeout(timeout, drain.wait_for_empty(state)).await {
282 Ok(result) => {
283 result?;
284 info!("activity drain completed cleanly");
285 Ok((ShutdownOutcome::Clean, Vec::new(), Vec::new()))
286 }
287 Err(_elapsed) => {
288 // #207 drain-timeout backstop: PARK the remaining in-flight
289 // dispatches for restart recovery instead of synthesizing
290 // transport-loss failures. Nothing is recorded, so the
291 // durable log converges on the kill -9 shape and post-restart
292 // replay re-dispatches every parked ordinal. A park that itself
293 // fails leaves in-flight state unhanded — the one remaining
294 // FAILURE-worthy drain outcome.
295 //
296 // Declared-body attempts park the same way but need no sweep: the
297 // engine teardown that follows ends their process groups with the
298 // server and their unrecorded attempts re-dispatch on restart. What
299 // the backstop owes them is the CENSUS — a park that cannot NAME
300 // the in-flight commands it is abandoning is the same unhanded
301 // state as a failed worker park, and is reported the same way.
302 let declared = match state.declared_attempts().executing() {
303 Ok(executing) => executing,
304 Err(census_error) => {
305 error!(
306 %census_error,
307 "activity drain timed out and the declared-command census could \
308 not be read; exiting with the failure drain outcome"
309 );
310 return Ok((ShutdownOutcome::TimedOut, Vec::new(), Vec::new()));
311 }
312 };
313 match state
314 .heartbeat_tracker()
315 .park_all_in_flight_workers(state.worker_registry(), state.pending_activities())
316 {
317 Ok(reports) => {
318 log_parked_workers(&reports);
319 log_parked_declared(&declared);
320 Ok((ShutdownOutcome::Parked, reports, declared))
321 }
322 Err(park_error) => {
323 error!(
324 %park_error,
325 "activity drain timed out and parking the remaining in-flight \
326 activities failed; exiting with the failure drain outcome"
327 );
328 Ok((ShutdownOutcome::TimedOut, Vec::new(), declared))
329 }
330 }
331 }
332 }
333}
334
335/// Stop every supervised managed worker, and say exactly what happened.
336///
337/// An empty failure list is the no-orphan claim: each stop returned only after
338/// a signal-zero probe found the worker's process group empty. Anything else is
339/// logged per worker with the observation that contradicted it — never summed
340/// into a single count that could read as calm.
341async fn stop_managed_workers(state: &ServerState) -> crate::worker::FleetShutdownReport {
342 let report = state.worker_supervisor().shutdown().await;
343 if report.failures.is_empty() {
344 info!("managed workers stopped; every process group confirmed empty");
345 return report;
346 }
347 for failure in &report.failures {
348 error!(%failure, "a managed worker could not be confirmed stopped at shutdown");
349 }
350 error!(
351 unstopped = report.failures.len(),
352 "shutdown could not prove every managed worker stopped; check for orphaned processes"
353 );
354 report
355}
356
357fn log_parked_declared(declared: &[AttemptKey]) {
358 for key in declared {
359 info!(
360 workflow_id = %key.workflow_id,
361 run_id = %key.run_id,
362 activity_id = %key.activity_id,
363 attempt = key.attempt,
364 "drain timed out with this declared command still executing; its process \
365 group ends with the server and the unrecorded attempt re-dispatches on \
366 the next boot"
367 );
368 }
369}
370
371fn log_parked_workers(reports: &[LostWorkerReport]) {
372 let parked_tasks: usize = reports.iter().map(|report| report.tasks.len()).sum();
373 if parked_tasks == 0 {
374 info!("activity drain timed out with no tracked in-flight activities to park");
375 } else {
376 info!(
377 parked_workers = reports.len(),
378 parked_tasks,
379 "activity drain timed out; remaining activities parked for restart recovery"
380 );
381 }
382}
383
384#[cfg(test)]
385mod tests {
386 use std::process::ExitCode;
387 use std::time::Duration;
388
389 use super::{DrainState, ShutdownOutcome};
390
391 type TestResult = Result<(), Box<dyn std::error::Error>>;
392
393 /// How long a woken waiter is allowed to take. Generous, and not a
394 /// behavioural bound: a correct latch resolves in microseconds and a broken
395 /// one never resolves, so this only decides how long a failure takes to
396 /// report.
397 const WAKE_BUDGET: Duration = Duration::from_secs(5);
398
399 #[test]
400 fn begin_is_idempotent_and_sets_draining() {
401 let drain = DrainState::default();
402
403 assert!(!drain.is_draining());
404 assert!(drain.begin());
405 assert!(drain.is_draining());
406 assert!(!drain.begin());
407 }
408
409 /// #72: a waiter already parked on the latch is woken by `begin`.
410 ///
411 /// This is the ordering the bridge's park depends on and the one a
412 /// notification-only signal gets wrong: the waiter registers first and the
413 /// latch flips afterwards, so nothing it could poll has changed yet. If the
414 /// wake is ever lost here, a dispatch parked for a worker becomes
415 /// unwakeable and the process cannot exit.
416 #[tokio::test]
417 async fn begin_wakes_a_waiter_that_registered_before_the_latch_flipped() {
418 let drain = DrainState::default();
419 let waiting = drain.clone();
420 let waiter = tokio::spawn(async move { waiting.wait_for_drain().await });
421 // Let the waiter reach its await before the latch is touched.
422 tokio::task::yield_now().await;
423 assert!(!drain.is_draining());
424 assert!(drain.begin());
425
426 let woken = tokio::time::timeout(WAKE_BUDGET, waiter).await;
427 assert!(
428 matches!(woken, Ok(Ok(()))),
429 "a waiter registered before `begin` was not woken: {woken:?}"
430 );
431 }
432
433 /// The other half of the same race: a waiter arriving AFTER the latch
434 /// flipped must not wait for a notification that has already been sent.
435 #[tokio::test]
436 async fn wait_for_drain_resolves_at_once_once_drain_has_begun() {
437 let drain = DrainState::default();
438 assert!(drain.begin());
439
440 let resolved = tokio::time::timeout(WAKE_BUDGET, drain.wait_for_drain()).await;
441 assert!(
442 resolved.is_ok(),
443 "a waiter arriving after `begin` blocked instead of resolving"
444 );
445 }
446
447 /// #207 exit contract: a fully-parked drain is a SUCCESS (parked state is
448 /// recoverable by design); FAILURE is reserved for a park that itself
449 /// failed; a forced exit keeps 130. `ExitCode` carries no `PartialEq`, so
450 /// the mapping is asserted through its debug representation.
451 #[test]
452 fn exit_codes_map_parked_to_success_and_timed_out_to_failure() {
453 let debug = |code: ExitCode| format!("{code:?}");
454 assert_eq!(
455 debug(ShutdownOutcome::Clean.exit_code()),
456 debug(ExitCode::SUCCESS)
457 );
458 assert_eq!(
459 debug(ShutdownOutcome::Parked.exit_code()),
460 debug(ExitCode::SUCCESS)
461 );
462 assert_eq!(
463 debug(ShutdownOutcome::TimedOut.exit_code()),
464 debug(ExitCode::FAILURE)
465 );
466 assert_eq!(
467 debug(ShutdownOutcome::Forced.exit_code()),
468 debug(ExitCode::from(130))
469 );
470 }
471
472 /// Red first — the drain gate must wait for an executing declared-body
473 /// command: in-flight work no worker holds and no heartbeat tracks. Before
474 /// the declared census joined the gate, a drain over a live declared
475 /// command resolved immediately and the server reported `Clean` while the
476 /// command still ran (found by `a_parked_drain_is_visible_in_the_stop_report`).
477 #[tokio::test]
478 async fn the_drain_gate_waits_for_an_executing_declared_command() -> TestResult {
479 let (engine, _store, _visibility) = crate::api::http::test_support::shared_engine().await?;
480 let resolver = crate::NamespaceResolver::from_config(
481 crate::config::NamespaceConfig {
482 mode: crate::config::NamespaceMode::SharedEngine,
483 },
484 engine.handle(),
485 );
486 let state = crate::api::http::test_support::server_state(
487 engine,
488 resolver,
489 crate::api::http::test_support::runtime_config(),
490 )
491 .await?;
492
493 let key = crate::worker::AttemptKey::new(
494 aion_core::WorkflowId::new_v4(),
495 aion_core::RunId::new_v4(),
496 aion_core::ActivityId::from_sequence_position(1),
497 1,
498 );
499 let (_context, cancellation) = aion_worker::ActivityContext::new(
500 key.workflow_id.clone(),
501 key.run_id.clone(),
502 key.activity_id.clone(),
503 key.attempt,
504 );
505 let registration = state.declared_attempts().register(key, cancellation)?;
506
507 let drain = state.drain_state().clone();
508 let waiter_state = state.clone();
509 let waiter = tokio::spawn(async move { drain.wait_for_empty(&waiter_state).await });
510 for _ in 0..32 {
511 tokio::task::yield_now().await;
512 }
513 assert!(
514 !waiter.is_finished(),
515 "the drain gate resolved over a live declared command — the in-flight \
516 census must include the declared-body registry"
517 );
518
519 // Finishing the command (the guard's drop) is what completes the drain,
520 // through the registry's own wake of the drain latch.
521 drop(registration);
522 let joined = tokio::time::timeout(WAKE_BUDGET, waiter).await;
523 joined
524 .map_err(|_elapsed| "the drain gate never woke after the declared command finished")?
525 .map_err(|join_error| format!("the drain waiter panicked: {join_error}"))??;
526 Ok(())
527 }
528}