Skip to main content

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}