Skip to main content

dre_core/
runs.rs

1//! A Binding's run folders, so overlapping and interrupted runs are safe:
2//!
3//! ```text
4//! target/run/<report>/<binding>/
5//!   current        the run id of the latest finished run (written atomically)
6//!   lock           while a run holds the Binding: its run id, host, process and start time
7//!   runs/<run id>/ one folder per run: its outputs and run_results.json
8//! ```
9//!
10//! Each run writes only into its own folder and delivers from there. At the end `current` is
11//! switched to it, unless a run for a later instant is already current (an older run never
12//! replaces a newer one). The same Binding can't run twice at once: the second run finds the
13//! `lock` and stops without touching anything. A crash leaves the previous run current.
14
15use std::path::{Path, PathBuf};
16
17use chrono::{DateTime, Utc};
18use serde::{Deserialize, Serialize};
19
20pub const CURRENT: &str = "current";
21pub const LOCK: &str = "lock";
22pub const RUNS: &str = "runs";
23
24/// A run id: the UTC start time and four random characters, `20261009T060000Z-k3f9`. Ids sort by
25/// start time.
26pub fn new_run_id(started: DateTime<Utc>) -> String {
27    let nanos = std::time::SystemTime::now()
28        .duration_since(std::time::UNIX_EPOCH)
29        .map(|d| d.subsec_nanos())
30        .unwrap_or(0);
31    let mut n = nanos ^ std::process::id().wrapping_mul(2_654_435_761);
32    const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz234567";
33    let suffix: String = (0..4)
34        .map(|_| {
35            let c = ALPHABET[(n % 32) as usize] as char;
36            n /= 32;
37            c
38        })
39        .collect();
40    format!("{}-{suffix}", started.format("%Y%m%dT%H%M%SZ"))
41}
42
43/// Who holds a Binding's lock.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct Holder {
46    pub run_id: String,
47    pub host: String,
48    pub pid: u32,
49    /// RFC 3339.
50    pub started_at: String,
51}
52
53impl Holder {
54    fn me(run_id: &str, started: DateTime<Utc>) -> Holder {
55        Holder {
56            run_id: run_id.to_string(),
57            host: host_name(),
58            pid: std::process::id(),
59            started_at: started.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
60        }
61    }
62
63    /// `run 20261009T060000Z-k3f9, started 2026-10-09T06:00:00Z on build-7 (process 4312)`.
64    pub fn describe(&self) -> String {
65        format!(
66            "run {}, started {} on {} (process {})",
67            self.run_id, self.started_at, self.host, self.pid
68        )
69    }
70}
71
72/// Why a Binding's lock couldn't be taken.
73#[derive(Debug)]
74pub enum LockError {
75    /// Another run holds it (on this host and alive, or on another host).
76    Held(Holder),
77    /// The lock file is there but unreadable (a crash mid-write, a hand edit).
78    Unreadable(PathBuf),
79    Io(std::io::Error),
80}
81
82/// Holds a Binding's lock; dropping it releases the lock.
83#[derive(Debug)]
84pub struct Guard {
85    path: PathBuf,
86    run_id: String,
87    /// A stale lock (its process gone) was taken over.
88    pub took_over: Option<Holder>,
89}
90
91impl Drop for Guard {
92    fn drop(&mut self) {
93        // Only our own lock: never one another run took over meanwhile.
94        if let Ok(text) = std::fs::read_to_string(&self.path)
95            && serde_json::from_str::<Holder>(&text).is_ok_and(|h| h.run_id == self.run_id)
96        {
97            let _ = std::fs::remove_file(&self.path);
98        }
99    }
100}
101
102/// One Binding's folder in `target/run/`.
103#[derive(Debug, Clone)]
104pub struct BindingRuns {
105    dir: PathBuf,
106}
107
108impl BindingRuns {
109    pub fn new(dir: &Path) -> BindingRuns {
110        BindingRuns {
111            dir: dir.to_path_buf(),
112        }
113    }
114
115    pub fn dir(&self) -> &Path {
116        &self.dir
117    }
118
119    /// A run's folder.
120    pub fn run_dir(&self, run_id: &str) -> PathBuf {
121        self.dir.join(RUNS).join(run_id)
122    }
123
124    /// The current run's id.
125    pub fn current(&self) -> Option<String> {
126        let id = std::fs::read_to_string(self.dir.join(CURRENT)).ok()?;
127        let id = id.trim();
128        (!id.is_empty() && self.run_dir(id).is_dir()).then(|| id.to_string())
129    }
130
131    /// The current run's folder.
132    pub fn current_dir(&self) -> Option<PathBuf> {
133        self.current().map(|id| self.run_dir(&id))
134    }
135
136    /// Every run's id, oldest first.
137    pub fn list(&self) -> Vec<String> {
138        let mut ids: Vec<String> = std::fs::read_dir(self.dir.join(RUNS))
139            .into_iter()
140            .flatten()
141            .flatten()
142            .filter(|e| e.path().is_dir())
143            .map(|e| e.file_name().to_string_lossy().to_string())
144            .collect();
145        ids.sort();
146        ids
147    }
148
149    /// The lock's holder, if any.
150    pub fn holder(&self) -> Option<Holder> {
151        let text = std::fs::read_to_string(self.dir.join(LOCK)).ok()?;
152        serde_json::from_str(&text).ok()
153    }
154
155    /// Take the Binding's lock for `run_id`. A lock left by a process on this host that has
156    /// gone is taken over (the guard says so); one held by a live process, or by any process on
157    /// another host (a shared folder), is [`LockError::Held`]: DRE never guesses about a run it
158    /// can't see.
159    pub fn lock(&self, run_id: &str, started: DateTime<Utc>) -> Result<Guard, LockError> {
160        std::fs::create_dir_all(&self.dir).map_err(LockError::Io)?;
161        let path = self.dir.join(LOCK);
162        let me = Holder::me(run_id, started);
163        let body = serde_json::to_string_pretty(&me).expect("a holder serializes") + "\n";
164        let mut took_over = None;
165        for _ in 0..2 {
166            match std::fs::OpenOptions::new()
167                .write(true)
168                .create_new(true)
169                .open(&path)
170            {
171                Ok(mut f) => {
172                    use std::io::Write;
173                    f.write_all(body.as_bytes()).map_err(LockError::Io)?;
174                    f.sync_all().map_err(LockError::Io)?;
175                    return Ok(Guard {
176                        path,
177                        run_id: run_id.to_string(),
178                        took_over,
179                    });
180                }
181                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
182                    let text = std::fs::read_to_string(&path).map_err(LockError::Io)?;
183                    let Ok(holder) = serde_json::from_str::<Holder>(&text) else {
184                        return Err(LockError::Unreadable(path));
185                    };
186                    if holder.host != me.host || process_alive(holder.pid) {
187                        return Err(LockError::Held(holder));
188                    }
189                    // Its process is gone: the run crashed or was killed.
190                    let _ = std::fs::remove_file(&path);
191                    took_over = Some(holder);
192                }
193                Err(e) => return Err(LockError::Io(e)),
194            }
195        }
196        Err(LockError::Io(std::io::Error::other(
197            "another run took the lock at the same moment",
198        )))
199    }
200
201    /// Remove the lock whatever holds it (`dre unlock`). Returns the holder it removed.
202    pub fn unlock(&self) -> std::io::Result<Option<Holder>> {
203        let holder = self.holder();
204        match std::fs::remove_file(self.dir.join(LOCK)) {
205            Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e),
206            _ => Ok(holder),
207        }
208    }
209
210    /// Point `current` at `run_id`.
211    pub fn switch(&self, run_id: &str) -> std::io::Result<()> {
212        crate::fs::write_atomic(&self.dir.join(CURRENT), format!("{run_id}\n").as_bytes())
213    }
214
215    /// Move a folder from before run folders (outputs and `run_results.json` straight in the
216    /// Binding's folder) into `runs/<started>-legacy/` and make it current, so its drift history
217    /// and results carry over. Call it holding the lock. Returns the new run id, if there was
218    /// anything to move. `before` is the new run's start: the legacy run is named for an earlier
219    /// time, so it always sorts (and is pruned) as older.
220    pub fn migrate_legacy(&self, before: DateTime<Utc>) -> std::io::Result<Option<String>> {
221        let Ok(entries) = std::fs::read_dir(&self.dir) else {
222            return Ok(None);
223        };
224        let old: Vec<PathBuf> = entries
225            .flatten()
226            .map(|e| e.path())
227            .filter(|p| {
228                let name = p
229                    .file_name()
230                    .map(|n| n.to_string_lossy().to_string())
231                    .unwrap_or_default();
232                ![CURRENT, LOCK, RUNS].contains(&name.as_str())
233                    && !(name.starts_with('.') && name.ends_with(".tmp"))
234            })
235            .collect();
236        if old.is_empty() {
237            return Ok(None);
238        }
239        let started = std::fs::metadata(self.dir.join("run_results.json"))
240            .and_then(|m| m.modified())
241            .map(DateTime::<Utc>::from)
242            .unwrap_or(before)
243            .min(before - chrono::Duration::seconds(1));
244        let id = format!("{}-legacy", started.format("%Y%m%dT%H%M%SZ"));
245        let to = self.run_dir(&id);
246        std::fs::create_dir_all(&to)?;
247        for p in old {
248            std::fs::rename(&p, to.join(p.file_name().unwrap()))?;
249        }
250        if self.current().is_none() {
251            self.switch(&id)?;
252        }
253        Ok(Some(id))
254    }
255
256    /// Remove all but the newest `keep` runs (of any status, finished or not), never the current
257    /// run. Call it holding the lock.
258    pub fn prune(&self, keep: usize) -> Vec<String> {
259        let current = self.current();
260        let ids = self.list();
261        let cut = ids.len().saturating_sub(keep);
262        let mut removed = Vec::new();
263        for id in &ids[..cut] {
264            if Some(id) == current.as_ref() {
265                continue;
266            }
267            if std::fs::remove_dir_all(self.run_dir(id)).is_ok() {
268                removed.push(id.clone());
269            }
270        }
271        removed
272    }
273}
274
275/// This machine's name, for the lock: `DRE_HOST_NAME` if set (containers that share a target
276/// folder and reuse names), else the OS's.
277pub fn host_name() -> String {
278    if let Ok(v) = std::env::var("DRE_HOST_NAME")
279        && !v.trim().is_empty()
280    {
281        return v.trim().to_string();
282    }
283    #[cfg(unix)]
284    {
285        let mut buf = [0u8; 256];
286        // SAFETY: gethostname writes at most `buf.len()` bytes into the buffer.
287        if unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len()) } == 0 {
288            let end = buf.iter().position(|b| *b == 0).unwrap_or(buf.len());
289            return String::from_utf8_lossy(&buf[..end]).to_string();
290        }
291    }
292    #[cfg(windows)]
293    if let Ok(v) = std::env::var("COMPUTERNAME") {
294        return v;
295    }
296    "unknown".to_string()
297}
298
299/// Whether process `pid` on this machine is running.
300pub fn process_alive(pid: u32) -> bool {
301    #[cfg(unix)]
302    {
303        // SAFETY: signal 0 only checks that the process exists.
304        let r = unsafe { libc::kill(pid as libc::pid_t, 0) };
305        r == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
306    }
307    #[cfg(windows)]
308    {
309        use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE};
310        use windows_sys::Win32::System::Threading::{
311            GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
312        };
313        // SAFETY: the handle is checked and closed; the exit code is written into a local.
314        unsafe {
315            let h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
316            if h.is_null() {
317                return false;
318            }
319            let mut code = 0u32;
320            let ok = GetExitCodeProcess(h, &mut code) != 0;
321            CloseHandle(h);
322            ok && code == STILL_ACTIVE as u32
323        }
324    }
325    #[cfg(not(any(unix, windows)))]
326    {
327        let _ = pid;
328        true
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    fn runs() -> (tempfile::TempDir, BindingRuns) {
337        let d = tempfile::tempdir().unwrap();
338        let r = BindingRuns::new(&d.path().join("run/daily/default"));
339        (d, r)
340    }
341
342    #[test]
343    fn run_ids_sort_by_start_time() {
344        let a = new_run_id("2026-10-09T06:00:00Z".parse().unwrap());
345        let b = new_run_id("2026-10-09T06:05:00Z".parse().unwrap());
346        assert!(a.starts_with("20261009T060000Z-") && a.len() == 21, "{a}");
347        assert!(a < b);
348    }
349
350    #[test]
351    fn a_second_run_is_refused_while_the_first_holds_the_lock() {
352        let (_d, r) = runs();
353        let now = Utc::now();
354        let g = r.lock("a", now).unwrap();
355        match r.lock("b", now) {
356            Err(LockError::Held(h)) => {
357                assert_eq!(h.run_id, "a");
358                assert_eq!(h.pid, std::process::id());
359            }
360            other => panic!("{other:?}"),
361        }
362        drop(g);
363        assert!(r.holder().is_none());
364        r.lock("b", now).unwrap();
365    }
366
367    #[test]
368    fn a_dead_processs_lock_is_taken_over_but_another_hosts_never() {
369        let (_d, r) = runs();
370        std::fs::create_dir_all(r.dir()).unwrap();
371        let dead = Holder {
372            run_id: "old".into(),
373            host: host_name(),
374            pid: 999_999,
375            started_at: "2026-10-09T06:00:00Z".into(),
376        };
377        std::fs::write(r.dir().join(LOCK), serde_json::to_string(&dead).unwrap()).unwrap();
378        let g = r.lock("new", Utc::now()).unwrap();
379        assert_eq!(g.took_over.as_ref().map(|h| h.run_id.as_str()), Some("old"));
380        drop(g);
381        let elsewhere = Holder {
382            host: "some-other-host".into(),
383            ..dead
384        };
385        std::fs::write(r.dir().join(LOCK), serde_json::to_string(&elsewhere).unwrap()).unwrap();
386        assert!(matches!(r.lock("new", Utc::now()), Err(LockError::Held(_))));
387        assert_eq!(r.unlock().unwrap().unwrap().host, "some-other-host");
388        r.lock("new", Utc::now()).unwrap();
389    }
390
391    #[test]
392    fn current_switches_and_pruning_keeps_it() {
393        let (_d, r) = runs();
394        for id in [
395            "20261001T000000Z-aaaa",
396            "20261002T000000Z-bbbb",
397            "20261003T000000Z-cccc",
398        ] {
399            std::fs::create_dir_all(r.run_dir(id)).unwrap();
400        }
401        assert_eq!(r.current(), None);
402        r.switch("20261001T000000Z-aaaa").unwrap();
403        assert_eq!(r.current().as_deref(), Some("20261001T000000Z-aaaa"));
404        let removed = r.prune(1);
405        assert_eq!(removed, ["20261002T000000Z-bbbb"]);
406        assert_eq!(r.list(), ["20261001T000000Z-aaaa", "20261003T000000Z-cccc"]);
407    }
408
409    #[test]
410    fn an_old_layout_moves_into_a_legacy_run() {
411        let (_d, r) = runs();
412        std::fs::create_dir_all(r.dir()).unwrap();
413        std::fs::write(r.dir().join("daily.csv"), "a\n").unwrap();
414        std::fs::write(r.dir().join("run_results.json"), "{}").unwrap();
415        let id = r.migrate_legacy(Utc::now()).unwrap().unwrap();
416        assert!(id.ends_with("-legacy"));
417        assert_eq!(r.current().as_deref(), Some(id.as_str()));
418        assert!(r.run_dir(&id).join("daily.csv").is_file());
419        assert!(!r.dir().join("daily.csv").exists());
420        assert_eq!(r.migrate_legacy(Utc::now()).unwrap(), None);
421    }
422
423    #[test]
424    fn this_process_is_alive() {
425        assert!(process_alive(std::process::id()));
426        assert!(!process_alive(999_999));
427    }
428}