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::{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}