use sparrowdb_common::{Error, Result};
use std::collections::HashMap;
use std::fs::File;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock, Weak};
const LOCK_FILE_NAME: &str = "db.lock";
struct LockedFile(File);
impl Drop for LockedFile {
fn drop(&mut self) {
let _ = self.0.unlock();
}
}
fn registry() -> &'static Mutex<HashMap<PathBuf, Weak<LockedFile>>> {
static REGISTRY: OnceLock<Mutex<HashMap<PathBuf, Weak<LockedFile>>>> = OnceLock::new();
REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
}
#[allow(dead_code)]
pub(crate) struct ProcessLock(Arc<LockedFile>);
impl ProcessLock {
fn lock_path(db_path: &Path) -> PathBuf {
db_path.join(LOCK_FILE_NAME)
}
pub(crate) fn acquire(db_path: &Path) -> Result<Self> {
let key = db_path
.canonicalize()
.unwrap_or_else(|_| db_path.to_path_buf());
let mut reg = registry().lock().expect("process lock registry poisoned");
reg.retain(|_, weak| weak.strong_count() > 0);
if let Some(shared) = reg.get(&key).and_then(Weak::upgrade) {
return Ok(ProcessLock(shared));
}
let path = Self::lock_path(db_path);
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&path)
.map_err(Error::Io)?;
match file.try_lock() {
Ok(()) => {
let locked = Arc::new(LockedFile(file));
reg.insert(key, Arc::downgrade(&locked));
Ok(ProcessLock(locked))
}
Err(std::fs::TryLockError::WouldBlock) => {
Err(Error::DatabaseLocked(db_path.display().to_string()))
}
Err(std::fs::TryLockError::Error(e)) => Err(Error::Io(e)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn second_handle_in_this_process_shares_the_lock() {
let dir = tempfile::tempdir().expect("tempdir");
let a = ProcessLock::acquire(dir.path()).expect("first acquire");
let b = ProcessLock::acquire(dir.path()).expect("second acquire, same process");
assert!(
Arc::ptr_eq(&a.0, &b.0),
"same-process handles must share one Arc<LockedFile>, not contend"
);
}
#[test]
fn lock_is_released_when_every_handle_in_this_process_drops() {
let dir = tempfile::tempdir().expect("tempdir");
let a = ProcessLock::acquire(dir.path()).expect("first acquire");
drop(a);
let _b = ProcessLock::acquire(dir.path()).expect("re-acquire after drop");
}
#[test]
fn dead_entry_for_a_specific_root_is_swept_by_the_next_acquire_anywhere() {
let dir_a = tempfile::tempdir().expect("tempdir a");
let key_a = dir_a
.path()
.canonicalize()
.expect("canonicalize dir_a (it exists — tempdir created it)");
let lock_a = ProcessLock::acquire(dir_a.path()).expect("acquire a");
drop(lock_a);
let dir_b = tempfile::tempdir().expect("tempdir b");
let _lock_b = ProcessLock::acquire(dir_b.path()).expect("acquire b — triggers the sweep");
let reg = registry().lock().expect("registry lock");
assert!(
!reg.contains_key(&key_a),
"a dead entry for a specific, no-longer-referenced root must be swept by the next \
acquire() call anywhere, not linger indefinitely"
);
}
}