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//! The one connection kept open across operations is a kept reader (the
15//! runtime store's delivery-generation reader). It holds the presence lock
16//! ([`KeptConnectionPresence`]) shared for its whole life, from before its
17//! connection opens until after it closes, and still takes an
18//! [`OperationGuard`] for each of its reads. Maintenance takes the presence
19//! lock exclusively after the fence lock, so it touches no bytes while a kept
20//! connection is open.
21//!
22//! Lock files live next to the database as `<file>.mfence` (the fence lock)
23//! and `<file>.mpresence` (the presence lock). They are advisory OS locks
24//! (flock on unix, `LockFileEx` on Windows), cheap to take per operation, and
25//! correct across processes as well as between handles in one process.
26//!
27//! Lock order: a kept reader holds its presence lock for its whole life and
28//! takes the fence lock for each read by try only, never waiting for it;
29//! maintenance takes the fence lock, then waits for the presence lock. Once
30//! maintenance holds the fence lock a kept reader's next read is refused and
31//! the reader closes, and an idle one closes at its owner's idle bound, so
32//! the wait ends; neither side waits for a lock while holding one the other
33//! waits for.
34//!
35//! Fail-open rule: a medium where the lock file cannot be created (read-only
36//! snapshot mounts, for example) is a medium where no fence can be held
37//! either, so guard acquisition degrades to a no-op there. If the lock file
38//! exists and is exclusively held, acquisition fails with the typed
39//! [`SqliteStoreError::MaintenanceFenceHeld`] — storage is under offline
40//! maintenance and the operation must not proceed. Presence fails closed
41//! instead: where the presence lock cannot be held, no connection is kept
42//! and the reader opens one per read.
43//!
44//! The [`ConnectionProfile::Maintenance`](crate::profile::ConnectionProfile)
45//! profile is the fence *holder's* profile; maintenance work runs under the
46//! exclusive fence and does not take shared guards.
47
48use std::collections::HashSet;
49use std::fs::{File, OpenOptions};
50use std::path::{Path, PathBuf};
51use std::sync::{Mutex, OnceLock};
52use std::time::{Duration, Instant};
53
54use crate::error::SqliteStoreError;
55
56/// Lock paths exclusively held BY THIS PROCESS.
57///
58/// Holder self-admission: the maintenance verb holds the exclusive fence
59/// while reusing production store code paths in-process (bulk checkpoint
60/// adoption runs through the ordinary, CAS-hardened store machinery).
61/// Those operations must pass their own fence — and an OS-level shared
62/// re-lock from the same process would deadlock against the held exclusive
63/// lock — so guard acquisition consults this registry first.
64fn held_exclusive_locks() -> &'static Mutex<HashSet<PathBuf>> {
65    static REGISTRY: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
66    REGISTRY.get_or_init(|| Mutex::new(HashSet::new()))
67}
68
69fn registry_key(lock_path: &Path) -> PathBuf {
70    std::fs::canonicalize(lock_path).unwrap_or_else(|_| lock_path.to_path_buf())
71}
72
73fn process_holds_exclusive(lock_path: &Path) -> bool {
74    held_exclusive_locks()
75        .lock()
76        .map(|held| held.contains(&registry_key(lock_path)))
77        .unwrap_or(false)
78}
79
80/// Suffix appended to a database file name to form its fence lock file.
81pub const FENCE_LOCK_SUFFIX: &str = "mfence";
82
83/// Suffix appended to a database file name to form its presence lock file.
84pub const PRESENCE_LOCK_SUFFIX: &str = "mpresence";
85
86/// The fence lock file for a database path: `<file>.mfence`.
87pub fn fence_lock_path(db_path: &Path) -> PathBuf {
88    sibling_lock_path(db_path, FENCE_LOCK_SUFFIX)
89}
90
91/// The presence lock file for a database path: `<file>.mpresence`.
92pub fn presence_lock_path(db_path: &Path) -> PathBuf {
93    sibling_lock_path(db_path, PRESENCE_LOCK_SUFFIX)
94}
95
96fn sibling_lock_path(db_path: &Path, suffix: &str) -> PathBuf {
97    let mut name = db_path.file_name().unwrap_or_default().to_os_string();
98    name.push(".");
99    name.push(suffix);
100    db_path.with_file_name(name)
101}
102
103fn open_lock_file(lock_path: &Path) -> Option<File> {
104    // Fail-open: if the lock file can neither be opened nor created, no
105    // fence can exist on this medium either (see module docs).
106    OpenOptions::new()
107        .read(true)
108        .write(true)
109        .create(true)
110        .truncate(false)
111        .open(lock_path)
112        .ok()
113}
114
115/// RAII shared guard for one store operation on one database file.
116///
117/// Held for the duration of the operation; dropping it releases the lock.
118#[derive(Debug)]
119pub struct OperationGuard {
120    _lock: Option<File>,
121}
122
123impl OperationGuard {
124    /// Require actual descriptor-scoped shared custody for online administration.
125    /// Unlike ordinary I/O this has no maintenance-holder shortcut or fail-open
126    /// fallback. Validate the database before creating any lock sidecar.
127    pub fn try_for_database_strict(db_path: &Path) -> Result<Self, SqliteStoreError> {
128        if db_path.file_name().is_none() || db_path.as_os_str() == ":memory:" {
129            return Err(SqliteStoreError::Io(std::io::Error::new(
130                std::io::ErrorKind::InvalidInput,
131                "strict operation custody requires an existing physical database",
132            )));
133        }
134        crate::profile::validate_database_file(db_path)?;
135        if !db_path.is_file() {
136            return Err(SqliteStoreError::Io(std::io::Error::new(
137                std::io::ErrorKind::NotFound,
138                "strict operation database is missing",
139            )));
140        }
141        #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
142        {
143            Err(SqliteStoreError::Io(std::io::Error::new(
144                std::io::ErrorKind::Unsupported,
145                "strict operation custody requires descriptor-scoped locking",
146            )))
147        }
148        #[cfg(any(target_os = "macos", target_os = "linux", windows))]
149        {
150            let path = fence_lock_path(db_path);
151            crate::profile::validate_database_file(&path)?;
152            let file = OpenOptions::new()
153                .read(true)
154                .write(true)
155                .create(true)
156                .truncate(false)
157                .open(&path)?;
158            crate::profile::validate_database_file(&path)?;
159            if !file.metadata()?.is_file() {
160                return Err(SqliteStoreError::Io(std::io::Error::new(
161                    std::io::ErrorKind::Unsupported,
162                    "strict operation lock is not a regular file",
163                )));
164            }
165            match file.try_lock_shared() {
166                Ok(()) => Ok(Self { _lock: Some(file) }),
167                Err(error) if is_would_block(&error) => {
168                    Err(SqliteStoreError::MaintenanceFenceHeld {
169                        path: db_path.to_path_buf(),
170                    })
171                }
172                Err(error) => Err(SqliteStoreError::Io(std::io::Error::other(error))),
173            }
174        }
175    }
176
177    /// Acquire the shared guard for an operation on `db_path`.
178    ///
179    /// Returns [`SqliteStoreError::MaintenanceFenceHeld`] when the exclusive
180    /// maintenance fence is held. In-memory databases and media where no
181    /// lock file can exist yield a no-op guard.
182    pub fn for_database(db_path: &Path) -> Result<Self, SqliteStoreError> {
183        if db_path.file_name().is_none() || db_path.as_os_str() == ":memory:" {
184            return Ok(Self { _lock: None });
185        }
186        // Refuse an unsafe database file before planting its fence sibling.
187        crate::profile::validate_database_file(db_path)?;
188        let lock_path = fence_lock_path(db_path);
189        if process_holds_exclusive(&lock_path) {
190            // Fence-holder self-admission (see `held_exclusive_locks`).
191            return Ok(Self { _lock: None });
192        }
193        let Some(file) = open_lock_file(&lock_path) else {
194            return Ok(Self { _lock: None });
195        };
196        match file.try_lock_shared() {
197            Ok(()) => Ok(Self { _lock: Some(file) }),
198            Err(err) if is_would_block(&err) => Err(SqliteStoreError::MaintenanceFenceHeld {
199                path: db_path.to_path_buf(),
200            }),
201            // Filesystems without lock support: fail open, same rationale.
202            Err(_) => Ok(Self { _lock: None }),
203        }
204    }
205
206    /// Admit an operation that finds `db_path` missing and answers without
207    /// touching it. The fence covers its database file whether or not the
208    /// file exists (an archive by rename removes the file while the fence is
209    /// held), so a missing database under the exclusive fence of another
210    /// holder fails typed with [`SqliteStoreError::MaintenanceFenceHeld`],
211    /// as [`Self::for_database`] does. Nothing is created: the lock file is
212    /// opened only if it exists, and an absent lock file holds no fence. The
213    /// in-process fence holder is admitted, as by [`Self::for_database`].
214    pub fn admit_missing_database(db_path: &Path) -> Result<(), SqliteStoreError> {
215        if db_path.file_name().is_none() || db_path.as_os_str() == ":memory:" {
216            return Ok(());
217        }
218        let lock_path = fence_lock_path(db_path);
219        if process_holds_exclusive(&lock_path) {
220            return Ok(());
221        }
222        let Ok(file) = OpenOptions::new().read(true).write(true).open(&lock_path) else {
223            return Ok(());
224        };
225        match file.try_lock_shared() {
226            Err(err) if is_would_block(&err) => Err(SqliteStoreError::MaintenanceFenceHeld {
227                path: db_path.to_path_buf(),
228            }),
229            // Admitted, or a filesystem without lock support (fail open, as
230            // `for_database`). Dropping the file releases the shared lock.
231            Ok(()) | Err(_) => Ok(()),
232        }
233    }
234}
235
236/// Shared presence of a connection kept open across reads of one database
237/// file (a kept reader).
238///
239/// The reader takes it before its connection opens and drops it after the
240/// connection closes: in the reader, declare the connection before the
241/// presence, so the close (and any checkpoint it runs) finishes before the
242/// lock goes. Between reads the reader holds no [`OperationGuard`]; each read
243/// takes one, and a refused guard
244/// ([`SqliteStoreError::MaintenanceFenceHeld`]) closes the connection and
245/// drops this presence. [`ExclusiveFence`] takes the presence lock
246/// exclusively after the fence lock (the module documents the lock order).
247#[derive(Debug)]
248pub struct KeptConnectionPresence {
249    _lock: File,
250}
251
252impl KeptConnectionPresence {
253    /// Take shared presence for a connection to be kept open on `db_path`.
254    ///
255    /// `None` means no connection may be kept now; the caller opens one per
256    /// read instead:
257    /// - this process holds the exclusive fence of `db_path`: the holder
258    ///   registry has the database's `.mfence` path, canonicalized as the
259    ///   registry keys it. The holder's own reads pass their guards through
260    ///   the registry and keep nothing, so the holder never waits on itself;
261    /// - maintenance holds the presence lock;
262    /// - the database file is missing (nothing to keep, and no presence file
263    ///   is planted next to it), has no file identity (as on every platform
264    ///   but unix), or is refused (see
265    ///   [`validate_database_file`](crate::profile::validate_database_file)),
266    ///   or the presence lock file cannot be created or locked (a read-only
267    ///   medium, a filesystem without locks): presence that cannot be held
268    ///   is never assumed;
269    /// - `db_path` is in-memory or has no file name.
270    pub fn acquire(db_path: &Path) -> Option<Self> {
271        if db_path.file_name().is_none() || db_path.as_os_str() == ":memory:" {
272            return None;
273        }
274        if process_holds_exclusive(&fence_lock_path(db_path)) {
275            return None;
276        }
277        // Refuse a missing or unsafe database file before planting its
278        // presence sibling.
279        let Ok(Some(_)) = crate::profile::validate_database_file(db_path) else {
280            return None;
281        };
282        let file = open_lock_file(&presence_lock_path(db_path))?;
283        file.try_lock_shared().ok()?;
284        Some(Self { _lock: file })
285    }
286}
287
288/// The exclusive side of the maintenance fence for one database file.
289///
290/// It holds the fence lock and the presence lock. Acquiring it waits for
291/// outstanding [`OperationGuard`]s to drain and for kept connections
292/// ([`KeptConnectionPresence`]) to close, both bounded by one `deadline`;
293/// holding it makes every subsequent guarded operation fail typed and keeps
294/// any connection from being kept. The Phase 6 migration machinery composes
295/// one of these per database file it intends to touch, across both candidate
296/// roots.
297#[derive(Debug)]
298pub struct ExclusiveFence {
299    _lock: File,
300    _presence: File,
301    lock_path: PathBuf,
302}
303
304impl ExclusiveFence {
305    /// Try to take the fence without waiting: both locks, or neither.
306    pub fn try_acquire(db_path: &Path) -> Result<Option<Self>, SqliteStoreError> {
307        let lock_path = fence_lock_path(db_path);
308        let Some(fence) = try_lock_exclusive(&lock_path)? else {
309            return Ok(None);
310        };
311        // Dropping `fence` on either early return releases the fence lock.
312        let Some(presence) = try_lock_exclusive(&presence_lock_path(db_path))? else {
313            return Ok(None);
314        };
315        Ok(Some(Self::held(fence, presence, lock_path)))
316    }
317
318    /// Take the fence, waiting up to `deadline` for in-flight operations to
319    /// drain and kept connections to close.
320    ///
321    /// The fence lock comes first: from then on every guarded operation is
322    /// refused, a kept reader's next read included, and the reader closes.
323    /// The presence lock follows within the same deadline; a reader that
324    /// reads nothing closes at its owner's idle bound, so a caller that can
325    /// meet a kept reader passes a deadline longer than that bound plus one
326    /// read. Past the deadline both locks are released and the fence fails
327    /// typed ([`SqliteStoreError::MaintenanceFenceHeld`]).
328    ///
329    /// Blocks the calling thread (bounded by `deadline`); async callers
330    /// should run it in `spawn_blocking`, or a kept reader on the blocked
331    /// runtime cannot close.
332    pub fn acquire(db_path: &Path, deadline: Duration) -> Result<Self, SqliteStoreError> {
333        let started = Instant::now();
334        let lock_path = fence_lock_path(db_path);
335        let fence = loop {
336            if let Some(fence) = try_lock_exclusive(&lock_path)? {
337                break fence;
338            }
339            if started.elapsed() >= deadline {
340                return Err(SqliteStoreError::MaintenanceFenceHeld {
341                    path: db_path.to_path_buf(),
342                });
343            }
344            std::thread::sleep(FENCE_POLL);
345        };
346        let presence_path = presence_lock_path(db_path);
347        loop {
348            if let Some(presence) = try_lock_exclusive(&presence_path)? {
349                return Ok(Self::held(fence, presence, lock_path));
350            }
351            if started.elapsed() >= deadline {
352                // Dropping `fence` releases the fence lock.
353                return Err(SqliteStoreError::MaintenanceFenceHeld {
354                    path: db_path.to_path_buf(),
355                });
356            }
357            std::thread::sleep(FENCE_POLL);
358        }
359    }
360
361    /// Record both held locks in the holder registry (see
362    /// `held_exclusive_locks`): only a fence that holds both admits its
363    /// holder's own operations.
364    fn held(fence: File, presence: File, lock_path: PathBuf) -> Self {
365        if let Ok(mut held) = held_exclusive_locks().lock() {
366            held.insert(registry_key(&lock_path));
367        }
368        Self {
369            _lock: fence,
370            _presence: presence,
371            lock_path,
372        }
373    }
374
375    /// The lock file this fence holds.
376    pub fn lock_path(&self) -> &Path {
377        &self.lock_path
378    }
379}
380
381/// How often [`ExclusiveFence::acquire`] retries a held lock.
382const FENCE_POLL: Duration = Duration::from_millis(25);
383
384/// Take `lock_path` exclusively without waiting; `None` while another holder
385/// has it.
386fn try_lock_exclusive(lock_path: &Path) -> Result<Option<File>, SqliteStoreError> {
387    let file = OpenOptions::new()
388        .read(true)
389        .write(true)
390        .create(true)
391        .truncate(false)
392        .open(lock_path)?;
393    match file.try_lock() {
394        Ok(()) => Ok(Some(file)),
395        Err(err) if is_would_block(&err) => Ok(None),
396        Err(err) => Err(SqliteStoreError::Io(std::io::Error::other(err))),
397    }
398}
399
400impl Drop for ExclusiveFence {
401    fn drop(&mut self) {
402        if let Ok(mut held) = held_exclusive_locks().lock() {
403            held.remove(&registry_key(&self.lock_path));
404        }
405    }
406}
407
408fn is_would_block(err: &std::fs::TryLockError) -> bool {
409    matches!(err, std::fs::TryLockError::WouldBlock)
410}
411
412#[cfg(test)]
413#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
414mod tests {
415    use super::*;
416
417    #[test]
418    fn shared_guards_coexist() {
419        let dir = tempfile::tempdir().expect("tempdir");
420        let db = dir.path().join("db.sqlite3");
421        let g1 = OperationGuard::for_database(&db).expect("g1");
422        let g2 = OperationGuard::for_database(&db).expect("g2");
423        drop((g1, g2));
424    }
425
426    #[test]
427    fn exclusive_fence_blocks_foreign_operations_typed() {
428        let dir = tempfile::tempdir().expect("tempdir");
429        let db = dir.path().join("db.sqlite3");
430        // Simulate ANOTHER process holding the exclusive fence: a raw
431        // exclusive lock on the fence file without the holder registry entry
432        // (the registry is what self-admits the in-process holder).
433        let lock_path = fence_lock_path(&db);
434        let foreign = OpenOptions::new()
435            .read(true)
436            .write(true)
437            .create(true)
438            .truncate(false)
439            .open(&lock_path)
440            .expect("open lock file");
441        foreign.try_lock().expect("foreign exclusive lock");
442        let err = OperationGuard::for_database(&db).expect_err("fence must block foreign ops");
443        assert!(matches!(err, SqliteStoreError::MaintenanceFenceHeld { .. }));
444        drop(foreign);
445        OperationGuard::for_database(&db).expect("released after drop");
446    }
447
448    /// A missing database is refused typed while another holder holds its
449    /// fence, admitted otherwise (no lock file, a released fence, or this
450    /// process's own fence), and nothing is ever created for it.
451    #[test]
452    fn a_missing_database_honours_a_held_fence_and_creates_nothing() {
453        let dir = tempfile::tempdir().expect("tempdir");
454        let db = dir.path().join("db.sqlite3");
455        OperationGuard::admit_missing_database(&db).expect("no lock file: no fence");
456        assert!(
457            !fence_lock_path(&db).exists(),
458            "admission creates no lock file"
459        );
460
461        let lock_path = fence_lock_path(&db);
462        let foreign = OpenOptions::new()
463            .read(true)
464            .write(true)
465            .create(true)
466            .truncate(false)
467            .open(&lock_path)
468            .expect("open lock file");
469        foreign.try_lock().expect("foreign exclusive lock");
470        let err = OperationGuard::admit_missing_database(&db)
471            .expect_err("a foreign fence covers the missing database");
472        assert!(matches!(err, SqliteStoreError::MaintenanceFenceHeld { .. }));
473        drop(foreign);
474        OperationGuard::admit_missing_database(&db).expect("released after drop");
475
476        let own = ExclusiveFence::try_acquire(&db)
477            .expect("io")
478            .expect("own fence");
479        OperationGuard::admit_missing_database(&db).expect("the fence holder is admitted");
480        drop(own);
481        assert!(!db.exists(), "nothing created the database");
482    }
483
484    #[test]
485    fn exclusive_fence_waits_for_guard_drain() {
486        let dir = tempfile::tempdir().expect("tempdir");
487        let db = dir.path().join("db.sqlite3");
488        let guard = OperationGuard::for_database(&db).expect("guard");
489        assert!(
490            ExclusiveFence::try_acquire(&db).expect("io").is_none(),
491            "fence must not be acquirable while an operation is in flight"
492        );
493        let handle = std::thread::spawn(move || {
494            ExclusiveFence::acquire(&db, Duration::from_secs(5)).expect("acquire")
495        });
496        std::thread::sleep(Duration::from_millis(100));
497        drop(guard);
498        let fence = handle.join().expect("thread");
499        assert!(fence.lock_path().ends_with("db.sqlite3.mfence"));
500    }
501
502    #[test]
503    fn fence_holder_operations_self_admit() {
504        let dir = tempfile::tempdir().expect("tempdir");
505        let db = dir.path().join("db.sqlite3");
506        let fence = ExclusiveFence::try_acquire(&db)
507            .expect("io")
508            .expect("acquired");
509        // The holder process's own store operations pass their guards while
510        // the fence is held (bulk maintenance reuses production store code).
511        let guard = OperationGuard::for_database(&db).expect("holder self-admission");
512        drop(guard);
513        drop(fence);
514        // After release, ordinary shared guards work as usual...
515        OperationGuard::for_database(&db).expect("released");
516        // ...and a second process-side exclusive acquisition still excludes.
517        let fence = ExclusiveFence::try_acquire(&db)
518            .expect("io")
519            .expect("reacquired");
520        drop(fence);
521    }
522
523    #[test]
524    fn in_memory_paths_get_noop_guards() {
525        let guard = OperationGuard::for_database(Path::new(":memory:")).expect("noop");
526        drop(guard);
527    }
528
529    #[test]
530    fn fence_lock_path_shape() {
531        assert_eq!(
532            fence_lock_path(Path::new("/a/b/sessions.sqlite3")),
533            PathBuf::from("/a/b/sessions.sqlite3.mfence")
534        );
535        assert_eq!(
536            presence_lock_path(Path::new("/a/b/sessions.sqlite3")),
537            PathBuf::from("/a/b/sessions.sqlite3.mpresence")
538        );
539    }
540
541    /// A database file at `dir/db.sqlite3`: presence is only taken for a
542    /// file that exists.
543    fn existing_database(dir: &Path) -> PathBuf {
544        let db = dir.join("db.sqlite3");
545        std::fs::write(&db, b"").expect("database file");
546        db
547    }
548
549    /// A kept connection's presence keeps the fence from being taken, and a
550    /// refused attempt leaves no lock behind: guarded operations go on.
551    #[test]
552    fn kept_presence_refuses_the_fence_and_a_refused_fence_holds_nothing() {
553        let dir = tempfile::tempdir().expect("tempdir");
554        let db = existing_database(dir.path());
555        let presence = KeptConnectionPresence::acquire(&db).expect("presence");
556        assert!(
557            ExclusiveFence::try_acquire(&db).expect("io").is_none(),
558            "the fence must not be taken while a connection is kept"
559        );
560        OperationGuard::for_database(&db).expect("a refused try holds no fence lock");
561        let err = ExclusiveFence::acquire(&db, Duration::from_millis(200))
562            .expect_err("a kept connection outlives the deadline");
563        assert!(matches!(err, SqliteStoreError::MaintenanceFenceHeld { .. }));
564        OperationGuard::for_database(&db).expect("an expired wait holds no fence lock");
565        drop(presence);
566        let fence = ExclusiveFence::try_acquire(&db)
567            .expect("io")
568            .expect("taken once the kept connection closed");
569        drop(fence);
570    }
571
572    /// While maintenance holds the fence lock and waits for the presence
573    /// lock, a kept reader's next read is refused typed at once, and the
574    /// fence completes when the reader closes.
575    #[test]
576    fn a_waiting_fence_refuses_the_kept_readers_next_read_and_completes_when_it_closes() {
577        let dir = tempfile::tempdir().expect("tempdir");
578        let db = existing_database(dir.path());
579        let presence = KeptConnectionPresence::acquire(&db).expect("presence");
580        let waiting = {
581            let db = db.clone();
582            std::thread::spawn(move || ExclusiveFence::acquire(&db, Duration::from_secs(10)))
583        };
584        // The fence lock is taken first; the reader's next guard is refused.
585        let started = Instant::now();
586        loop {
587            match OperationGuard::for_database(&db) {
588                Err(SqliteStoreError::MaintenanceFenceHeld { .. }) => break,
589                Ok(guard) => drop(guard),
590                Err(other) => panic!("unexpected guard error: {other}"),
591            }
592            assert!(
593                started.elapsed() < Duration::from_secs(5),
594                "the waiting fence never took its fence lock"
595            );
596            std::thread::sleep(Duration::from_millis(5));
597        }
598        assert!(
599            !waiting.is_finished(),
600            "the fence waits for the kept reader"
601        );
602        drop(presence);
603        let fence = waiting
604            .join()
605            .expect("thread")
606            .expect("fence after the reader closed");
607        assert!(
608            KeptConnectionPresence::acquire(&db).is_none(),
609            "no connection is kept while the fence is held"
610        );
611        drop(fence);
612        KeptConnectionPresence::acquire(&db).expect("presence after release");
613    }
614
615    /// No connection is kept while maintenance holds the database, whether
616    /// this process holds the fence (the holder registry) or another does.
617    #[test]
618    fn presence_is_refused_while_the_fence_is_held() {
619        let dir = tempfile::tempdir().expect("tempdir");
620        let db = existing_database(dir.path());
621        let own = ExclusiveFence::try_acquire(&db)
622            .expect("io")
623            .expect("own fence");
624        assert!(KeptConnectionPresence::acquire(&db).is_none());
625        // The holder's own reads still pass their guards (self-admission).
626        OperationGuard::for_database(&db).expect("holder self-admission");
627        drop(own);
628
629        // Another holder: a raw exclusive presence lock without the registry.
630        let foreign = OpenOptions::new()
631            .read(true)
632            .write(true)
633            .create(true)
634            .truncate(false)
635            .open(presence_lock_path(&db))
636            .expect("open presence lock file");
637        foreign.try_lock().expect("foreign exclusive presence lock");
638        assert!(KeptConnectionPresence::acquire(&db).is_none());
639        drop(foreign);
640        KeptConnectionPresence::acquire(&db).expect("presence after release");
641    }
642
643    /// A database file that is refused gets no presence sibling planted.
644    #[test]
645    fn presence_plants_nothing_next_to_a_refused_database() {
646        let dir = tempfile::tempdir().expect("tempdir");
647        let db = dir.path().join("db.sqlite3");
648        std::fs::create_dir(&db).expect("a directory where the database belongs");
649        assert!(KeptConnectionPresence::acquire(&db).is_none());
650        assert!(!presence_lock_path(&db).exists());
651        assert!(KeptConnectionPresence::acquire(Path::new(":memory:")).is_none());
652    }
653
654    /// A missing database has nothing to keep, and gets no presence sibling.
655    #[test]
656    fn presence_plants_nothing_next_to_a_missing_database() {
657        let dir = tempfile::tempdir().expect("tempdir");
658        let db = dir.path().join("db.sqlite3");
659        assert!(KeptConnectionPresence::acquire(&db).is_none());
660        assert!(!presence_lock_path(&db).exists());
661        assert_eq!(
662            std::fs::read_dir(dir.path()).expect("read dir").count(),
663            0,
664            "nothing planted"
665        );
666    }
667}