layover_tower/state.rs
1//! Knowing a run existed, even if nothing was watching when it ended.
2//!
3//! # Why this is written before the process starts
4//!
5//! A run that was alive when the supervisor died leaves no exit code, no final record, and no
6//! trace in anything the supervisor holds in memory. The only way to know it existed is to have
7//! written that down first — and the only way to know whether it is *still* alive is to have
8//! recorded enough to check.
9//!
10//! Writing the record after spawning would leave a window in which a real process is running, may
11//! be spending money, and nothing knows about it. That window is precisely where a crash falls.
12//!
13//! # Why the start time is recorded alongside the process identifier
14//!
15//! Operating systems reuse process identifiers. A supervisor that restarts an hour later, finds a
16//! recorded identifier, asks the system whether it is alive and is told yes, may be looking at an
17//! entirely unrelated program that happened to inherit the number.
18//!
19//! Recovering into that mistake is worse than not recovering: the supervisor would decide a run is
20//! still going, wait for it, and wait forever. So the record carries when the process began, and a
21//! match requires both.
22
23use std::fs;
24use std::io;
25use std::path::{Path, PathBuf};
26
27use jiff::Timestamp;
28use layover_core::agent::AgentName;
29use layover_core::flight::{ItineraryId, RunId};
30use serde::{Deserialize, Serialize};
31
32/// A run the supervisor started and has not yet seen finish.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct Live {
35 /// Which run this is.
36 pub run: RunId,
37 /// The chain it belongs to.
38 pub itinerary: ItineraryId,
39 /// Which agent is running.
40 pub agent: AgentName,
41 /// The operating system's identifier for the child.
42 pub pid: u32,
43 /// When the supervisor started it.
44 pub started_at: Timestamp,
45 /// Where the run's files are.
46 pub hangar: PathBuf,
47}
48
49/// Where live-run records are kept.
50///
51/// One file per run rather than one file with many lines: a run ending is a deletion, and deleting
52/// a file is atomic in a way that rewriting a shared file after a crash is not.
53#[derive(Debug, Clone)]
54pub struct Ledger {
55 root: PathBuf,
56}
57
58impl Ledger {
59 /// Opens — and creates — the directory live records are kept in.
60 ///
61 /// # Errors
62 ///
63 /// Returns an error when the directory cannot be created.
64 pub fn open(root: impl Into<PathBuf>) -> io::Result<Self> {
65 let root = root.into();
66 fs::create_dir_all(&root)?;
67 Ok(Self { root })
68 }
69
70 /// Records that a run is about to start.
71 ///
72 /// Call this **before** spawning. The record is the only evidence the run existed if the
73 /// supervisor does not survive to write another.
74 ///
75 /// # Errors
76 ///
77 /// Returns an error when the record cannot be written.
78 pub fn starting(&self, live: &Live) -> io::Result<()> {
79 let text = serde_json::to_string(live).map_err(io::Error::other)?;
80 fs::write(self.path_for(&live.run), text)
81 }
82
83 /// Forgets a run that has been seen to finish.
84 ///
85 /// # Errors
86 ///
87 /// Returns an error when the record exists and cannot be removed.
88 pub fn finished(&self, run: &RunId) -> io::Result<()> {
89 match fs::remove_file(self.path_for(run)) {
90 Ok(()) => Ok(()),
91 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
92 Err(error) => Err(error),
93 }
94 }
95
96 /// Every run recorded as started and not recorded as finished.
97 ///
98 /// # Errors
99 ///
100 /// Returns an error when the directory cannot be read.
101 pub fn live(&self) -> io::Result<Vec<Live>> {
102 let mut found = Vec::new();
103
104 for entry in fs::read_dir(&self.root)? {
105 let path = entry?.path();
106 if path.extension().is_none_or(|ext| ext != "json") {
107 continue;
108 }
109
110 // A record that cannot be parsed is skipped rather than fatal. It means a crash
111 // mid-write, and refusing to start because of one unreadable file would turn a lost
112 // run into a supervisor that will not run at all.
113 if let Ok(text) = fs::read_to_string(&path)
114 && let Ok(live) = serde_json::from_str::<Live>(&text)
115 {
116 found.push(live);
117 }
118 }
119
120 found.sort_by_key(|live| live.started_at);
121 Ok(found)
122 }
123
124 fn path_for(&self, run: &RunId) -> PathBuf {
125 self.root.join(format!("{run}.json"))
126 }
127
128 /// The directory records are kept in.
129 #[must_use]
130 pub fn root(&self) -> &Path {
131 &self.root
132 }
133}
134
135/// What reconciliation concluded about one recorded run.
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum Verdict {
138 /// The process is still running: same identifier, same start time.
139 StillRunning,
140 /// Nothing is running under that identifier. The run was interrupted and is recoverable.
141 ConfirmedGone,
142 /// Something is running under that identifier, but it did not start when this run did.
143 ///
144 /// The identifier was reused. The original run is gone, and the supervisor must not wait for
145 /// whatever inherited its number.
146 Reused,
147}
148
149impl Verdict {
150 /// Whether recovery may start a replacement for this run.
151 ///
152 /// Recovery requires the previous process to be *confirmed* gone. A reused identifier is also
153 /// gone — that is why it is a separate verdict rather than an error: the conclusion is the
154 /// same, the evidence is different, and saying so makes the log readable.
155 #[must_use]
156 pub const fn is_recoverable(self) -> bool {
157 matches!(self, Self::ConfirmedGone | Self::Reused)
158 }
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164
165 struct Temp(PathBuf);
166
167 impl Temp {
168 fn new(name: &str) -> Self {
169 let path =
170 std::env::temp_dir().join(format!("layover-state-{name}-{}", std::process::id()));
171 let _ = fs::remove_dir_all(&path);
172 Self(path)
173 }
174 }
175
176 impl Drop for Temp {
177 fn drop(&mut self) {
178 let _ = fs::remove_dir_all(&self.0);
179 }
180 }
181
182 fn live() -> Live {
183 Live {
184 run: RunId::generate(),
185 itinerary: ItineraryId::generate(),
186 agent: AgentName::new("tester"),
187 pid: 4242,
188 started_at: Timestamp::now(),
189 hangar: PathBuf::from("hangar"),
190 }
191 }
192
193 #[test]
194 fn a_started_run_is_readable_before_anything_else_happens() {
195 // This is the whole point: if the supervisor dies here, the record is what survives.
196 let temp = Temp::new("started");
197 let ledger = Ledger::open(&temp.0).expect("opens");
198 let record = live();
199
200 ledger.starting(&record).expect("records");
201
202 let found = ledger.live().expect("reads");
203 assert_eq!(found, vec![record]);
204 }
205
206 #[test]
207 fn a_finished_run_is_forgotten() {
208 let temp = Temp::new("finished");
209 let ledger = Ledger::open(&temp.0).expect("opens");
210 let record = live();
211
212 ledger.starting(&record).expect("records");
213 ledger.finished(&record.run).expect("forgets");
214
215 assert!(ledger.live().expect("reads").is_empty());
216 }
217
218 #[test]
219 fn forgetting_a_run_twice_is_not_an_error() {
220 // A supervisor that crashed between removing the record and writing the outcome will try
221 // again on restart, and refusing would leave it unable to start.
222 let temp = Temp::new("twice");
223 let ledger = Ledger::open(&temp.0).expect("opens");
224 let record = live();
225
226 ledger.starting(&record).expect("records");
227 ledger.finished(&record.run).expect("first");
228 ledger.finished(&record.run).expect("second");
229 }
230
231 #[test]
232 fn a_half_written_record_does_not_stop_the_supervisor_starting() {
233 // A crash mid-write leaves a truncated file. Refusing to start because of it would turn
234 // one lost run into a factory that will not run at all.
235 let temp = Temp::new("corrupt");
236 let ledger = Ledger::open(&temp.0).expect("opens");
237 ledger.starting(&live()).expect("records");
238 fs::write(temp.0.join("run_bad.json"), "{\"run\":").expect("writes rubbish");
239
240 let found = ledger.live().expect("reads");
241 assert_eq!(found.len(), 1, "the readable record still arrives");
242 }
243
244 #[test]
245 fn live_runs_come_back_oldest_first() {
246 let temp = Temp::new("order");
247 let ledger = Ledger::open(&temp.0).expect("opens");
248
249 let mut first = live();
250 first.started_at = Timestamp::now()
251 .checked_sub(jiff::SignedDuration::from_hours(2))
252 .expect("in range");
253 let second = live();
254
255 ledger.starting(&second).expect("records");
256 ledger.starting(&first).expect("records");
257
258 let found = ledger.live().expect("reads");
259 assert_eq!(found[0].run, first.run, "the longest-running is first");
260 }
261
262 #[test]
263 fn a_reused_identifier_is_still_recoverable_but_says_why() {
264 assert!(Verdict::ConfirmedGone.is_recoverable());
265 assert!(Verdict::Reused.is_recoverable());
266 assert!(!Verdict::StillRunning.is_recoverable());
267 }
268}