1use 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
56fn 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(®istry_key(lock_path)))
77 .unwrap_or(false)
78}
79
80pub const FENCE_LOCK_SUFFIX: &str = "mfence";
82
83pub const PRESENCE_LOCK_SUFFIX: &str = "mpresence";
85
86pub fn fence_lock_path(db_path: &Path) -> PathBuf {
88 sibling_lock_path(db_path, FENCE_LOCK_SUFFIX)
89}
90
91pub 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 OpenOptions::new()
107 .read(true)
108 .write(true)
109 .create(true)
110 .truncate(false)
111 .open(lock_path)
112 .ok()
113}
114
115#[derive(Debug)]
119pub struct OperationGuard {
120 _lock: Option<File>,
121}
122
123impl OperationGuard {
124 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 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 crate::profile::validate_database_file(db_path)?;
188 let lock_path = fence_lock_path(db_path);
189 if process_holds_exclusive(&lock_path) {
190 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 Err(_) => Ok(Self { _lock: None }),
203 }
204 }
205
206 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 Ok(()) | Err(_) => Ok(()),
232 }
233 }
234}
235
236#[derive(Debug)]
248pub struct KeptConnectionPresence {
249 _lock: File,
250}
251
252impl KeptConnectionPresence {
253 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 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#[derive(Debug)]
298pub struct ExclusiveFence {
299 _lock: File,
300 _presence: File,
301 lock_path: PathBuf,
302}
303
304impl ExclusiveFence {
305 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 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 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 return Err(SqliteStoreError::MaintenanceFenceHeld {
354 path: db_path.to_path_buf(),
355 });
356 }
357 std::thread::sleep(FENCE_POLL);
358 }
359 }
360
361 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 pub fn lock_path(&self) -> &Path {
377 &self.lock_path
378 }
379}
380
381const FENCE_POLL: Duration = Duration::from_millis(25);
383
384fn 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(®istry_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 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 #[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 let guard = OperationGuard::for_database(&db).expect("holder self-admission");
512 drop(guard);
513 drop(fence);
514 OperationGuard::for_database(&db).expect("released");
516 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 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 #[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 #[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 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 #[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 OperationGuard::for_database(&db).expect("holder self-admission");
627 drop(own);
628
629 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 #[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 #[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}