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;