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, its entries carry pid frames, and none of them is an
125    /// `ARMED` for this pid.
126    NoBracketForPid,
127    /// The note holds entries this build cannot attribute to ANY pid: they
128    /// carry no `pid=` frame, which means a server older than the per-pid
129    /// framing wrote them.
130    ///
131    /// Reported rather than guessed. The alternative — scanning the file for
132    /// a bracket and hoping the lines between belong to it — is exactly the
133    /// defect the framing replaced: a refused boot's `ARMED` line landing
134    /// mid-bracket made `aion server stop` report the `kill -9` shape about a
135    /// clean drain. Nothing is inferred from an unframed line; the note is on
136    /// disk and readable by eye, and this face says so.
137    Unattributable {
138        /// How many timestamped entries carry no pid frame.
139        untagged_entries: usize,
140    },
141    /// The pid's last bracket is ARMED with no DISARMED: the process was
142    /// destroyed without the run loop seeing it (`SIGKILL` class) — or is
143    /// still running. The caller knows which, from its own liveness probe.
144    ArmedNotDisarmed {
145        /// The outcome record, if the drain got far enough to write one
146        /// before the process was destroyed.
147        outcome: Option<OutcomeRecord>,
148        /// An `OUTCOME` line that is PRESENT in the bracket but that this
149        /// binary could not parse — a torn line, or a record written by a
150        /// build whose schema this one cannot read. Distinct from `outcome:
151        /// None` because "the drain never wrote a record" and "the drain
152        /// wrote a record I cannot read" are different facts, and reporting
153        /// the second as the first sends the operator down the wrong path.
154        outcome_unreadable: Option<String>,
155    },
156    /// The bracket closed by ordinary control flow.
157    Disarmed {
158        /// The drain's recorded outcome; `None` when the exit wrote no
159        /// record (an error return before serving, or a pre-record binary),
160        /// reported as exactly that absence.
161        outcome: Option<OutcomeRecord>,
162        /// An unparseable `OUTCOME` line in the bracket — the unreadable
163        /// presence, kept distinct from the honest absence exactly as on
164        /// [`NoteFate::ArmedNotDisarmed`].
165        outcome_unreadable: Option<String>,
166        /// The DISARMED line's reason text, for the report.
167        reason: String,
168    },
169}
170
171/// Read the death note under `home` and classify what it records for `pid`.
172///
173/// Entries are SELECTED by their `pid=` frame. Nothing is inferred from an
174/// entry that carries no frame — that is a
175/// [`NoteFate::Unattributable`] answer, not a guess.
176///
177/// # Errors
178///
179/// Returns [`ServerError::DeathNote`] when the note exists but cannot be read.
180/// A malformed line inside the note is skipped, never fatal: the note is an
181/// append-only log a panicking process writes into, and one torn line must
182/// not make every other entry unreadable.
183pub fn read_fate(home: &Path, pid: u32) -> Result<NoteFate, ServerError> {
184    let path = crate::death_note::note_path(home);
185    let content = match std::fs::read_to_string(&path) {
186        Ok(content) => content,
187        Err(io_error) if io_error.kind() == std::io::ErrorKind::NotFound => {
188            return Ok(NoteFate::NoNote);
189        }
190        Err(io_error) => {
191            return Err(ServerError::DeathNote {
192                message: format!(
193                    "cannot read the death note `{}`: {io_error}",
194                    path.display()
195                ),
196            });
197        }
198    };
199    // SELECT by pid, never scan a bracket. One home's note is shared by every
200    // server that ever ran against it, and their lives interleave routinely —
201    // a refused boot arms and abandons between a live server's SIGNAL and its
202    // OUTCOME. Filtering first makes the interleaving irrelevant instead of
203    // rare.
204    let mut untagged_entries = 0_usize;
205    let mut ours: Vec<&str> = Vec::new();
206    for line in content.lines() {
207        // A line with no timestamp is a CONTINUATION (a PANIC entry embeds a
208        // multi-line backtrace) or a torn write. Neither is an entry, and
209        // neither is evidence of an old format.
210        let Some(body) = timestamped_body(line) else {
211            continue;
212        };
213        match split_pid_frame(body) {
214            Some((entry_pid, entry)) => {
215                if entry_pid == pid {
216                    ours.push(entry);
217                }
218            }
219            None => untagged_entries += 1,
220        }
221    }
222    // The LAST ARMED for this pid: a note outlives pid recycling, and only the
223    // most recent bracket describes the incarnation a caller is asking about.
224    let Some(start) = ours.iter().rposition(|entry| entry.starts_with("ARMED ")) else {
225        if untagged_entries > 0 {
226            return Ok(NoteFate::Unattributable { untagged_entries });
227        }
228        return Ok(NoteFate::NoBracketForPid);
229    };
230    let mut outcome: Option<OutcomeRecord> = None;
231    let mut outcome_unreadable: Option<String> = None;
232    let mut disarmed_reason: Option<String> = None;
233    for entry in &ours[start + 1..] {
234        if let Some(json) = entry.strip_prefix("OUTCOME ") {
235            // A line that does not parse — torn by a kill mid-write, or
236            // written by a build whose schema this binary cannot read — is
237            // carried as the UNREADABLE presence, never collapsed into "no
238            // record": an operator told "the drain never got far enough to
239            // write one" over a record that exists is debugging the wrong
240            // incident.
241            // Both facts are kept in BOTH orders: a torn line beside a
242            // readable record is stated by the renderers ("a readable record
243            // does not un-happen the torn line beside it"), and the reader
244            // must not pre-empt that rule by erasing whichever fact arrived
245            // first.
246            match serde_json::from_str::<OutcomeRecord>(json) {
247                Ok(record) if record.pid == pid => outcome = Some(record),
248                // The frame says this pid and the record says another. With
249                // per-pid framing that cannot happen by interleaving, so it is
250                // a torn or hand-edited line — reported, never silently
251                // dropped, because a disagreement between two spellings of one
252                // fact is exactly what a reader needs told.
253                Ok(other) => {
254                    outcome_unreadable = Some(format!(
255                        "an OUTCOME line framed for pid {pid} carries a record for pid {}",
256                        other.pid
257                    ));
258                }
259                Err(parse_error) => {
260                    outcome_unreadable =
261                        Some(format!("an OUTCOME line does not parse: {parse_error}"));
262                }
263            }
264        } else if let Some(reason) = entry.strip_prefix("DISARMED ") {
265            disarmed_reason = Some(reason.to_owned());
266            break;
267        }
268    }
269    Ok(match disarmed_reason {
270        Some(reason) => NoteFate::Disarmed {
271            outcome,
272            outcome_unreadable,
273            reason,
274        },
275        None => NoteFate::ArmedNotDisarmed {
276            outcome,
277            outcome_unreadable,
278        },
279    })
280}
281
282/// Split a framed entry body into its pid and the entry itself.
283///
284/// The frame is `pid=<digits> <ENTRY>`. `None` for a body without one: an
285/// entry written by a server older than the per-pid framing, which this build
286/// reports as unattributable rather than guessing onto a pid.
287fn split_pid_frame(body: &str) -> Option<(u32, &str)> {
288    let tagged = body.strip_prefix("pid=")?;
289    let (digits, entry) = tagged.split_once(' ')?;
290    Some((digits.parse().ok()?, entry))
291}
292
293/// Strip the leading RFC 3339 timestamp from a note line, returning the
294/// entry body (which still carries its `pid=` frame). `None` for lines that
295/// do not carry a timestamped entry — a PANIC entry's backtrace continuation
296/// lines, or a torn write.
297fn timestamped_body(line: &str) -> Option<&str> {
298    let (timestamp, body) = line.split_once(' ')?;
299    // Cheap shape check, not a full parse: every writer-emitted line starts
300    // with an RFC 3339 instant, and a torn line must simply be skipped.
301    if timestamp.len() >= 20
302        && timestamp
303            .chars()
304            .take(4)
305            .all(|character| character.is_ascii_digit())
306    {
307        Some(body)
308    } else {
309        None
310    }
311}
312
313#[cfg(test)]
314#[path = "outcome_tests.rs"]
315mod tests;