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
177/// The exclusive side of the maintenance fence for one database file.
178///
179/// Acquiring it waits for outstanding [`OperationGuard`]s to drain (bounded
180/// by `deadline`); holding it makes every subsequent guarded operation fail
181/// typed. The Phase 6 migration machinery composes one of these per database
182/// file it intends to touch, across both candidate roots.
183#[derive(Debug)]
184pub struct ExclusiveFence {
185    _lock: File,
186    lock_path: PathBuf,
187}
188
189impl ExclusiveFence {
190    /// Try to take the fence without waiting.
191    pub fn try_acquire(db_path: &Path) -> Result<Option<Self>, SqliteStoreError> {
192        let lock_path = fence_lock_path(db_path);
193        let file = OpenOptions::new()
194            .read(true)
195            .write(true)
196            .create(true)
197            .truncate(false)
198            .open(&lock_path)?;
199        match file.try_lock() {
200            Ok(()) => {
201                if let Ok(mut held) = held_exclusive_locks().lock() {
202                    held.insert(registry_key(&lock_path));
203                }
204                Ok(Some(Self {
205                    _lock: file,
206                    lock_path,
207                }))
208            }
209            Err(err) if is_would_block(&err) => Ok(None),
210            Err(err) => Err(SqliteStoreError::Io(std::io::Error::other(err))),
211        }
212    }
213
214    /// Take the fence, waiting up to `deadline` for in-flight operations to
215    /// drain.
216    pub fn acquire(db_path: &Path, deadline: Duration) -> Result<Self, SqliteStoreError> {
217        let started = Instant::now();
218        loop {
219            if let Some(fence) = Self::try_acquire(db_path)? {
220                return Ok(fence);
221            }
222            if started.elapsed() >= deadline {
223                return Err(SqliteStoreError::MaintenanceFenceHeld {
224                    path: db_path.to_path_buf(),
225                });
226            }
227            std::thread::sleep(Duration::from_millis(25));
228        }
229    }
230
231    /// The lock file this fence holds.
232    pub fn lock_path(&self) -> &Path {
233        &self.lock_path
234    }
235}
236
237impl Drop for ExclusiveFence {
238    fn drop(&mut self) {
239        if let Ok(mut held) = held_exclusive_locks().lock() {
240            held.remove(&registry_key(&self.lock_path));
241        }
242    }
243}
244
245fn is_would_block(err: &std::fs::TryLockError) -> bool {
246    matches!(err, std::fs::TryLockError::WouldBlock)
247}
248
249#[cfg(test)]
250#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn shared_guards_coexist() {
256        let dir = tempfile::tempdir().expect("tempdir");
257        let db = dir.path().join("db.sqlite3");
258        let g1 = OperationGuard::for_database(&db).expect("g1");
259        let g2 = OperationGuard::for_database(&db).expect("g2");
260        drop((g1, g2));
261    }
262
263    #[test]
264    fn exclusive_fence_blocks_foreign_operations_typed() {
265        let dir = tempfile::tempdir().expect("tempdir");
266        let db = dir.path().join("db.sqlite3");
267        // Simulate ANOTHER process holding the exclusive fence: a raw
268        // exclusive lock on the fence file without the holder registry entry
269        // (the registry is what self-admits the in-process holder).
270        let lock_path = fence_lock_path(&db);
271        let foreign = OpenOptions::new()
272            .read(true)
273            .write(true)
274            .create(true)
275            .truncate(false)
276            .open(&lock_path)
277            .expect("open lock file");
278        foreign.try_lock().expect("foreign exclusive lock");
279        let err = OperationGuard::for_database(&db).expect_err("fence must block foreign ops");
280        assert!(matches!(err, SqliteStoreError::MaintenanceFenceHeld { .. }));
281        drop(foreign);
282        OperationGuard::for_database(&db).expect("released after drop");
283    }
284
285    #[test]
286    fn exclusive_fence_waits_for_guard_drain() {
287        let dir = tempfile::tempdir().expect("tempdir");
288        let db = dir.path().join("db.sqlite3");
289        let guard = OperationGuard::for_database(&db).expect("guard");
290        assert!(
291            ExclusiveFence::try_acquire(&db).expect("io").is_none(),
292            "fence must not be acquirable while an operation is in flight"
293        );
294        let handle = std::thread::spawn(move || {
295            ExclusiveFence::acquire(&db, Duration::from_secs(5)).expect("acquire")
296        });
297        std::thread::sleep(Duration::from_millis(100));
298        drop(guard);
299        let fence = handle.join().expect("thread");
300        assert!(fence.lock_path().ends_with("db.sqlite3.mfence"));
301    }
302
303    #[test]
304    fn fence_holder_operations_self_admit() {
305        let dir = tempfile::tempdir().expect("tempdir");
306        let db = dir.path().join("db.sqlite3");
307        let fence = ExclusiveFence::try_acquire(&db)
308            .expect("io")
309            .expect("acquired");
310        // The holder process's own store operations pass their guards while
311        // the fence is held (bulk maintenance reuses production store code).
312        let guard = OperationGuard::for_database(&db).expect("holder self-admission");
313        drop(guard);
314        drop(fence);
315        // After release, ordinary shared guards work as usual...
316        OperationGuard::for_database(&db).expect("released");
317        // ...and a second process-side exclusive acquisition still excludes.
318        let fence = ExclusiveFence::try_acquire(&db)
319            .expect("io")
320            .expect("reacquired");
321        drop(fence);
322    }
323
324    #[test]
325    fn in_memory_paths_get_noop_guards() {
326        let guard = OperationGuard::for_database(Path::new(":memory:")).expect("noop");
327        drop(guard);
328    }
329
330    #[test]
331    fn fence_lock_path_shape() {
332        assert_eq!(
333            fence_lock_path(Path::new("/a/b/sessions.sqlite3")),
334            PathBuf::from("/a/b/sessions.sqlite3.mfence")
335        );
336    }
337}