Skip to main content

zeppelin_embed/lifecycle/
lock.rs

1//! Store ownership via a process-local registry and an OS file lock.
2//!
3//! Two mechanisms are needed, because neither alone is sufficient:
4//!
5//! * The **process-local registry** provides same-process exclusion. POSIX
6//!   record locks do not conflict between descriptors held by one process, so
7//!   without the registry a second `Store` in the same process would be
8//!   admitted. Windows byte-range locks do conflict per handle, even inside
9//!   one process, but the registry is still what keeps the two platforms on
10//!   one contract: it owns exactly one descriptor per store, counts the local
11//!   shared holders, and refuses a conflicting mode before any lock call, so
12//!   admission does not depend on which platform is answering.
13//! * The **persistent `writer.lock`** provides cross-process exclusion and is
14//!   released by the operating system when the holder dies, orderly or not.
15//!
16//! The registry is keyed on the store directory's stable filesystem identity —
17//! `(st_dev, st_ino)` on Unix, `(volume serial, file index)` on Windows — never
18//! on path text, so case differences, separator differences, relative versus
19//! absolute spellings and supported junctions all converge on one store.
20//!
21//! Nothing else in this process may open [`STORE_LOCK_FILE`].
22
23#[cfg(all(test, feature = "graph-cypher"))]
24mod native_tests;
25
26use std::collections::BTreeMap;
27use std::fs::{File, OpenOptions};
28#[cfg(unix)]
29use std::os::fd::AsRawFd;
30#[cfg(unix)]
31use std::os::unix::fs::MetadataExt as _;
32use std::path::{Path, PathBuf};
33use std::sync::{Mutex, MutexGuard};
34
35/// Persistent lock filename; only [`StoreLock`] may open this file.
36pub const STORE_LOCK_FILE: &str = "writer.lock";
37
38/// Stable filesystem identity of a store directory.
39type StoreKey = (u64, u64, bool);
40
41#[derive(Clone, Copy, Debug, Eq, PartialEq)]
42enum LockMode {
43    Exclusive,
44    Shared,
45}
46
47struct RegistryEntry {
48    _file: File,
49    mode: LockMode,
50    shared_count: u32,
51}
52
53static HELD_STORES: Mutex<BTreeMap<StoreKey, RegistryEntry>> = Mutex::new(BTreeMap::new());
54
55/// Exclusive writer ownership automatically released when the process dies.
56#[derive(Debug)]
57pub struct StoreLock {
58    registry_key: StoreKey,
59    mode: LockMode,
60}
61
62impl StoreLock {
63    /// Attempts to acquire exclusive non-blocking ownership for one store.
64    ///
65    /// Registry admission happens first so a second same-process owner is
66    /// rejected before any lock file is touched.
67    pub fn acquire(directory: &Path) -> Result<Self, StoreLockError> {
68        Self::acquire_exclusive(directory, true)
69    }
70
71    fn acquire_exclusive(directory: &Path, create: bool) -> Result<Self, StoreLockError> {
72        Self::exclusive_named(directory, create, false)
73    }
74
75    pub(crate) fn reclaim_exclusive(directory: &Path) -> Result<Self, StoreLockError> {
76        Self::exclusive_named(directory, true, true)
77    }
78
79    fn exclusive_named(
80        directory: &Path,
81        create: bool,
82        lease: bool,
83    ) -> Result<Self, StoreLockError> {
84        let path = directory.join(if lease {
85            ".ze-readers.lock"
86        } else {
87            STORE_LOCK_FILE
88        });
89        let identity = store_identity(directory).map_err(|source| StoreLockError::Io {
90            path: directory.to_path_buf(),
91            source,
92        })?;
93        let registry_key = (identity.0, identity.1, lease);
94        let mut held = held_stores();
95        if held.contains_key(&registry_key) {
96            return Err(StoreLockError::Io {
97                path,
98                source: std::io::Error::from(std::io::ErrorKind::WouldBlock),
99            });
100        }
101        let file = open_lock_file(&path, create).map_err(|source| StoreLockError::Io {
102            path: path.clone(),
103            source,
104        })?;
105        if let Err(source) = lock_exclusive(&file) {
106            drop(file);
107            return Err(StoreLockError::Io { path, source });
108        }
109        held.insert(
110            registry_key,
111            RegistryEntry {
112                _file: file,
113                mode: LockMode::Exclusive,
114                shared_count: 0,
115            },
116        );
117        Ok(Self {
118            registry_key,
119            mode: LockMode::Exclusive,
120        })
121    }
122
123    /// Attempts to acquire non-blocking shared ownership for a graph reader.
124    ///
125    /// The first reader opens the existing lock file read-only and owns the
126    /// process's single descriptor. Further local readers increment the
127    /// checked holder count without opening the file or issuing another lock
128    /// call. Missing lock files are never created.
129    #[cfg(feature = "graph-cypher")]
130    #[allow(dead_code)] // Consumed by the ZE-40 native read-only constructor.
131    pub(crate) fn acquire_shared(directory: &Path) -> Result<Self, StoreLockError> {
132        Self::shared_named(directory, false, false)
133    }
134
135    pub(crate) fn reader_lease(directory: &Path) -> Result<Self, StoreLockError> {
136        Self::shared_named(directory, true, true)
137    }
138
139    pub(crate) fn reader_lease_read_only(directory: &Path) -> Result<Option<Self>, StoreLockError> {
140        match Self::shared_named(directory, true, false) {
141            Ok(lock) => Ok(Some(lock)),
142            Err(StoreLockError::Io { source, .. })
143                if source.kind() == std::io::ErrorKind::NotFound =>
144            {
145                Ok(None)
146            }
147            Err(error) => Err(error),
148        }
149    }
150
151    fn shared_named(directory: &Path, lease: bool, create: bool) -> Result<Self, StoreLockError> {
152        let path = directory.join(if lease {
153            ".ze-readers.lock"
154        } else {
155            STORE_LOCK_FILE
156        });
157        let identity = store_identity(directory).map_err(|source| StoreLockError::Io {
158            path: directory.to_path_buf(),
159            source,
160        })?;
161        let registry_key = (identity.0, identity.1, lease);
162        let mut held = held_stores();
163        if let Some(entry) = held.get_mut(&registry_key) {
164            if entry.mode == LockMode::Exclusive {
165                return Err(StoreLockError::Io {
166                    path,
167                    source: std::io::Error::from(std::io::ErrorKind::WouldBlock),
168                });
169            }
170            let Some(next_count) = entry.shared_count.checked_add(1) else {
171                return Err(StoreLockError::Io {
172                    path,
173                    source: std::io::Error::other("shared store lock holder count overflow"),
174                });
175            };
176            entry.shared_count = next_count;
177            return Ok(Self {
178                registry_key,
179                mode: LockMode::Shared,
180            });
181        }
182
183        let file = (if create {
184            open_lock_file(&path, true)
185        } else {
186            open_shared_lock_file(&path)
187        })
188        .map_err(|source| StoreLockError::Io {
189            path: path.clone(),
190            source,
191        })?;
192        if let Err(source) = lock_shared(&file) {
193            drop(file);
194            return Err(StoreLockError::Io { path, source });
195        }
196        held.insert(
197            registry_key,
198            RegistryEntry {
199                _file: file,
200                mode: LockMode::Shared,
201                shared_count: 1,
202            },
203        );
204        Ok(Self {
205            registry_key,
206            mode: LockMode::Shared,
207        })
208    }
209}
210
211fn held_stores() -> MutexGuard<'static, BTreeMap<StoreKey, RegistryEntry>> {
212    match HELD_STORES.lock() {
213        Ok(held) => held,
214        Err(poisoned) => poisoned.into_inner(),
215    }
216}
217
218/// Opens an already-existing lock file without write or creation authority.
219fn open_shared_lock_file(path: &Path) -> std::io::Result<File> {
220    let file = open_shared_lock_handle(path)?;
221    if file.metadata()?.is_file() {
222        Ok(file)
223    } else {
224        Err(std::io::Error::new(
225            std::io::ErrorKind::IsADirectory,
226            "store lock path is not a regular file",
227        ))
228    }
229}
230
231/// Read-only handle on the existing lock file; never creates and never writes.
232#[cfg(not(windows))]
233fn open_shared_lock_handle(path: &Path) -> std::io::Result<File> {
234    let mut options = OpenOptions::new();
235    options.read(true);
236    #[cfg(unix)]
237    {
238        use std::os::unix::fs::OpenOptionsExt as _;
239        options.custom_flags(libc::O_NOFOLLOW);
240    }
241    options.open(path)
242}
243
244/// Read-only handle that additionally refuses to share deletion.
245///
246/// This is the same reasoning `open_lock_file` already documents, applied to
247/// readers. Rust's default share mode includes `FILE_SHARE_DELETE`, and a
248/// reader-held lock file that anyone may rename or unlink is no protection at
249/// all: a writer would then create a fresh `writer.lock`, take an uncontended
250/// exclusive range on it, and run beside live readers. Sharing read and write
251/// but not deletion keeps the file in place for as long as any reader holds
252/// it, while still admitting the further readers and the eventual writer that
253/// the lock range itself arbitrates. No write access is requested, because
254/// `LockFileEx` needs only `GENERIC_READ`.
255#[cfg(windows)]
256fn open_shared_lock_handle(path: &Path) -> std::io::Result<File> {
257    // The existing share mode prevents deletion while the handle is held.
258    use std::os::windows::fs::OpenOptionsExt as _;
259
260    OpenOptions::new()
261        .read(true)
262        .share_mode(crate::sys::windows::FILE_SHARE_READ | crate::sys::windows::FILE_SHARE_WRITE)
263        .open(path)
264}
265
266/// The store directory's stable filesystem identity.
267#[cfg(unix)]
268pub(super) fn store_identity(directory: &Path) -> std::io::Result<(u64, u64)> {
269    let metadata = std::fs::metadata(directory)?;
270    Ok((metadata.dev(), metadata.ino()))
271}
272
273#[cfg(windows)]
274pub(super) fn store_identity(directory: &Path) -> std::io::Result<(u64, u64)> {
275    crate::sys::windows::directory_identity(directory)
276}
277
278#[cfg(not(any(unix, windows)))]
279pub(super) fn store_identity(_directory: &Path) -> std::io::Result<(u64, u64)> {
280    Err(std::io::Error::new(
281        std::io::ErrorKind::Unsupported,
282        "store identity requires a supported platform",
283    ))
284}
285
286/// Opens the persistent lock file with the access the platform lock needs.
287#[cfg(not(windows))]
288fn open_lock_file(path: &Path, create: bool) -> std::io::Result<File> {
289    let mut options = OpenOptions::new();
290    options
291        .create(create)
292        .truncate(false)
293        .read(true)
294        .write(true);
295    #[cfg(unix)]
296    {
297        use std::os::unix::fs::OpenOptionsExt as _;
298        options.custom_flags(libc::O_NOFOLLOW);
299    }
300    options.open(path)
301}
302
303/// Opens the persistent lock file, additionally refusing to share deletion.
304///
305/// Rust opens files with `FILE_SHARE_READ | FILE_SHARE_WRITE |
306/// FILE_SHARE_DELETE` by default. Sharing deletion would let any process
307/// unlink or rename `writer.lock` out from under a live writer, after which a
308/// second writer would create a fresh lock file and be admitted. Narrowing the
309/// share mode makes the held lock file undeletable and unrenameable for as long
310/// as ownership lasts. Handles are non-inheritable by default, so a child
311/// process cannot inherit writer ownership either.
312#[cfg(windows)]
313fn open_lock_file(path: &Path, create: bool) -> std::io::Result<File> {
314    // The existing share mode prevents deletion while the handle is held.
315    use std::os::windows::fs::OpenOptionsExt as _;
316
317    OpenOptions::new()
318        .create(create)
319        .truncate(false)
320        .read(true)
321        .write(true)
322        .share_mode(crate::sys::windows::FILE_SHARE_READ | crate::sys::windows::FILE_SHARE_WRITE)
323        .open(path)
324}
325
326#[cfg(unix)]
327fn lock_exclusive(file: &File) -> std::io::Result<()> {
328    let mut lock = unsafe {
329        // SAFETY: all-zero is a valid `flock` value before named fields are set.
330        std::mem::zeroed::<libc::flock>()
331    };
332    lock.l_type = libc::F_WRLCK as libc::c_short;
333    lock.l_whence = libc::SEEK_SET as libc::c_short;
334    lock.l_start = 0;
335    lock.l_len = 0;
336    let result = unsafe {
337        // SAFETY: `file` owns a live descriptor and `lock` remains valid for
338        // the duration of this non-blocking `F_SETLK` call.
339        libc::fcntl(file.as_raw_fd(), libc::F_SETLK, &lock)
340    };
341    if result == -1 {
342        let error = std::io::Error::last_os_error();
343        if error
344            .raw_os_error()
345            .is_some_and(|errno| errno == libc::EAGAIN || errno == libc::EACCES)
346        {
347            Err(std::io::Error::from(std::io::ErrorKind::WouldBlock))
348        } else {
349            Err(error)
350        }
351    } else {
352        Ok(())
353    }
354}
355
356#[cfg(unix)]
357#[allow(dead_code)] // Called by the ZE-40 producer seam above.
358fn lock_shared(file: &File) -> std::io::Result<()> {
359    let mut lock = unsafe {
360        // SAFETY: all-zero is a valid `flock` value before named fields are set.
361        std::mem::zeroed::<libc::flock>()
362    };
363    lock.l_type = libc::F_RDLCK as libc::c_short;
364    lock.l_whence = libc::SEEK_SET as libc::c_short;
365    lock.l_start = 0;
366    lock.l_len = 0;
367    let result = unsafe {
368        // SAFETY: `file` owns a live descriptor and `lock` remains valid for
369        // the duration of this non-blocking `F_SETLK` call.
370        libc::fcntl(file.as_raw_fd(), libc::F_SETLK, &lock)
371    };
372    if result == -1 {
373        let error = std::io::Error::last_os_error();
374        if error
375            .raw_os_error()
376            .is_some_and(|errno| errno == libc::EAGAIN || errno == libc::EACCES)
377        {
378            Err(std::io::Error::from(std::io::ErrorKind::WouldBlock))
379        } else {
380            Err(error)
381        }
382    } else {
383        Ok(())
384    }
385}
386
387/// Takes the cross-process lock with `LockFileEx`, non-blocking and shared,
388/// over the same fixed range the exclusive arm uses.
389///
390/// Contention with a live writer arrives as [`std::io::ErrorKind::WouldBlock`];
391/// a permission failure stays a permission failure and is never reported as
392/// contention.
393#[cfg(windows)]
394#[allow(dead_code)] // Called by the ZE-40 producer seam above.
395fn lock_shared(file: &File) -> std::io::Result<()> {
396    crate::sys::windows::lock_file_shared(file)
397}
398
399#[cfg(not(any(unix, windows)))]
400#[allow(dead_code)] // Called by the ZE-40 producer seam above.
401fn lock_shared(_: &File) -> std::io::Result<()> {
402    Err(std::io::Error::new(
403        std::io::ErrorKind::Unsupported,
404        "shared store locks require a supported file-locking platform",
405    ))
406}
407
408/// Takes the cross-process lock with `LockFileEx`, non-blocking and exclusive,
409/// over the fixed range documented by `sys::windows::WRITER_LOCK_RANGE`.
410///
411/// Contention arrives as [`std::io::ErrorKind::WouldBlock`]; a permission
412/// failure stays a permission failure and is never reported as contention.
413#[cfg(windows)]
414fn lock_exclusive(file: &File) -> std::io::Result<()> {
415    crate::sys::windows::lock_file_exclusive(file)
416}
417
418#[cfg(not(any(unix, windows)))]
419fn lock_exclusive(_: &File) -> std::io::Result<()> {
420    Err(std::io::Error::new(
421        std::io::ErrorKind::Unsupported,
422        "store locks require a supported file-locking platform",
423    ))
424}
425
426impl Drop for StoreLock {
427    fn drop(&mut self) {
428        let mut held = held_stores();
429        let remove = match held.get_mut(&self.registry_key) {
430            Some(entry) if entry.mode == LockMode::Shared && self.mode == LockMode::Shared => {
431                if entry.shared_count > 1 {
432                    entry.shared_count -= 1;
433                    false
434                } else {
435                    true
436                }
437            }
438            Some(entry)
439                if entry.mode == LockMode::Exclusive && self.mode == LockMode::Exclusive =>
440            {
441                true
442            }
443            _ => false,
444        };
445        if remove {
446            // POSIX closes of any descriptor for the file release this
447            // process's record locks. Remove and close the registry-owned sole
448            // descriptor while local admission remains excluded.
449            drop(held.remove(&self.registry_key));
450        }
451    }
452}
453
454/// Failure to open or acquire the single-writer lock.
455#[derive(Debug)]
456pub enum StoreLockError {
457    /// A named filesystem or lock operation failed.
458    Io {
459        /// Lock-file path.
460        path: PathBuf,
461        /// Underlying platform error.
462        source: std::io::Error,
463    },
464}
465
466impl std::fmt::Display for StoreLockError {
467    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
468        match self {
469            Self::Io { path, source } => {
470                write!(formatter, "store lock {}: {source}", path.display())
471            }
472        }
473    }
474}
475
476impl std::error::Error for StoreLockError {
477    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
478        match self {
479            Self::Io { source, .. } => Some(source),
480        }
481    }
482}