1use 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
24pub 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct Holder {
46 pub run_id: String,
47 pub host: String,
48 pub pid: u32,
49 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 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#[derive(Debug)]
74pub enum LockError {
75 Held(Holder),
77 Unreadable(PathBuf),
79 Io(std::io::Error),
80}
81
82#[derive(Debug)]
84pub struct Guard {
85 path: PathBuf,
86 run_id: String,
87 pub took_over: Option<Holder>,
89}
90
91impl Drop for Guard {
92 fn drop(&mut self) {
93 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#[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 pub fn run_dir(&self, run_id: &str) -> PathBuf {
121 self.dir.join(RUNS).join(run_id)
122 }
123
124 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 pub fn current_dir(&self) -> Option<PathBuf> {
133 self.current().map(|id| self.run_dir(&id))
134 }
135
136 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 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 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 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 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 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 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 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
275pub 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 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
299pub fn process_alive(pid: u32) -> bool {
301 #[cfg(unix)]
302 {
303 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 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}