pointlock_store/lease.rs
1//! The advisory per-run writer lease (07 §3.3 rule 5).
2//!
3//! The ledger is single-writer (I1), but a `running` status alone cannot
4//! say whether a writer is alive or died mid-segment without writing
5//! `runSuspended`. Liveness is therefore a separate primitive: a
6//! non-blocking exclusive `flock` on `<root>/locks/<run>.lock`, taken by
7//! every writing segment ([`pointlock_runner`](https://docs.rs/pointlock-runner)'s
8//! `run`/`resume`) before its first append and held until the segment
9//! returns. The kernel releases `flock` when the holder dies, so:
10//!
11//! - lease held → a live writer; every other writer must refuse
12//! ([`StoreError::WriterBusy`]);
13//! - lease free + status `running` → the previous segment crashed; the
14//! run is resumable without any operator vouch.
15//!
16//! **Advisory and filesystem-bound.** `flock` is advisory (a writer that
17//! skips it is not stopped) and is unreliable on network filesystems
18//! (NFS, SMB): there it may error, or — worse — grant two holders. Keep
19//! stores on local disks; `pointlock resume --force-stale-writer` is the
20//! escape hatch for filesystems where the lock lies.
21//!
22//! The guard's `Drop` calls `unlock` explicitly before the descriptor
23//! closes: a close-only release can be pinned by a child process that
24//! inherited a duplicate of the descriptor (`inspect --serve` spawns
25//! children), which would make a dead segment look alive.
26
27use std::fs::{File, OpenOptions};
28use std::path::{Path, PathBuf};
29
30use fs2::FileExt;
31
32use crate::error::StoreError;
33
34/// An acquired per-run writer lease; dropping it releases the lock.
35#[derive(Debug)]
36pub struct WriterLease {
37 file: File,
38 run_id: String,
39}
40
41/// `<root>/locks/<run>.lock`. Run ids are free-form strings, so any byte
42/// outside `[A-Za-z0-9._-]` is mapped to `_`: two exotic ids could share a
43/// file, which only errs on the refusing side (a spurious `WriterBusy`,
44/// escapable with `--force-stale-writer`), never on the permissive one.
45pub fn lock_path(root: &Path, run_id: &str) -> PathBuf {
46 let safe: String = run_id
47 .chars()
48 .map(|c| {
49 if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
50 c
51 } else {
52 '_'
53 }
54 })
55 .collect();
56 root.join("locks").join(format!("{safe}.lock"))
57}
58
59fn open_lock_file(root: &Path, run_id: &str) -> Result<File, StoreError> {
60 let path = lock_path(root, run_id);
61 if let Some(parent) = path.parent() {
62 std::fs::create_dir_all(parent)?;
63 }
64 Ok(OpenOptions::new()
65 .read(true)
66 .write(true)
67 .create(true)
68 .truncate(false)
69 .open(path)?)
70}
71
72impl WriterLease {
73 /// Takes the run's lease without blocking. A lease held elsewhere —
74 /// another process, or another handle in this one — is
75 /// [`StoreError::WriterBusy`]; any other lock failure surfaces as
76 /// [`StoreError::Io`].
77 pub fn acquire(root: impl AsRef<Path>, run_id: &str) -> Result<Self, StoreError> {
78 let file = open_lock_file(root.as_ref(), run_id)?;
79 match FileExt::try_lock_exclusive(&file) {
80 Ok(()) => Ok(WriterLease {
81 file,
82 run_id: run_id.to_owned(),
83 }),
84 Err(err) if err.kind() == fs2::lock_contended_error().kind() => {
85 Err(StoreError::WriterBusy {
86 run_id: run_id.to_owned(),
87 })
88 }
89 Err(err) => Err(err.into()),
90 }
91 }
92
93 /// Probes liveness: `true` iff some holder currently has the lease.
94 /// The probe try-locks and immediately unlocks + closes; it never
95 /// holds across anything else and creates nothing: a lease can only
96 /// be held on an existing lock file, so a missing file is `false`
97 /// without touching the filesystem. A lock file that exists but
98 /// cannot be opened answers `true` (fail closed: a probe that cannot
99 /// see must not vouch).
100 pub fn is_held(root: impl AsRef<Path>, run_id: &str) -> bool {
101 let file = match OpenOptions::new()
102 .read(true)
103 .write(true)
104 .open(lock_path(root.as_ref(), run_id))
105 {
106 Ok(file) => file,
107 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return false,
108 Err(_) => return true,
109 };
110 match FileExt::try_lock_exclusive(&file) {
111 Ok(()) => {
112 let _ = FileExt::unlock(&file);
113 false
114 }
115 Err(_) => true,
116 }
117 }
118
119 /// The run this lease guards.
120 pub fn run_id(&self) -> &str {
121 &self.run_id
122 }
123}
124
125impl Drop for WriterLease {
126 fn drop(&mut self) {
127 // Explicit release (see the module docs): close alone may not
128 // drop the lock when a child holds a duplicate descriptor. The
129 // fully qualified call pins fs2's method (std grew an inherent
130 // `File::unlock` in 1.89, above this workspace's MSRV).
131 let _ = FileExt::unlock(&self.file);
132 }
133}