vaultdb_core/lock.rs
1//! Vault-scoped exclusive lock used to serialize mutations across processes.
2//!
3//! [`with_vault_lock`] acquires a `flock`-style exclusive lock on a hidden
4//! sentinel file at `<vault>/.vaultdb/lock` for the duration of `op`. Two
5//! `vaultdb-core` consumers calling `UpdateBuilder::execute` (or any other
6//! mutation builder's `execute`) on the same vault path will serialize.
7//!
8//! ## What this protects against
9//!
10//! - Two `vaultdb-core` instances racing on the same file (e.g. the CLI and
11//! eduport-tauri running simultaneously, both writing the same record).
12//! - Two threads in the same process racing on the same file.
13//!
14//! ## What this does NOT protect against
15//!
16//! - **External editors that don't take this lock** (Obsidian, Vim, VS Code).
17//! `flock` is advisory on Unix and only effective between processes that
18//! call it. The mitigation for editor races is mtime-based optimistic
19//! concurrency at write time, plus the atomic tempfile+rename in
20//! [`crate::writer::atomic_write`] which guarantees no partial-content
21//! reads. Eduport-core is expected to debounce its watcher long enough
22//! for atomic writes to settle.
23//! - **Power loss between write and fsync.** Use the [`crate::WriteOptions`]
24//! `fsync` flag for durability.
25//!
26//! ## Layout
27//!
28//! ```text
29//! <vault>/.vaultdb/
30//! lock # this module's sentinel
31//! rename-journal/ # transactional rename journals (next phase)
32//! ```
33//!
34//! `.vaultdb/` is hidden (dotfile) so it doesn't pollute regular `ls` output
35//! in the user's vault.
36
37use std::fs::OpenOptions;
38use std::path::Path;
39
40use fs2::FileExt;
41
42use crate::error::VaultdbError;
43
44/// Subdirectory under the vault root that holds vaultdb-core's
45/// metadata (locks, journals).
46pub(crate) const META_DIR: &str = ".vaultdb";
47
48/// Filename of the vault-scoped mutation lock sentinel.
49const LOCK_FILE: &str = "lock";
50
51/// Run `op` while holding an exclusive advisory lock on `<vault>/.vaultdb/lock`.
52///
53/// The directory and lock file are created on first call and persist after.
54/// The lock is released automatically when this function returns (success
55/// or error) via the returned `File`'s `Drop` impl, even if `op` panics.
56///
57/// `op`'s error type must be convertible from `std::io::Error` so file-system
58/// errors during lock acquisition can flow through naturally.
59pub fn with_vault_lock<F, R, E>(vault_root: &Path, op: F) -> Result<R, E>
60where
61 F: FnOnce() -> Result<R, E>,
62 E: From<std::io::Error>,
63{
64 let lock_dir = vault_root.join(META_DIR);
65 std::fs::create_dir_all(&lock_dir).map_err(E::from)?;
66
67 let lock_path = lock_dir.join(LOCK_FILE);
68 let lock_file = OpenOptions::new()
69 .read(true)
70 .write(true)
71 .create(true)
72 .truncate(false)
73 .open(&lock_path)
74 .map_err(E::from)?;
75
76 // `lock_exclusive` blocks until the lock is acquired. If another
77 // vaultdb instance is mid-mutation, we wait for it. There's no
78 // try-lock variant exposed here on purpose: callers shouldn't have
79 // to choose between "wait" and "fail-fast" for the common case.
80 lock_file.lock_exclusive().map_err(E::from)?;
81
82 // Run the caller's operation. Drop of `lock_file` at end of scope
83 // releases the flock; we don't need to do it explicitly. The result
84 // is returned regardless of success/error so the caller's error
85 // stays intact.
86 let result = op();
87
88 // Best-effort explicit unlock for clarity; Drop handles it too.
89 let _ = FileExt::unlock(&lock_file);
90 result
91}
92
93/// Convenience: same as [`with_vault_lock`] but pinned to `VaultdbError`.
94pub(crate) fn with_lock<F, R>(vault_root: &Path, op: F) -> Result<R, VaultdbError>
95where
96 F: FnOnce() -> Result<R, VaultdbError>,
97{
98 with_vault_lock(vault_root, op)
99}
100
101#[cfg(test)]
102mod tests {
103 use super::*;
104 use std::sync::Arc;
105 use std::sync::atomic::{AtomicUsize, Ordering};
106 use std::thread;
107 use std::time::Duration;
108 use tempfile::TempDir;
109
110 fn make_vault() -> TempDir {
111 let dir = TempDir::new().unwrap();
112 std::fs::create_dir(dir.path().join(".obsidian")).unwrap();
113 dir
114 }
115
116 #[test]
117 fn lock_file_and_meta_dir_are_created() {
118 let dir = make_vault();
119 let result: Result<(), VaultdbError> = with_lock(dir.path(), || Ok(()));
120 result.unwrap();
121
122 assert!(dir.path().join(".vaultdb").is_dir());
123 assert!(dir.path().join(".vaultdb").join("lock").is_file());
124 }
125
126 #[test]
127 fn lock_serializes_concurrent_callers() {
128 // Spawn N threads that each try to take the lock and increment a
129 // counter, holding the lock briefly. If the lock works, the
130 // observed counter values inside the critical section should
131 // never collide (each thread sees a unique pre-increment value).
132 let dir = make_vault();
133 let vault_path = dir.path().to_path_buf();
134 let counter = Arc::new(AtomicUsize::new(0));
135 let collisions = Arc::new(AtomicUsize::new(0));
136
137 let mut handles = Vec::new();
138 for _ in 0..8 {
139 let vault_path = vault_path.clone();
140 let counter = Arc::clone(&counter);
141 let collisions = Arc::clone(&collisions);
142 handles.push(thread::spawn(move || {
143 let result: Result<(), VaultdbError> = with_lock(&vault_path, || {
144 let before = counter.load(Ordering::SeqCst);
145 // Hold the lock long enough that a buggy lock would
146 // visibly let another thread interleave.
147 thread::sleep(Duration::from_millis(20));
148 let after = counter.load(Ordering::SeqCst);
149 if before != after {
150 collisions.fetch_add(1, Ordering::SeqCst);
151 }
152 counter.fetch_add(1, Ordering::SeqCst);
153 Ok(())
154 });
155 result.unwrap();
156 }));
157 }
158 for h in handles {
159 h.join().unwrap();
160 }
161
162 assert_eq!(
163 counter.load(Ordering::SeqCst),
164 8,
165 "every thread should have incremented exactly once"
166 );
167 assert_eq!(
168 collisions.load(Ordering::SeqCst),
169 0,
170 "lock failed to serialize: counter changed during a critical section"
171 );
172 }
173
174 #[test]
175 fn op_error_propagates_and_lock_releases() {
176 let dir = make_vault();
177
178 // First call returns an error from the closure. Subsequent calls
179 // must still acquire the lock cleanly (i.e., the error path must
180 // still release the lock).
181 let result: Result<(), VaultdbError> = with_lock(dir.path(), || {
182 Err(VaultdbError::SchemaError("intentional".into()))
183 });
184 assert!(matches!(
185 result,
186 Err(VaultdbError::SchemaError(ref m)) if m == "intentional"
187 ));
188
189 let result: Result<(), VaultdbError> = with_lock(dir.path(), || Ok(()));
190 result.unwrap();
191 }
192}