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;