Skip to main content

aion_server/control/
outcome.rs

1//! The shutdown outcome record: how the drain's rich result crosses the
2//! process boundary.
3//!
4//! The process exit code deliberately collapses the drain outcome (#207:
5//! `Parked` exits success because parked state is recoverable by design), so
6//! the dying server writes its full [`OutcomeRecord`] into the death note —
7//! the file that already owns "what ended this process" — as one `OUTCOME`
8//! entry, and `aion server stop` reads it back after the process is gone.
9//! One file, one writer, no second channel; the note's line-oriented format
10//! means readers of the existing entries are untouched by the new kind.
11//!
12//! A server that dies without writing the record (`kill -9`, a panic before
13//! the drain) leaves an ARMED bracket with no `OUTCOME` and no `DISARMED`;
14//! the reader reports exactly that shape and never fabricates a drain
15//! summary from it.
16
17use std::path::Path;
18
19use serde::{Deserialize, Serialize};
20
21use crate::error::ServerError;
22use crate::shutdown::ShutdownOutcome;
23
24/// One worker's parked in-flight work at drain timeout, by name.
25#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
26pub struct ParkedWorkerRecord {
27    /// Registry id of the worker whose tasks were parked.
28    pub worker: String,
29    /// Task queue the worker was serving, when the registry still knew it.
30    pub queue: Option<String>,
31    /// The parked activities as `workflow/activity#attempt` names.
32    pub tasks: Vec<String>,
33}
34
35/// The durable record of one shutdown's drain outcome.
36#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
37pub struct OutcomeRecord {
38    /// Pid of the server that wrote the record, tying it to an ARMED bracket.
39    pub pid: u32,
40    /// Which face the drain ended on.
41    pub outcome: ShutdownOutcome,
42    /// The drain window that governed the wait, in seconds.
43    pub drain_timeout_seconds: u64,
44    /// How many connected workers received the drain request.
45    pub delivered_drain_requests: usize,
46    /// Parked in-flight work, by worker and task name. Empty on a clean
47    /// drain.
48    pub parked: Vec<ParkedWorkerRecord>,
49    /// Declared-body commands still executing when the drain ended, as
50    /// `workflow/activity#attempt` names: server-run commands no worker holds,
51    /// ended with the server and re-dispatched by the next boot's replay.
52    /// Defaulted on read because records written before the declared census
53    /// existed — the death note is shared with older builds — carry no field,
54    /// and their honest reading is "none named", not a parse refusal.
55    #[serde(default)]
56    pub parked_declared_commands: Vec<String>,
57    /// Managed workers proven stopped (process group empty) at shutdown.
58    pub managed_workers_stopped: Vec<String>,
59    /// Managed workers that could NOT be proven stopped, by name — never
60    /// summed into a count that could read as calm.
61    pub managed_workers_unstopped: Vec<String>,
62}
63
64impl OutcomeRecord {
65    /// Build the record from what the drain observed, named where the
66    /// observation was made: workers by registry id, parked tasks as
67    /// `workflow/activity#attempt`.
68    #[must_use]
69    pub fn from_report(pid: u32, report: &crate::shutdown::ShutdownReport) -> Self {
70        Self {
71            pid,
72            outcome: report.outcome,
73            drain_timeout_seconds: report.drain_timeout.as_secs(),
74            delivered_drain_requests: report.delivered_drain_requests,
75            parked: report
76                .parked
77                .iter()
78                .map(|lost| ParkedWorkerRecord {
79                    worker: lost.worker_id.value().to_string(),
80                    queue: lost.task_queue.clone(),
81                    tasks: lost
82                        .tasks
83                        .iter()
84                        .map(|task| {
85                            format!("{}/{}#{}", task.workflow_id, task.activity_id, task.attempt)
86                        })
87                        .collect(),
88                })
89                .collect(),
90            parked_declared_commands: report
91                .parked_declared
92                .iter()
93                .map(|key| format!("{}/{}#{}", key.workflow_id, key.activity_id, key.attempt))
94                .collect(),
95            managed_workers_stopped: report.managed_workers_stopped.clone(),
96            managed_workers_unstopped: report.managed_workers_unstopped.clone(),
97        }
98    }
99}
100
101/// The entry keyword the record is written under in the death note.
102const OUTCOME_KIND: &str = "OUTCOME";
103
104/// Render the record as the death-note entry body (`OUTCOME <one-line json>`).
105///
106/// # Errors
107///
108/// Returns [`ServerError::DeathNote`] when the record cannot be serialized —
109/// which the caller reports and survives: failing the shutdown over its own
110/// receipt would invert the receipt's purpose.
111pub fn render_entry(record: &OutcomeRecord) -> Result<String, ServerError> {
112    let json = serde_json::to_string(record).map_err(|serialize_error| ServerError::DeathNote {
113        message: format!("cannot serialize the shutdown outcome record: {serialize_error}"),
114    })?;
115    Ok(format!("{OUTCOME_KIND} {json}"))
116}
117
118/// What the death note says about the server incarnation that wore `pid`.
119#[derive(Clone, Debug, Eq, PartialEq)]
120pub enum NoteFate {
121    /// No death note exists under this home — nothing was ever armed here
122    /// (or the home predates the death-note era).
123    NoNote,
124    /// A note exists but holds no ARMED bracket for this pid.
125    NoBracketForPid,
126    /// The pid's last bracket is ARMED with no DISARMED: the process was
127    /// destroyed without the run loop seeing it (`SIGKILL` class) — or is
128    /// still running. The caller knows which, from its own liveness probe.
129    ArmedNotDisarmed {
130        /// The outcome record, if the drain got far enough to write one
131        /// before the process was destroyed.
132        outcome: Option<OutcomeRecord>,
133        /// An `OUTCOME` line that is PRESENT in the bracket but that this
134        /// binary could not parse — a torn line, or a record written by a
135        /// build whose schema this one cannot read. Distinct from `outcome:
136        /// None` because "the drain never wrote a record" and "the drain
137        /// wrote a record I cannot read" are different facts, and reporting
138        /// the second as the first sends the operator down the wrong path.
139        outcome_unreadable: Option<String>,
140    },
141    /// The bracket closed by ordinary control flow.
142    Disarmed {
143        /// The drain's recorded outcome; `None` when the exit wrote no
144        /// record (an error return before serving, or a pre-record binary),
145        /// reported as exactly that absence.
146        outcome: Option<OutcomeRecord>,
147        /// An unparseable `OUTCOME` line in the bracket — the unreadable
148        /// presence, kept distinct from the honest absence exactly as on
149        /// [`NoteFate::ArmedNotDisarmed`].
150        outcome_unreadable: Option<String>,
151        /// The DISARMED line's reason text, for the report.
152        reason: String,
153    },
154}
155
156/// Read the death note under `home` and classify what it records for `pid`.
157///
158/// # Errors
159///
160/// Returns [`ServerError::DeathNote`] when the note exists but cannot be read.
161/// A malformed line inside the note is skipped, never fatal: the note is an
162/// append-only log a panicking process writes into, and one torn line must
163/// not make every other entry unreadable.
164pub fn read_fate(home: &Path, pid: u32) -> Result<NoteFate, ServerError> {
165    let path = crate::death_note::note_path(home);
166    let content = match std::fs::read_to_string(&path) {
167        Ok(content) => content,
168        Err(io_error) if io_error.kind() == std::io::ErrorKind::NotFound => {
169            return Ok(NoteFate::NoNote);
170        }
171        Err(io_error) => {
172            return Err(ServerError::DeathNote {
173                message: format!(
174                    "cannot read the death note `{}`: {io_error}",
175                    path.display()
176                ),
177            });
178        }
179    };
180    let armed_marker = format!("ARMED pid={pid} ");
181    let mut bracket_start: Option<usize> = None;
182    let lines: Vec<&str> = content.lines().collect();
183    for (index, line) in lines.iter().enumerate() {
184        if entry_body(line).is_some_and(|body| body.starts_with(&armed_marker)) {
185            bracket_start = Some(index);
186        }
187    }
188    let Some(start) = bracket_start else {
189        return Ok(NoteFate::NoBracketForPid);
190    };
191    let mut outcome: Option<OutcomeRecord> = None;
192    let mut outcome_unreadable: Option<String> = None;
193    let mut disarmed_reason: Option<String> = None;
194    for line in &lines[start + 1..] {
195        let Some(body) = entry_body(line) else {
196            continue;
197        };
198        if let Some(json) = body.strip_prefix("OUTCOME ") {
199            // A record for another pid inside this bracket would mean a
200            // second writer (the note's ARMED_ONCE guard makes it
201            // unreachable in practice) and is ignored. A line that does not
202            // parse — torn by a kill mid-write, or written by a build whose
203            // schema this binary cannot read — is carried as the UNREADABLE
204            // presence, never collapsed into "no record": the operator told
205            // "the drain never got far enough to write one" over a record
206            // that exists is debugging the wrong incident.
207            // Both facts are kept in BOTH orders: a torn line beside a
208            // readable record is stated by the renderers ("a readable
209            // record does not un-happen the torn line beside it"), and the
210            // reader must not pre-empt that rule by erasing whichever fact
211            // arrived first.
212            match serde_json::from_str::<OutcomeRecord>(json) {
213                Ok(record) if record.pid == pid => outcome = Some(record),
214                Ok(_other_pid) => {}
215                Err(parse_error) => {
216                    outcome_unreadable =
217                        Some(format!("an OUTCOME line does not parse: {parse_error}"));
218                }
219            }
220        } else if let Some(reason) = body.strip_prefix("DISARMED ") {
221            disarmed_reason = Some(reason.to_owned());
222            break;
223        } else if body.starts_with("ARMED pid=") {
224            // A newer incarnation's bracket begins; ours never closed.
225            break;
226        }
227    }
228    Ok(match disarmed_reason {
229        Some(reason) => NoteFate::Disarmed {
230            outcome,
231            outcome_unreadable,
232            reason,
233        },
234        None => NoteFate::ArmedNotDisarmed {
235            outcome,
236            outcome_unreadable,
237        },
238    })
239}
240
241/// Strip the leading RFC 3339 timestamp from a note line, returning the
242/// entry body. `None` for lines that do not carry a timestamped entry.
243fn entry_body(line: &str) -> Option<&str> {
244    let (timestamp, body) = line.split_once(' ')?;
245    // Cheap shape check, not a full parse: every writer-emitted line starts
246    // with an RFC 3339 instant, and a torn line must simply be skipped.
247    if timestamp.len() >= 20
248        && timestamp
249            .chars()
250            .take(4)
251            .all(|character| character.is_ascii_digit())
252    {
253        Some(body)
254    } else {
255        None
256    }
257}
258
259#[cfg(test)]
260#[path = "outcome_tests.rs"]
261mod tests;