Skip to main content

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}