Skip to main content

meerkat_sqlite/
fence.rs

1//! Per-operation maintenance-fence guards.
2//!
3//! The SQLite stores deliberately hold no long-lived connection (or, where
4//! they do, still perform discrete operations), so a fence honored only at
5//! realm open cannot quiesce them: a process that opened its store before the
6//! fence was acquired would keep writing straight through it. The fence is
7//! therefore enforced **per operation**: every store operation takes a
8//! [`OperationGuard`] (a shared OS file lock co-located with the database
9//! file), and offline migration takes the [`ExclusiveFence`] and waits for
10//! outstanding guards and in-flight operations to drain before touching
11//! bytes. Stores built on this crate get this for free — which is also why
12//! no store may roll its own opener.
13//!
14//! Lock files live next to the database as `<file>.mfence`. They are
15//! advisory OS locks (flock on unix, `LockFileEx` on Windows), cheap to take
16//! per operation, and correct across processes as well as between handles in
17//! one process.
18//!
19//! Fail-open rule: a medium where the lock file cannot be created (read-only
20//! snapshot mounts, for example) is a medium where no fence can be held
21//! either, so guard acquisition degrades to a no-op there. If the lock file
22//! exists and is exclusively held, acquisition fails with the typed
23//! [`SqliteStoreError::MaintenanceFenceHeld`] — storage is under offline
24//! maintenance and the operation must not proceed.
25//!
26//! The [`ConnectionProfile::Maintenance`](crate::profile::ConnectionProfile)
27//! profile is the fence *holder's* profile; maintenance work runs under the
28//! exclusive fence and does not take shared guards.
29
30use std::collections::HashSet;
31use std::fs::{File, OpenOptions};
32use std::path::{Path, PathBuf};
33use std::sync::{Mutex, OnceLock};
34use std::time::{Duration, Instant};
35
36use crate::error::SqliteStoreError;
37
38/// Lock paths exclusively held BY THIS PROCESS.
39///
40/// Holder self-admission: the maintenance verb holds the exclusive fence
41/// while reusing production store code paths in-process (bulk checkpoint
42/// adoption runs through the ordinary, CAS-hardened store machinery).
43/// Those operations must pass their own fence — and an OS-level shared
44/// re-lock from the same process would deadlock against the held exclusive
45/// lock — so guard acquisition consults this registry first.
46fn held_exclusive_locks() -> &'static Mutex<HashSet<PathBuf>> {
47    static REGISTRY: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
48    REGISTRY.get_or_init(|| Mutex::new(HashSet::new()))
49}
50
51fn registry_key(lock_path: &Path) -> PathBuf {
52    std::fs::canonicalize(lock_path).unwrap_or_else(|_| lock_path.to_path_buf())
53}
54
55fn process_holds_exclusive(lock_path: &Path) -> bool {
56    held_exclusive_locks()
57        .lock()
58        .map(|held| held.contains(&registry_key(lock_path)))
59        .unwrap_or(false)
60}
61
62/// Suffix appended to a database file name to form its fence lock file.
63pub const FENCE_LOCK_SUFFIX: &str = "mfence";
64
65/// The fence lock file for a database path: `<file>.mfence`.
66pub fn fence_lock_path(db_path: &Path) -> PathBuf {
67    let mut name = db_path.file_name().unwrap_or_default().to_os_string();
68    name.push(".");
69    name.push(FENCE_LOCK_SUFFIX);
70    db_path.with_file_name(name)
71}
72
73fn open_lock_file(lock_path: &Path) -> Option<File> {
74    // Fail-open: if the lock file can neither be opened nor created, no
75    // fence can exist on this medium either (see module docs).
76    OpenOptions::new()
77        .read(true)
78        .write(true)
79        .create(true)
80        .truncate(false)
81        .open(lock_path)
82        .ok()
83}
84
85/// RAII shared guard for one store operation on one database file.
86///
87/// Held for the duration of the operation; dropping it releases the lock.
88#[derive(Debug)]
89pub struct OperationGuard {
90    _lock: Option<File>,
91}
92
93impl OperationGuard {
94    /// Require actual descriptor-scoped shared custody for online administration.
95    /// Unlike ordinary I/O this has no maintenance-holder shortcut or fail-open
96    /// fallback. Validate the database before creating any lock sidecar.
97    pub fn try_for_database_strict(db_path: &Path) -> Result<Self, SqliteStoreError> {
98        if db_path.file_name().is_none() || db_path.as_os_str() == ":memory:" {
99            return Err(SqliteStoreError::Io(std::io::Error::new(
100                std::io::ErrorKind::InvalidInput,
101                "strict operation custody requires an existing physical database",
102            )));
103        }
104        crate::profile::validate_database_file(db_path)?;
105        if !db_path.is_file() {
106            return Err(SqliteStoreError::Io(std::io::Error::new(
107                std::io::ErrorKind::NotFound,
108                "strict operation database is missing",
109            )));
110        }
111        #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
112        {
113            Err(SqliteStoreError::Io(std::io::Error::new(
114                std::io::ErrorKind::Unsupported,
115                "strict operation custody requires descriptor-scoped locking",
116            )))
117        }
118        #[cfg(any(target_os = "macos", target_os = "linux", windows))]
119        {
120            let path = fence_lock_path(db_path);
121            crate::profile::validate_database_file(&path)?;
122            let file = OpenOptions::new()
123                .read(true)
124                .write(true)
125                .create(true)
126                .truncate(false)
127                .open(&path)?;
128            crate::profile::validate_database_file(&path)?;
129            if !file.metadata()?.is_file() {
130                return Err(SqliteStoreError::Io(std::io::Error::new(
131                    std::io::ErrorKind::Unsupported,
132                    "strict operation lock is not a regular file",
133                )));
134            }
135            match file.try_lock_shared() {
136                Ok(()) => Ok(Self { _lock: Some(file) }),
137                Err(error) if is_would_block(&error) => {
138                    Err(SqliteStoreError::MaintenanceFenceHeld {
139                        path: db_path.to_path_buf(),
140                    })
141                }
142                Err(error) => Err(SqliteStoreError::Io(std::io::Error::other(error))),
143            }
144        }
145    }
146
147    /// Acquire the shared guard for an operation on `db_path`.
148    ///
149    /// Returns [`SqliteStoreError::MaintenanceFenceHeld`] when the exclusive
150    /// maintenance fence is held. In-memory databases and media where no
151    /// lock file can exist yield a no-op guard.
152    pub fn for_database(db_path: &Path) -> Result<Self, SqliteStoreError> {
153        if db_path.file_name().is_none() || db_path.as_os_str() == ":memory:" {
154            return Ok(Self { _lock: None });
155        }
156        // Refuse an unsafe database file before planting its fence sibling.
157        crate::profile::validate_database_file(db_path)?;
158        let lock_path = fence_lock_path(db_path);
159        if process_holds_exclusive(&lock_path) {
160            // Fence-holder self-admission (see `held_exclusive_locks`).
161            return Ok(Self { _lock: None });
162        }
163        let Some(file) = open_lock_file(&lock_path) else {
164            return Ok(Self { _lock: None });
165        };
166        match file.try_lock_shared() {
167            Ok(()) => Ok(Self { _lock: Some(file) }),
168            Err(err) if is_would_block(&err) => Err(SqliteStoreError::MaintenanceFenceHeld {
169                path: db_path.to_path_buf(),
170            }),
171            // Filesystems without lock support: fail open, same rationale.
172            Err(_) => Ok(Self { _lock: None }),
173        }
174    }
175
176    /// Admit an operation that finds `db_path` missing and answers without
177    /// touching it. The fence covers its database file whether or not the
178    /// file exists (an archive by rename removes the file while the fence is
179    /// held), so a missing database under the exclusive fence of another
180    /// holder fails typed with [`SqliteStoreError::MaintenanceFenceHeld`],
181    /// as [`Self::for_database`] does. Nothing is created: the lock file is
182    /// opened only if it exists, and an absent lock file holds no fence. The
183    /// in-process fence holder is admitted, as by [`Self::for_database`].
184    pub fn admit_missing_database(db_path: &Path) -> Result<(), SqliteStoreError> {
185        if db_path.file_name().is_none() || db_path.as_os_str() == ":memory:" {
186            return Ok(());
187        }
188        let lock_path = fence_lock_path(db_path);
189        if process_holds_exclusive(&lock_path) {
190            return Ok(());
191        }
192        let Ok(file) = OpenOptions::new().read(true).write(true).open(&lock_path) else {
193            return Ok(());
194        };
195        match file.try_lock_shared() {
196            Err(err) if is_would_block(&err) => Err(SqliteStoreError::MaintenanceFenceHeld {
197                path: db_path.to_path_buf(),
198            }),
199            // Admitted, or a filesystem without lock support (fail open, as
200            // `for_database`). Dropping the file releases the shared lock.
201            Ok(()) | Err(_) => Ok(()),
202        }
203    }
204}
205
206/// The exclusive side of the maintenance fence for one database file.
207///
208/// Acquiring it waits for outstanding [`OperationGuard`]s to drain (bounded
209/// by `deadline`); holding it makes every subsequent guarded operation fail
210/// typed. The Phase 6 migration machinery composes one of these per database
211/// file it intends to touch, across both candidate roots.
212#[derive(Debug)]
213pub struct ExclusiveFence {
214    _lock: File,
215    lock_path: PathBuf,
216}
217
218impl ExclusiveFence {
219    /// Try to take the fence without waiting.
220    pub fn try_acquire(db_path: &Path) -> Result<Option<Self>, SqliteStoreError> {
221        let lock_path = fence_lock_path(db_path);
222        let file = OpenOptions::new()
223            .read(true)
224            .write(true)
225            .create(true)
226            .truncate(false)
227            .open(&lock_path)?;
228        match file.try_lock() {
229            Ok(()) => {
230                if let Ok(mut held) = held_exclusive_locks().lock() {
231                    held.insert(registry_key(&lock_path));
232                }
233                Ok(Some(Self {
234                    _lock: file,
235                    lock_path,
236                }))
237            }
238            Err(err) if is_would_block(&err) => Ok(None),
239            Err(err) => Err(SqliteStoreError::Io(std::io::Error::other(err))),
240        }
241    }
242
243    /// Take the fence, waiting up to `deadline` for in-flight operations to
244    /// drain.
245    pub fn acquire(db_path: &Path, deadline: Duration) -> Result<Self, SqliteStoreError> {
246        let started = Instant::now();
247        loop {
248            if let Some(fence) = Self::try_acquire(db_path)? {
249                return Ok(fence);
250            }
251            if started.elapsed() >= deadline {
252                return Err(SqliteStoreError::MaintenanceFenceHeld {
253                    path: db_path.to_path_buf(),
254                });
255            }
256            std::thread::sleep(Duration::from_millis(25));
257        }
258    }
259
260    /// The lock file this fence holds.
261    pub fn lock_path(&self) -> &Path {
262        &self.lock_path
263    }
264}
265
266impl Drop for ExclusiveFence {
267    fn drop(&mut self) {
268        if let Ok(mut held) = held_exclusive_locks().lock() {
269            held.remove(&registry_key(&self.lock_path));
270        }
271    }
272}
273
274fn is_would_block(err: &std::fs::TryLockError) -> bool {
275    matches!(err, std::fs::TryLockError::WouldBlock)
276}
277
278#[cfg(test)]
279#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn shared_guards_coexist() {
285        let dir = tempfile::tempdir().expect("tempdir");
286        let db = dir.path().join("db.sqlite3");
287        let g1 = OperationGuard::for_database(&db).expect("g1");
288        let g2 = OperationGuard::for_database(&db).expect("g2");
289        drop((g1, g2));
290    }
291
292    #[test]
293    fn exclusive_fence_blocks_foreign_operations_typed() {
294        let dir = tempfile::tempdir().expect("tempdir");
295        let db = dir.path().join("db.sqlite3");
296        // Simulate ANOTHER process holding the exclusive fence: a raw
297        // exclusive lock on the fence file without the holder registry entry
298        // (the registry is what self-admits the in-process holder).
299        let lock_path = fence_lock_path(&db);
300        let foreign = OpenOptions::new()
301            .read(true)
302            .write(true)
303            .create(true)
304            .truncate(false)
305            .open(&lock_path)
306            .expect("open lock file");
307        foreign.try_lock().expect("foreign exclusive lock");
308        let err = OperationGuard::for_database(&db).expect_err("fence must block foreign ops");
309        assert!(matches!(err, SqliteStoreError::MaintenanceFenceHeld { .. }));
310        drop(foreign);
311        OperationGuard::for_database(&db).expect("released after drop");
312    }
313
314    /// A missing database is refused typed while another holder holds its
315    /// fence, admitted otherwise (no lock file, a released fence, or this
316    /// process's own fence), and nothing is ever created for it.
317    #[test]
318    fn a_missing_database_honours_a_held_fence_and_creates_nothing() {
319        let dir = tempfile::tempdir().expect("tempdir");
320        let db = dir.path().join("db.sqlite3");
321        OperationGuard::admit_missing_database(&db).expect("no lock file: no fence");
322        assert!(
323            !fence_lock_path(&db).exists(),
324            "admission creates no lock file"
325        );
326
327        let lock_path = fence_lock_path(&db);
328        let foreign = OpenOptions::new()
329            .read(true)
330            .write(true)
331            .create(true)
332            .truncate(false)
333            .open(&lock_path)
334            .expect("open lock file");
335        foreign.try_lock().expect("foreign exclusive lock");
336        let err = OperationGuard::admit_missing_database(&db)
337            .expect_err("a foreign fence covers the missing database");
338        assert!(matches!(err, SqliteStoreError::MaintenanceFenceHeld { .. }));
339        drop(foreign);
340        OperationGuard::admit_missing_database(&db).expect("released after drop");
341
342        let own = ExclusiveFence::try_acquire(&db)
343            .expect("io")
344            .expect("own fence");
345        OperationGuard::admit_missing_database(&db).expect("the fence holder is admitted");
346        drop(own);
347        assert!(!db.exists(), "nothing created the database");
348    }
349
350    #[test]
351    fn exclusive_fence_waits_for_guard_drain() {
352        let dir = tempfile::tempdir().expect("tempdir");
353        let db = dir.path().join("db.sqlite3");
354        let guard = OperationGuard::for_database(&db).expect("guard");
355        assert!(
356            ExclusiveFence::try_acquire(&db).expect("io").is_none(),
357            "fence must not be acquirable while an operation is in flight"
358        );
359        let handle = std::thread::spawn(move || {
360            ExclusiveFence::acquire(&db, Duration::from_secs(5)).expect("acquire")
361        });
362        std::thread::sleep(Duration::from_millis(100));
363        drop(guard);
364        let fence = handle.join().expect("thread");
365        assert!(fence.lock_path().ends_with("db.sqlite3.mfence"));
366    }
367
368    #[test]
369    fn fence_holder_operations_self_admit() {
370        let dir = tempfile::tempdir().expect("tempdir");
371        let db = dir.path().join("db.sqlite3");
372        let fence = ExclusiveFence::try_acquire(&db)
373            .expect("io")
374            .expect("acquired");
375        // The holder process's own store operations pass their guards while
376        // the fence is held (bulk maintenance reuses production store code).
377        let guard = OperationGuard::for_database(&db).expect("holder self-admission");
378        drop(guard);
379        drop(fence);
380        // After release, ordinary shared guards work as usual...
381        OperationGuard::for_database(&db).expect("released");
382        // ...and a second process-side exclusive acquisition still excludes.
383        let fence = ExclusiveFence::try_acquire(&db)
384            .expect("io")
385            .expect("reacquired");
386        drop(fence);
387    }
388
389    #[test]
390    fn in_memory_paths_get_noop_guards() {
391        let guard = OperationGuard::for_database(Path::new(":memory:")).expect("noop");
392        drop(guard);
393    }
394
395    #[test]
396    fn fence_lock_path_shape() {
397        assert_eq!(
398            fence_lock_path(Path::new("/a/b/sessions.sqlite3")),
399            PathBuf::from("/a/b/sessions.sqlite3.mfence")
400        );
401    }
402}