1use std::collections::HashSet;
31use std::fs::{File, OpenOptions};
32use std::path::{Path, PathBuf};
33use std::sync::{Mutex, OnceLock};
34use std::time::{Duration, Instant};
35
36use crate::error::SqliteStoreError;
37
38fn held_exclusive_locks() -> &'static Mutex<HashSet<PathBuf>> {
47 static REGISTRY: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
48 REGISTRY.get_or_init(|| Mutex::new(HashSet::new()))
49}
50
51fn registry_key(lock_path: &Path) -> PathBuf {
52 std::fs::canonicalize(lock_path).unwrap_or_else(|_| lock_path.to_path_buf())
53}
54
55fn process_holds_exclusive(lock_path: &Path) -> bool {
56 held_exclusive_locks()
57 .lock()
58 .map(|held| held.contains(®istry_key(lock_path)))
59 .unwrap_or(false)
60}
61
62pub const FENCE_LOCK_SUFFIX: &str = "mfence";
64
65pub fn fence_lock_path(db_path: &Path) -> PathBuf {
67 let mut name = db_path.file_name().unwrap_or_default().to_os_string();
68 name.push(".");
69 name.push(FENCE_LOCK_SUFFIX);
70 db_path.with_file_name(name)
71}
72
73fn open_lock_file(lock_path: &Path) -> Option<File> {
74 OpenOptions::new()
77 .read(true)
78 .write(true)
79 .create(true)
80 .truncate(false)
81 .open(lock_path)
82 .ok()
83}
84
85#[derive(Debug)]
89pub struct OperationGuard {
90 _lock: Option<File>,
91}
92
93impl OperationGuard {
94 pub fn for_database(db_path: &Path) -> Result<Self, SqliteStoreError> {
100 if db_path.file_name().is_none() || db_path.as_os_str() == ":memory:" {
101 return Ok(Self { _lock: None });
102 }
103 let lock_path = fence_lock_path(db_path);
104 if process_holds_exclusive(&lock_path) {
105 return Ok(Self { _lock: None });
107 }
108 let Some(file) = open_lock_file(&lock_path) else {
109 return Ok(Self { _lock: None });
110 };
111 match file.try_lock_shared() {
112 Ok(()) => Ok(Self { _lock: Some(file) }),
113 Err(err) if is_would_block(&err) => Err(SqliteStoreError::MaintenanceFenceHeld {
114 path: db_path.to_path_buf(),
115 }),
116 Err(_) => Ok(Self { _lock: None }),
118 }
119 }
120}
121
122#[derive(Debug)]
129pub struct ExclusiveFence {
130 _lock: File,
131 lock_path: PathBuf,
132}
133
134impl ExclusiveFence {
135 pub fn try_acquire(db_path: &Path) -> Result<Option<Self>, SqliteStoreError> {
137 let lock_path = fence_lock_path(db_path);
138 let file = OpenOptions::new()
139 .read(true)
140 .write(true)
141 .create(true)
142 .truncate(false)
143 .open(&lock_path)?;
144 match file.try_lock() {
145 Ok(()) => {
146 if let Ok(mut held) = held_exclusive_locks().lock() {
147 held.insert(registry_key(&lock_path));
148 }
149 Ok(Some(Self {
150 _lock: file,
151 lock_path,
152 }))
153 }
154 Err(err) if is_would_block(&err) => Ok(None),
155 Err(err) => Err(SqliteStoreError::Io(std::io::Error::other(err))),
156 }
157 }
158
159 pub fn acquire(db_path: &Path, deadline: Duration) -> Result<Self, SqliteStoreError> {
162 let started = Instant::now();
163 loop {
164 if let Some(fence) = Self::try_acquire(db_path)? {
165 return Ok(fence);
166 }
167 if started.elapsed() >= deadline {
168 return Err(SqliteStoreError::MaintenanceFenceHeld {
169 path: db_path.to_path_buf(),
170 });
171 }
172 std::thread::sleep(Duration::from_millis(25));
173 }
174 }
175
176 pub fn lock_path(&self) -> &Path {
178 &self.lock_path
179 }
180}
181
182impl Drop for ExclusiveFence {
183 fn drop(&mut self) {
184 if let Ok(mut held) = held_exclusive_locks().lock() {
185 held.remove(®istry_key(&self.lock_path));
186 }
187 }
188}
189
190fn is_would_block(err: &std::fs::TryLockError) -> bool {
191 matches!(err, std::fs::TryLockError::WouldBlock)
192}
193
194#[cfg(test)]
195#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
196mod tests {
197 use super::*;
198
199 #[test]
200 fn shared_guards_coexist() {
201 let dir = tempfile::tempdir().expect("tempdir");
202 let db = dir.path().join("db.sqlite3");
203 let g1 = OperationGuard::for_database(&db).expect("g1");
204 let g2 = OperationGuard::for_database(&db).expect("g2");
205 drop((g1, g2));
206 }
207
208 #[test]
209 fn exclusive_fence_blocks_foreign_operations_typed() {
210 let dir = tempfile::tempdir().expect("tempdir");
211 let db = dir.path().join("db.sqlite3");
212 let lock_path = fence_lock_path(&db);
216 let foreign = OpenOptions::new()
217 .read(true)
218 .write(true)
219 .create(true)
220 .truncate(false)
221 .open(&lock_path)
222 .expect("open lock file");
223 foreign.try_lock().expect("foreign exclusive lock");
224 let err = OperationGuard::for_database(&db).expect_err("fence must block foreign ops");
225 assert!(matches!(err, SqliteStoreError::MaintenanceFenceHeld { .. }));
226 drop(foreign);
227 OperationGuard::for_database(&db).expect("released after drop");
228 }
229
230 #[test]
231 fn exclusive_fence_waits_for_guard_drain() {
232 let dir = tempfile::tempdir().expect("tempdir");
233 let db = dir.path().join("db.sqlite3");
234 let guard = OperationGuard::for_database(&db).expect("guard");
235 assert!(
236 ExclusiveFence::try_acquire(&db).expect("io").is_none(),
237 "fence must not be acquirable while an operation is in flight"
238 );
239 let handle = std::thread::spawn(move || {
240 ExclusiveFence::acquire(&db, Duration::from_secs(5)).expect("acquire")
241 });
242 std::thread::sleep(Duration::from_millis(100));
243 drop(guard);
244 let fence = handle.join().expect("thread");
245 assert!(fence.lock_path().ends_with("db.sqlite3.mfence"));
246 }
247
248 #[test]
249 fn fence_holder_operations_self_admit() {
250 let dir = tempfile::tempdir().expect("tempdir");
251 let db = dir.path().join("db.sqlite3");
252 let fence = ExclusiveFence::try_acquire(&db)
253 .expect("io")
254 .expect("acquired");
255 let guard = OperationGuard::for_database(&db).expect("holder self-admission");
258 drop(guard);
259 drop(fence);
260 OperationGuard::for_database(&db).expect("released");
262 let fence = ExclusiveFence::try_acquire(&db)
264 .expect("io")
265 .expect("reacquired");
266 drop(fence);
267 }
268
269 #[test]
270 fn in_memory_paths_get_noop_guards() {
271 let guard = OperationGuard::for_database(Path::new(":memory:")).expect("noop");
272 drop(guard);
273 }
274
275 #[test]
276 fn fence_lock_path_shape() {
277 assert_eq!(
278 fence_lock_path(Path::new("/a/b/sessions.sqlite3")),
279 PathBuf::from("/a/b/sessions.sqlite3.mfence")
280 );
281 }
282}