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    /// Acquire the shared guard for an operation on `db_path`.
95    ///
96    /// Returns [`SqliteStoreError::MaintenanceFenceHeld`] when the exclusive
97    /// maintenance fence is held. In-memory databases and media where no
98    /// lock file can exist yield a no-op guard.
99    pub fn for_database(db_path: &Path) -> Result<Self, SqliteStoreError> {
100        if db_path.file_name().is_none() || db_path.as_os_str() == ":memory:" {
101            return Ok(Self { _lock: None });
102        }
103        let lock_path = fence_lock_path(db_path);
104        if process_holds_exclusive(&lock_path) {
105            // Fence-holder self-admission (see `held_exclusive_locks`).
106            return Ok(Self { _lock: None });
107        }
108        let Some(file) = open_lock_file(&lock_path) else {
109            return Ok(Self { _lock: None });
110        };
111        match file.try_lock_shared() {
112            Ok(()) => Ok(Self { _lock: Some(file) }),
113            Err(err) if is_would_block(&err) => Err(SqliteStoreError::MaintenanceFenceHeld {
114                path: db_path.to_path_buf(),
115            }),
116            // Filesystems without lock support: fail open, same rationale.
117            Err(_) => Ok(Self { _lock: None }),
118        }
119    }
120}
121
122/// The exclusive side of the maintenance fence for one database file.
123///
124/// Acquiring it waits for outstanding [`OperationGuard`]s to drain (bounded
125/// by `deadline`); holding it makes every subsequent guarded operation fail
126/// typed. The Phase 6 migration machinery composes one of these per database
127/// file it intends to touch, across both candidate roots.
128#[derive(Debug)]
129pub struct ExclusiveFence {
130    _lock: File,
131    lock_path: PathBuf,
132}
133
134impl ExclusiveFence {
135    /// Try to take the fence without waiting.
136    pub fn try_acquire(db_path: &Path) -> Result<Option<Self>, SqliteStoreError> {
137        let lock_path = fence_lock_path(db_path);
138        let file = OpenOptions::new()
139            .read(true)
140            .write(true)
141            .create(true)
142            .truncate(false)
143            .open(&lock_path)?;
144        match file.try_lock() {
145            Ok(()) => {
146                if let Ok(mut held) = held_exclusive_locks().lock() {
147                    held.insert(registry_key(&lock_path));
148                }
149                Ok(Some(Self {
150                    _lock: file,
151                    lock_path,
152                }))
153            }
154            Err(err) if is_would_block(&err) => Ok(None),
155            Err(err) => Err(SqliteStoreError::Io(std::io::Error::other(err))),
156        }
157    }
158
159    /// Take the fence, waiting up to `deadline` for in-flight operations to
160    /// drain.
161    pub fn acquire(db_path: &Path, deadline: Duration) -> Result<Self, SqliteStoreError> {
162        let started = Instant::now();
163        loop {
164            if let Some(fence) = Self::try_acquire(db_path)? {
165                return Ok(fence);
166            }
167            if started.elapsed() >= deadline {
168                return Err(SqliteStoreError::MaintenanceFenceHeld {
169                    path: db_path.to_path_buf(),
170                });
171            }
172            std::thread::sleep(Duration::from_millis(25));
173        }
174    }
175
176    /// The lock file this fence holds.
177    pub fn lock_path(&self) -> &Path {
178        &self.lock_path
179    }
180}
181
182impl Drop for ExclusiveFence {
183    fn drop(&mut self) {
184        if let Ok(mut held) = held_exclusive_locks().lock() {
185            held.remove(&registry_key(&self.lock_path));
186        }
187    }
188}
189
190fn is_would_block(err: &std::fs::TryLockError) -> bool {
191    matches!(err, std::fs::TryLockError::WouldBlock)
192}
193
194#[cfg(test)]
195#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn shared_guards_coexist() {
201        let dir = tempfile::tempdir().expect("tempdir");
202        let db = dir.path().join("db.sqlite3");
203        let g1 = OperationGuard::for_database(&db).expect("g1");
204        let g2 = OperationGuard::for_database(&db).expect("g2");
205        drop((g1, g2));
206    }
207
208    #[test]
209    fn exclusive_fence_blocks_foreign_operations_typed() {
210        let dir = tempfile::tempdir().expect("tempdir");
211        let db = dir.path().join("db.sqlite3");
212        // Simulate ANOTHER process holding the exclusive fence: a raw
213        // exclusive lock on the fence file without the holder registry entry
214        // (the registry is what self-admits the in-process holder).
215        let lock_path = fence_lock_path(&db);
216        let foreign = OpenOptions::new()
217            .read(true)
218            .write(true)
219            .create(true)
220            .truncate(false)
221            .open(&lock_path)
222            .expect("open lock file");
223        foreign.try_lock().expect("foreign exclusive lock");
224        let err = OperationGuard::for_database(&db).expect_err("fence must block foreign ops");
225        assert!(matches!(err, SqliteStoreError::MaintenanceFenceHeld { .. }));
226        drop(foreign);
227        OperationGuard::for_database(&db).expect("released after drop");
228    }
229
230    #[test]
231    fn exclusive_fence_waits_for_guard_drain() {
232        let dir = tempfile::tempdir().expect("tempdir");
233        let db = dir.path().join("db.sqlite3");
234        let guard = OperationGuard::for_database(&db).expect("guard");
235        assert!(
236            ExclusiveFence::try_acquire(&db).expect("io").is_none(),
237            "fence must not be acquirable while an operation is in flight"
238        );
239        let handle = std::thread::spawn(move || {
240            ExclusiveFence::acquire(&db, Duration::from_secs(5)).expect("acquire")
241        });
242        std::thread::sleep(Duration::from_millis(100));
243        drop(guard);
244        let fence = handle.join().expect("thread");
245        assert!(fence.lock_path().ends_with("db.sqlite3.mfence"));
246    }
247
248    #[test]
249    fn fence_holder_operations_self_admit() {
250        let dir = tempfile::tempdir().expect("tempdir");
251        let db = dir.path().join("db.sqlite3");
252        let fence = ExclusiveFence::try_acquire(&db)
253            .expect("io")
254            .expect("acquired");
255        // The holder process's own store operations pass their guards while
256        // the fence is held (bulk maintenance reuses production store code).
257        let guard = OperationGuard::for_database(&db).expect("holder self-admission");
258        drop(guard);
259        drop(fence);
260        // After release, ordinary shared guards work as usual...
261        OperationGuard::for_database(&db).expect("released");
262        // ...and a second process-side exclusive acquisition still excludes.
263        let fence = ExclusiveFence::try_acquire(&db)
264            .expect("io")
265            .expect("reacquired");
266        drop(fence);
267    }
268
269    #[test]
270    fn in_memory_paths_get_noop_guards() {
271        let guard = OperationGuard::for_database(Path::new(":memory:")).expect("noop");
272        drop(guard);
273    }
274
275    #[test]
276    fn fence_lock_path_shape() {
277        assert_eq!(
278            fence_lock_path(Path::new("/a/b/sessions.sqlite3")),
279            PathBuf::from("/a/b/sessions.sqlite3.mfence")
280        );
281    }
282}