mempal_runtime/ingest/
lock.rs1use std::fs::{File, OpenOptions};
17use std::io;
18use std::path::{Path, PathBuf};
19use std::time::{Duration, Instant};
20
21use thiserror::Error;
22
23#[derive(Debug, Error)]
24pub enum LockError {
25 #[error("timed out after {} ms acquiring ingest lock on {path}", timeout.as_millis())]
26 Timeout { path: PathBuf, timeout: Duration },
27 #[error("io error on ingest lock {path}: {source}")]
28 Io {
29 path: PathBuf,
30 #[source]
31 source: io::Error,
32 },
33 #[error("invalid source_key `{0}` (empty or contains path separators)")]
34 InvalidSourceKey(String),
35}
36
37#[derive(Debug)]
40pub struct IngestLock {
41 _file: File,
42 path: PathBuf,
43 wait: Duration,
44}
45
46impl IngestLock {
47 pub fn wait_duration(&self) -> Duration {
48 self.wait
49 }
50
51 pub fn path(&self) -> &Path {
52 &self.path
53 }
54}
55
56pub fn source_key(source_file: &Path) -> String {
63 use std::hash::{Hash, Hasher};
64 let mut hasher = std::collections::hash_map::DefaultHasher::new();
65 source_file.to_string_lossy().hash(&mut hasher);
66 format!("{:016x}", hasher.finish())
67}
68
69pub fn acquire_source_lock(
70 mempal_home: &Path,
71 source_key: &str,
72 timeout: Duration,
73) -> Result<IngestLock, LockError> {
74 if source_key.is_empty()
75 || source_key.contains('/')
76 || source_key.contains('\\')
77 || source_key.contains("..")
78 {
79 return Err(LockError::InvalidSourceKey(source_key.to_string()));
80 }
81
82 let locks_dir = mempal_home.join("locks");
83 if !locks_dir.exists() {
84 std::fs::create_dir_all(&locks_dir).map_err(|e| LockError::Io {
85 path: locks_dir.clone(),
86 source: e,
87 })?;
88 }
89 let lock_path = locks_dir.join(format!("{source_key}.lock"));
90
91 let file = OpenOptions::new()
92 .create(true)
93 .read(true)
94 .truncate(false)
95 .write(true)
96 .open(&lock_path)
97 .map_err(|e| LockError::Io {
98 path: lock_path.clone(),
99 source: e,
100 })?;
101
102 let start = Instant::now();
103 loop {
104 match imp::try_lock_exclusive(&file) {
105 Ok(()) => {
106 return Ok(IngestLock {
107 _file: file,
108 path: lock_path,
109 wait: start.elapsed(),
110 });
111 }
112 Err(imp::LockAcquire::WouldBlock) => {
113 if start.elapsed() >= timeout {
114 return Err(LockError::Timeout {
115 path: lock_path,
116 timeout,
117 });
118 }
119 std::thread::sleep(Duration::from_millis(50 + jitter_ms()));
120 }
121 Err(imp::LockAcquire::Io(e)) => {
122 return Err(LockError::Io {
123 path: lock_path,
124 source: e,
125 });
126 }
127 }
128 }
129}
130
131fn jitter_ms() -> u64 {
132 use std::time::{SystemTime, UNIX_EPOCH};
133 SystemTime::now()
134 .duration_since(UNIX_EPOCH)
135 .map(|d| d.subsec_nanos() as u64 % 30)
136 .unwrap_or(0)
137}
138
139#[cfg(unix)]
140mod imp {
141 use std::fs::File;
142 use std::io;
143 use std::os::fd::AsRawFd;
144
145 const LOCK_EX: i32 = 2;
146 const LOCK_NB: i32 = 4;
147 const EWOULDBLOCK: i32 = 35; unsafe extern "C" {
150 fn flock(fd: i32, operation: i32) -> i32;
151 }
152
153 pub enum LockAcquire {
154 WouldBlock,
155 Io(io::Error),
156 }
157
158 pub fn try_lock_exclusive(file: &File) -> Result<(), LockAcquire> {
159 let fd = file.as_raw_fd();
160 let ret = unsafe { flock(fd, LOCK_EX | LOCK_NB) };
161 if ret == 0 {
162 return Ok(());
163 }
164 let err = io::Error::last_os_error();
165 match err.raw_os_error() {
168 Some(code) if code == EWOULDBLOCK || code == 11 => Err(LockAcquire::WouldBlock),
169 _ => Err(LockAcquire::Io(err)),
170 }
171 }
172}
173
174#[cfg(windows)]
175mod imp {
176 use std::fs::File;
177 use std::io;
178
179 pub enum LockAcquire {
180 WouldBlock,
181 Io(io::Error),
182 }
183
184 pub fn try_lock_exclusive(_file: &File) -> Result<(), LockAcquire> {
187 Ok(())
188 }
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194
195 #[test]
196 fn test_source_key_is_deterministic() {
197 let p = Path::new("/tmp/foo/bar.md");
198 assert_eq!(source_key(p), source_key(p));
199 }
200
201 #[test]
202 fn test_source_key_is_fs_safe() {
203 let k = source_key(Path::new("/tmp/a/b/c.md"));
204 assert_eq!(k.len(), 16);
205 assert!(k.chars().all(|c| c.is_ascii_hexdigit()));
206 }
207
208 #[test]
209 fn test_invalid_source_key_with_slash_rejected() {
210 let tmp = tempfile::tempdir().unwrap();
211 let err = acquire_source_lock(tmp.path(), "a/b", Duration::from_millis(100));
212 assert!(matches!(err, Err(LockError::InvalidSourceKey(_))));
213 }
214
215 #[test]
216 fn test_invalid_source_key_with_traversal_rejected() {
217 let tmp = tempfile::tempdir().unwrap();
218 let err = acquire_source_lock(tmp.path(), "..", Duration::from_millis(100));
219 assert!(matches!(err, Err(LockError::InvalidSourceKey(_))));
220 }
221
222 #[cfg(unix)]
223 #[test]
224 fn test_acquire_then_release_round_trip() {
225 let tmp = tempfile::tempdir().unwrap();
226 let key = source_key(Path::new("/tmp/test-source"));
227
228 let guard1 =
229 acquire_source_lock(tmp.path(), &key, Duration::from_secs(1)).expect("first acquire");
230 drop(guard1);
231
232 let guard2 = acquire_source_lock(tmp.path(), &key, Duration::from_millis(500))
233 .expect("second acquire after drop");
234 assert!(guard2.wait_duration() < Duration::from_millis(500));
235 }
236
237 #[cfg(unix)]
238 #[test]
239 fn test_concurrent_holders_serialize() {
240 use std::sync::Arc;
241 use std::sync::atomic::{AtomicUsize, Ordering};
242 use std::thread;
243
244 let tmp = Arc::new(tempfile::tempdir().unwrap());
245 let key = source_key(Path::new("/tmp/concurrent-source"));
246 let counter = Arc::new(AtomicUsize::new(0));
247
248 let handles: Vec<_> = (0..3)
249 .map(|_| {
250 let tmp = Arc::clone(&tmp);
251 let key = key.clone();
252 let counter = Arc::clone(&counter);
253 thread::spawn(move || {
254 let guard = acquire_source_lock(tmp.path(), &key, Duration::from_secs(5))
255 .expect("acquire");
256 let inside = counter.fetch_add(1, Ordering::SeqCst);
259 assert_eq!(inside, 0, "serial critical section violated");
260 thread::sleep(Duration::from_millis(50));
261 counter.fetch_sub(1, Ordering::SeqCst);
262 drop(guard);
263 })
264 })
265 .collect();
266
267 for h in handles {
268 h.join().expect("thread");
269 }
270 }
271}