1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
//! Process-level exclusive lock on a database directory.
//!
//! Every armdb directory holding state — the `Db` root and each collection
//! directory — is guarded by a `db.lock` sidecar carrying an exclusive advisory
//! lock. Without it two engines could run independent in-memory indexes over
//! one shard set and silently drop each other's writes.
//!
//! The lock lives exactly as long as the returned `File`, because the kernel
//! releases it when the fd closes. That holds through `SIGKILL`, panic and OOM.
//! The `db.lock` file itself persists between runs — it is the *lock* that never
//! goes stale, so nothing needs cleaning up at startup and a leftover file is
//! not evidence of a live holder.
//!
//! `flock` binds the lock to the *open file description*, not to the process,
//! so a second `open` inside this process is rejected too. Callers rely on
//! that: it is what makes a second `Db::open` on a live path an error.
//!
//! See `docs/superpowers/specs/26-07-15-db-directory-file-lock.md`.
use std::fs::{File, OpenOptions, TryLockError};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::Path;
use crate::error::{DbError, DbResult};
/// Name of the lock sidecar inside a database directory. Reserved as a
/// collection name (`Db::RESERVED_COLLECTION_NAMES`) because `tree_path` would
/// otherwise map a collection onto the root's own lock file.
pub(crate) const LOCK_FILE_NAME: &str = "db.lock";
/// Take the exclusive lease on `<dir>/db.lock`. `dir` must already exist.
///
/// Hold the returned `File` for as long as anything can touch the directory's
/// files, and drop it only after they are flushed and closed — releasing it
/// early lets the next process in while this one is still writing.
pub(crate) fn acquire(dir: &Path) -> DbResult<File> {
let path = dir.join(LOCK_FILE_NAME);
// No `truncate`: the holder's pid must survive our failed attempt.
let mut file = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(&path)?;
match file.try_lock() {
Ok(()) => {}
Err(TryLockError::WouldBlock) => {
return Err(DbError::Locked {
path: dir.to_path_buf(),
pid: read_pid(&mut file),
});
}
Err(TryLockError::Error(e)) => return Err(DbError::Io(e)),
}
// The lease is ours now, so this write cannot race another holder. The pid
// is only a diagnostic, so failing to record it must not fail the open.
if let Err(e) = write_pid(&mut file) {
tracing::warn!(?path, error = %e, "could not record pid in the lock file");
}
Ok(file)
}
/// Best-effort read of the holder's pid. Unreadable, empty, torn or malformed
/// content all mean "no hint" — never an error, since the lock state is already
/// known and the pid only sharpens the message.
fn read_pid(file: &mut File) -> Option<u32> {
let mut buf = String::new();
file.seek(SeekFrom::Start(0)).ok()?;
// A pid is at most 10 digits; cap the read so a corrupted or replaced
// lock file can't drive an unbounded allocation. The pid is only a hint.
file.take(20).read_to_string(&mut buf).ok()?;
buf.trim().parse().ok()
}
fn write_pid(file: &mut File) -> std::io::Result<()> {
file.set_len(0)?;
file.seek(SeekFrom::Start(0))?;
writeln!(file, "{}", std::process::id())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::DbError;
#[test]
fn second_acquire_reports_locked_with_the_holder_pid() {
let dir = tempfile::tempdir().unwrap();
let _held = acquire(dir.path()).expect("first acquire must succeed");
let err = acquire(dir.path()).expect_err("second acquire must fail");
match err {
DbError::Locked { path, pid } => {
assert_eq!(path, dir.path());
assert_eq!(pid, Some(std::process::id()), "holder pid must be recorded");
}
other => panic!("expected DbError::Locked, got {other:?}"),
}
}
#[test]
fn lock_is_released_when_the_file_is_dropped() {
let dir = tempfile::tempdir().unwrap();
let held = acquire(dir.path()).expect("first acquire");
drop(held);
acquire(dir.path()).expect("reacquire after drop must succeed");
}
#[test]
fn unreadable_pid_yields_no_hint_rather_than_an_error() {
let dir = tempfile::tempdir().unwrap();
let _held = acquire(dir.path()).expect("first acquire");
// Clobber the pid the holder wrote; the lock itself is unaffected.
std::fs::write(dir.path().join(LOCK_FILE_NAME), b"not-a-pid").unwrap();
let err = acquire(dir.path()).expect_err("second acquire must still fail");
match err {
DbError::Locked { pid, .. } => {
assert_eq!(pid, None, "garbage pid must degrade to None")
}
other => panic!("expected DbError::Locked, got {other:?}"),
}
}
}