Skip to main content

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}