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 crate::profile::validate_database_file(db_path)?;
105 let lock_path = fence_lock_path(db_path);
106 if process_holds_exclusive(&lock_path) {
107 return Ok(Self { _lock: None });
109 }
110 let Some(file) = open_lock_file(&lock_path) else {
111 return Ok(Self { _lock: None });
112 };
113 match file.try_lock_shared() {
114 Ok(()) => Ok(Self { _lock: Some(file) }),
115 Err(err) if is_would_block(&err) => Err(SqliteStoreError::MaintenanceFenceHeld {
116 path: db_path.to_path_buf(),
117 }),
118 Err(_) => Ok(Self { _lock: None }),
120 }
121 }
122}
123
124#[derive(Debug)]
131pub struct ExclusiveFence {
132 _lock: File,
133 lock_path: PathBuf,
134}
135
136impl ExclusiveFence {
137 pub fn try_acquire(db_path: &Path) -> Result<Option<Self>, SqliteStoreError> {
139 let lock_path = fence_lock_path(db_path);
140 let file = OpenOptions::new()
141 .read(true)
142 .write(true)
143 .create(true)
144 .truncate(false)
145 .open(&lock_path)?;
146 match file.try_lock() {
147 Ok(()) => {
148 if let Ok(mut held) = held_exclusive_locks().lock() {
149 held.insert(registry_key(&lock_path));
150 }
151 Ok(Some(Self {
152 _lock: file,
153 lock_path,
154 }))
155 }
156 Err(err) if is_would_block(&err) => Ok(None),
157 Err(err) => Err(SqliteStoreError::Io(std::io::Error::other(err))),
158 }
159 }
160
161 pub fn acquire(db_path: &Path, deadline: Duration) -> Result<Self, SqliteStoreError> {
164 let started = Instant::now();
165 loop {
166 if let Some(fence) = Self::try_acquire(db_path)? {
167 return Ok(fence);
168 }
169 if started.elapsed() >= deadline {
170 return Err(SqliteStoreError::MaintenanceFenceHeld {
171 path: db_path.to_path_buf(),
172 });
173 }
174 std::thread::sleep(Duration::from_millis(25));
175 }
176 }
177
178 pub fn lock_path(&self) -> &Path {
180 &self.lock_path
181 }
182}
183
184impl Drop for ExclusiveFence {
185 fn drop(&mut self) {
186 if let Ok(mut held) = held_exclusive_locks().lock() {
187 held.remove(®istry_key(&self.lock_path));
188 }
189 }
190}
191
192fn is_would_block(err: &std::fs::TryLockError) -> bool {
193 matches!(err, std::fs::TryLockError::WouldBlock)
194}
195
196#[cfg(test)]
197#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
198mod tests {
199 use super::*;
200
201 #[test]
202 fn shared_guards_coexist() {
203 let dir = tempfile::tempdir().expect("tempdir");
204 let db = dir.path().join("db.sqlite3");
205 let g1 = OperationGuard::for_database(&db).expect("g1");
206 let g2 = OperationGuard::for_database(&db).expect("g2");
207 drop((g1, g2));
208 }
209
210 #[test]
211 fn exclusive_fence_blocks_foreign_operations_typed() {
212 let dir = tempfile::tempdir().expect("tempdir");
213 let db = dir.path().join("db.sqlite3");
214 let lock_path = fence_lock_path(&db);
218 let foreign = OpenOptions::new()
219 .read(true)
220 .write(true)
221 .create(true)
222 .truncate(false)
223 .open(&lock_path)
224 .expect("open lock file");
225 foreign.try_lock().expect("foreign exclusive lock");
226 let err = OperationGuard::for_database(&db).expect_err("fence must block foreign ops");
227 assert!(matches!(err, SqliteStoreError::MaintenanceFenceHeld { .. }));
228 drop(foreign);
229 OperationGuard::for_database(&db).expect("released after drop");
230 }
231
232 #[test]
233 fn exclusive_fence_waits_for_guard_drain() {
234 let dir = tempfile::tempdir().expect("tempdir");
235 let db = dir.path().join("db.sqlite3");
236 let guard = OperationGuard::for_database(&db).expect("guard");
237 assert!(
238 ExclusiveFence::try_acquire(&db).expect("io").is_none(),
239 "fence must not be acquirable while an operation is in flight"
240 );
241 let handle = std::thread::spawn(move || {
242 ExclusiveFence::acquire(&db, Duration::from_secs(5)).expect("acquire")
243 });
244 std::thread::sleep(Duration::from_millis(100));
245 drop(guard);
246 let fence = handle.join().expect("thread");
247 assert!(fence.lock_path().ends_with("db.sqlite3.mfence"));
248 }
249
250 #[test]
251 fn fence_holder_operations_self_admit() {
252 let dir = tempfile::tempdir().expect("tempdir");
253 let db = dir.path().join("db.sqlite3");
254 let fence = ExclusiveFence::try_acquire(&db)
255 .expect("io")
256 .expect("acquired");
257 let guard = OperationGuard::for_database(&db).expect("holder self-admission");
260 drop(guard);
261 drop(fence);
262 OperationGuard::for_database(&db).expect("released");
264 let fence = ExclusiveFence::try_acquire(&db)
266 .expect("io")
267 .expect("reacquired");
268 drop(fence);
269 }
270
271 #[test]
272 fn in_memory_paths_get_noop_guards() {
273 let guard = OperationGuard::for_database(Path::new(":memory:")).expect("noop");
274 drop(guard);
275 }
276
277 #[test]
278 fn fence_lock_path_shape() {
279 assert_eq!(
280 fence_lock_path(Path::new("/a/b/sessions.sqlite3")),
281 PathBuf::from("/a/b/sessions.sqlite3.mfence")
282 );
283 }
284}