use std::path::{Path, PathBuf};
use crate::error::Result;
use crate::lsm::container::{self, ConvergePolicy};
pub const LOCK_FILE_NAME: &str = ".alopex.lock";
const LOCK_FILE_SUFFIX: &str = ".lock";
#[derive(Debug)]
pub(crate) struct DirectoryLock {
#[cfg(test)]
path: Option<PathBuf>,
#[cfg(not(target_arch = "wasm32"))]
_file: Option<std::fs::File>,
#[cfg(target_arch = "wasm32")]
_wasm: (),
}
impl DirectoryLock {
#[cfg(any(test, target_arch = "wasm32"))]
pub(crate) fn disabled() -> Self {
Self {
#[cfg(test)]
path: None,
#[cfg(not(target_arch = "wasm32"))]
_file: None,
#[cfg(target_arch = "wasm32")]
_wasm: (),
}
}
#[cfg(test)]
pub(crate) fn path(&self) -> Option<&Path> {
self.path.as_deref()
}
}
pub(crate) fn lock_path_for(data_dir: &Path, policy: &ConvergePolicy) -> PathBuf {
let container = match policy {
ConvergePolicy::Always { container } => Some(container.clone()),
ConvergePolicy::SidecarOnly | ConvergePolicy::Never => {
container::container_path_for(data_dir, &ConvergePolicy::SidecarOnly)
}
};
match container {
Some(container) => append_lock_suffix(&container),
None => data_dir.join(LOCK_FILE_NAME),
}
}
pub fn is_lock_file(path: &Path) -> bool {
path.file_name()
.is_some_and(|name| name.as_encoded_bytes().ends_with(LOCK_FILE_NAME.as_bytes()))
}
fn append_lock_suffix(container: &Path) -> PathBuf {
let mut name = container.as_os_str().to_os_string();
name.push(LOCK_FILE_SUFFIX);
PathBuf::from(name)
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn acquire(data_dir: &Path, lock_path: &Path) -> Result<DirectoryLock> {
use std::fs::{OpenOptions, TryLockError};
use crate::error::Error;
if let Some(parent) = lock_path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
let file = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(lock_path)?;
match file.try_lock() {
Ok(()) => {}
Err(TryLockError::WouldBlock) => {
return Err(Error::AlreadyOpen {
path: data_dir.to_path_buf(),
lock_path: lock_path.to_path_buf(),
holder: read_holder(lock_path),
});
}
Err(TryLockError::Error(err)) => return Err(Error::Io(err)),
}
let _ = write_holder(&file);
Ok(DirectoryLock {
#[cfg(test)]
path: Some(lock_path.to_path_buf()),
_file: Some(file),
})
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn acquire(_data_dir: &Path, _lock_path: &Path) -> Result<DirectoryLock> {
Ok(DirectoryLock::disabled())
}
#[cfg(not(target_arch = "wasm32"))]
fn write_holder(file: &std::fs::File) -> std::io::Result<()> {
use std::io::{Seek, SeekFrom, Write};
let line = holder_line();
file.set_len(0)?;
let mut handle = file;
handle.seek(SeekFrom::Start(0))?;
handle.write_all(line.as_bytes())?;
handle.flush()?;
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
fn holder_line() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let pid = std::process::id();
let exe = std::env::current_exe()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "unknown".to_string());
let host = std::env::var("HOSTNAME")
.or_else(|_| std::env::var("COMPUTERNAME"))
.unwrap_or_else(|_| "unknown".to_string());
let started_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
format!("pid={pid} host={host} exe={exe} started_ms={started_ms}\n")
}
#[cfg(not(target_arch = "wasm32"))]
fn read_holder(lock_path: &Path) -> String {
match std::fs::read_to_string(lock_path) {
Ok(text) => {
let line = text.trim();
if line.is_empty() {
"unknown".to_string()
} else {
line.to_string()
}
}
Err(_) => "unknown".to_string(),
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::*;
use crate::error::Error;
use tempfile::tempdir;
#[test]
fn lock_path_table_pins_the_outside_the_sidecar_invariant() {
assert_eq!(
lock_path_for(Path::new("/t/mydb.alopex.d"), &ConvergePolicy::SidecarOnly),
PathBuf::from("/t/mydb.alopex.lock"),
"sidecar shape locks beside the container, never inside the sidecar"
);
assert_eq!(
lock_path_for(Path::new("/t/mydb.alopex.d"), &ConvergePolicy::Never),
PathBuf::from("/t/mydb.alopex.lock"),
);
assert_eq!(
lock_path_for(Path::new("/t/plaindir"), &ConvergePolicy::SidecarOnly),
PathBuf::from("/t/plaindir/.alopex.lock"),
);
assert_eq!(
lock_path_for(Path::new("/t/plaindir"), &ConvergePolicy::Never),
PathBuf::from("/t/plaindir/.alopex.lock"),
);
assert_eq!(
lock_path_for(
Path::new("/t/x.alopex.d.tmp"),
&ConvergePolicy::Always {
container: PathBuf::from("/t/x.alopex"),
}
),
PathBuf::from("/t/x.alopex.lock"),
);
}
#[test]
fn lock_files_are_recognized_for_exclusion() {
assert!(is_lock_file(Path::new("/t/db/.alopex.lock")));
assert!(is_lock_file(Path::new("/t/mydb.alopex.lock")));
assert!(!is_lock_file(Path::new("/t/db/lsm.wal")));
assert!(!is_lock_file(Path::new("/t/mydb.alopex")));
assert!(!is_lock_file(Path::new("/t/db/sst/1.sst")));
}
#[cfg(unix)]
#[test]
fn lock_file_detection_does_not_require_a_utf8_database_name() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let mut name = vec![0xff];
name.extend_from_slice(b".alopex.lock");
assert!(is_lock_file(Path::new(&OsString::from_vec(name))));
}
#[test]
fn lock_suffix_is_appended_not_substituted() {
assert_eq!(
append_lock_suffix(Path::new("/t/a.alopex")),
PathBuf::from("/t/a.alopex.lock")
);
assert_eq!(
append_lock_suffix(Path::new("/t/a.sqlite")),
PathBuf::from("/t/a.sqlite.lock")
);
}
#[test]
fn second_acquire_reports_already_open() {
let dir = tempdir().unwrap();
let data_dir = dir.path().join("db");
let lock_path = data_dir.join(LOCK_FILE_NAME);
let held = acquire(&data_dir, &lock_path).unwrap();
assert_eq!(held.path(), Some(lock_path.as_path()));
let err = acquire(&data_dir, &lock_path).unwrap_err();
match &err {
Error::AlreadyOpen {
path,
lock_path: reported,
..
} => {
assert_eq!(path, &data_dir);
assert_eq!(reported, &lock_path);
}
other => panic!("expected AlreadyOpen, got {other:?}"),
}
assert!(err.to_string().contains("already open by another process"));
}
#[test]
fn dropping_the_lock_releases_it() {
let dir = tempdir().unwrap();
let data_dir = dir.path().join("db");
let lock_path = data_dir.join(LOCK_FILE_NAME);
let held = acquire(&data_dir, &lock_path).unwrap();
drop(held);
let again = acquire(&data_dir, &lock_path).unwrap();
drop(again);
assert!(
lock_path.exists(),
"the lock file is left behind on purpose (裁定 D8)"
);
}
#[test]
fn a_losing_open_does_not_truncate_the_holder_record() {
let dir = tempdir().unwrap();
let data_dir = dir.path().join("db");
let lock_path = data_dir.join(LOCK_FILE_NAME);
let _held = acquire(&data_dir, &lock_path).unwrap();
let _ = acquire(&data_dir, &lock_path).unwrap_err();
let holder = read_holder(&lock_path);
assert!(
holder.contains(&format!("pid={}", std::process::id())),
"the winner's diagnostics must survive the loser's open, got: {holder}"
);
}
#[test]
fn an_unlocked_leftover_lock_file_is_inert() {
let dir = tempdir().unwrap();
let data_dir = dir.path().join("db");
let lock_path = data_dir.join(LOCK_FILE_NAME);
std::fs::create_dir_all(&data_dir).unwrap();
std::fs::write(&lock_path, "pid=999999 host=gone exe=/nope started_ms=0\n").unwrap();
drop(acquire(&data_dir, &lock_path).unwrap());
}
}