use std::collections::HashSet;
use std::fs::{File, OpenOptions};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use crate::error::SqliteStoreError;
fn held_exclusive_locks() -> &'static Mutex<HashSet<PathBuf>> {
static REGISTRY: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
REGISTRY.get_or_init(|| Mutex::new(HashSet::new()))
}
fn registry_key(lock_path: &Path) -> PathBuf {
std::fs::canonicalize(lock_path).unwrap_or_else(|_| lock_path.to_path_buf())
}
fn process_holds_exclusive(lock_path: &Path) -> bool {
held_exclusive_locks()
.lock()
.map(|held| held.contains(®istry_key(lock_path)))
.unwrap_or(false)
}
pub const FENCE_LOCK_SUFFIX: &str = "mfence";
pub fn fence_lock_path(db_path: &Path) -> PathBuf {
let mut name = db_path.file_name().unwrap_or_default().to_os_string();
name.push(".");
name.push(FENCE_LOCK_SUFFIX);
db_path.with_file_name(name)
}
fn open_lock_file(lock_path: &Path) -> Option<File> {
OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(lock_path)
.ok()
}
#[derive(Debug)]
pub struct OperationGuard {
_lock: Option<File>,
}
impl OperationGuard {
pub fn for_database(db_path: &Path) -> Result<Self, SqliteStoreError> {
if db_path.file_name().is_none() || db_path.as_os_str() == ":memory:" {
return Ok(Self { _lock: None });
}
let lock_path = fence_lock_path(db_path);
if process_holds_exclusive(&lock_path) {
return Ok(Self { _lock: None });
}
let Some(file) = open_lock_file(&lock_path) else {
return Ok(Self { _lock: None });
};
match file.try_lock_shared() {
Ok(()) => Ok(Self { _lock: Some(file) }),
Err(err) if is_would_block(&err) => Err(SqliteStoreError::MaintenanceFenceHeld {
path: db_path.to_path_buf(),
}),
Err(_) => Ok(Self { _lock: None }),
}
}
}
#[derive(Debug)]
pub struct ExclusiveFence {
_lock: File,
lock_path: PathBuf,
}
impl ExclusiveFence {
pub fn try_acquire(db_path: &Path) -> Result<Option<Self>, SqliteStoreError> {
let lock_path = fence_lock_path(db_path);
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)?;
match file.try_lock() {
Ok(()) => {
if let Ok(mut held) = held_exclusive_locks().lock() {
held.insert(registry_key(&lock_path));
}
Ok(Some(Self {
_lock: file,
lock_path,
}))
}
Err(err) if is_would_block(&err) => Ok(None),
Err(err) => Err(SqliteStoreError::Io(std::io::Error::other(err))),
}
}
pub fn acquire(db_path: &Path, deadline: Duration) -> Result<Self, SqliteStoreError> {
let started = Instant::now();
loop {
if let Some(fence) = Self::try_acquire(db_path)? {
return Ok(fence);
}
if started.elapsed() >= deadline {
return Err(SqliteStoreError::MaintenanceFenceHeld {
path: db_path.to_path_buf(),
});
}
std::thread::sleep(Duration::from_millis(25));
}
}
pub fn lock_path(&self) -> &Path {
&self.lock_path
}
}
impl Drop for ExclusiveFence {
fn drop(&mut self) {
if let Ok(mut held) = held_exclusive_locks().lock() {
held.remove(®istry_key(&self.lock_path));
}
}
}
fn is_would_block(err: &std::fs::TryLockError) -> bool {
matches!(err, std::fs::TryLockError::WouldBlock)
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn shared_guards_coexist() {
let dir = tempfile::tempdir().expect("tempdir");
let db = dir.path().join("db.sqlite3");
let g1 = OperationGuard::for_database(&db).expect("g1");
let g2 = OperationGuard::for_database(&db).expect("g2");
drop((g1, g2));
}
#[test]
fn exclusive_fence_blocks_foreign_operations_typed() {
let dir = tempfile::tempdir().expect("tempdir");
let db = dir.path().join("db.sqlite3");
let lock_path = fence_lock_path(&db);
let foreign = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)
.expect("open lock file");
foreign.try_lock().expect("foreign exclusive lock");
let err = OperationGuard::for_database(&db).expect_err("fence must block foreign ops");
assert!(matches!(err, SqliteStoreError::MaintenanceFenceHeld { .. }));
drop(foreign);
OperationGuard::for_database(&db).expect("released after drop");
}
#[test]
fn exclusive_fence_waits_for_guard_drain() {
let dir = tempfile::tempdir().expect("tempdir");
let db = dir.path().join("db.sqlite3");
let guard = OperationGuard::for_database(&db).expect("guard");
assert!(
ExclusiveFence::try_acquire(&db).expect("io").is_none(),
"fence must not be acquirable while an operation is in flight"
);
let handle = std::thread::spawn(move || {
ExclusiveFence::acquire(&db, Duration::from_secs(5)).expect("acquire")
});
std::thread::sleep(Duration::from_millis(100));
drop(guard);
let fence = handle.join().expect("thread");
assert!(fence.lock_path().ends_with("db.sqlite3.mfence"));
}
#[test]
fn fence_holder_operations_self_admit() {
let dir = tempfile::tempdir().expect("tempdir");
let db = dir.path().join("db.sqlite3");
let fence = ExclusiveFence::try_acquire(&db)
.expect("io")
.expect("acquired");
let guard = OperationGuard::for_database(&db).expect("holder self-admission");
drop(guard);
drop(fence);
OperationGuard::for_database(&db).expect("released");
let fence = ExclusiveFence::try_acquire(&db)
.expect("io")
.expect("reacquired");
drop(fence);
}
#[test]
fn in_memory_paths_get_noop_guards() {
let guard = OperationGuard::for_database(Path::new(":memory:")).expect("noop");
drop(guard);
}
#[test]
fn fence_lock_path_shape() {
assert_eq!(
fence_lock_path(Path::new("/a/b/sessions.sqlite3")),
PathBuf::from("/a/b/sessions.sqlite3.mfence")
);
}
}