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