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    pub(crate) fn acquire_shared(directory: &Path) -> Result<Self, StoreLockError> {
131        Self::shared_named(directory, false, false)
132    }
133
134    pub(crate) fn reader_lease(directory: &Path) -> Result<Self, StoreLockError> {
135        Self::shared_named(directory, true, true)
136    }
137
138    pub(crate) fn reader_lease_read_only(directory: &Path) -> Result<Option<Self>, StoreLockError> {
139        match Self::shared_named(directory, true, false) {
140            Ok(lock) => Ok(Some(lock)),
141            Err(StoreLockError::Io { source, .. })
142                if source.kind() == std::io::ErrorKind::NotFound =>
143            {
144                Ok(None)
145            }
146            Err(error) => Err(error),
147        }
148    }
149
150    fn shared_named(directory: &Path, lease: bool, create: bool) -> Result<Self, StoreLockError> {
151        let path = directory.join(if lease {
152            ".ze-readers.lock"
153        } else {
154            STORE_LOCK_FILE
155        });
156        let identity = store_identity(directory).map_err(|source| StoreLockError::Io {
157            path: directory.to_path_buf(),
158            source,
159        })?;
160        let registry_key = (identity.0, identity.1, lease);
161        let mut held = held_stores();
162        if let Some(entry) = held.get_mut(&registry_key) {
163            if entry.mode == LockMode::Exclusive {
164                return Err(StoreLockError::Io {
165                    path,
166                    source: std::io::Error::from(std::io::ErrorKind::WouldBlock),
167                });
168            }
169            let Some(next_count) = entry.shared_count.checked_add(1) else {
170                return Err(StoreLockError::Io {
171                    path,
172                    source: std::io::Error::other("shared store lock holder count overflow"),
173                });
174            };
175            entry.shared_count = next_count;
176            return Ok(Self {
177                registry_key,
178                mode: LockMode::Shared,
179            });
180        }
181
182        let file = (if create {
183            open_lock_file(&path, true)
184        } else {
185            open_shared_lock_file(&path)
186        })
187        .map_err(|source| StoreLockError::Io {
188            path: path.clone(),
189            source,
190        })?;
191        if let Err(source) = lock_shared(&file) {
192            drop(file);
193            return Err(StoreLockError::Io { path, source });
194        }
195        held.insert(
196            registry_key,
197            RegistryEntry {
198                _file: file,
199                mode: LockMode::Shared,
200                shared_count: 1,
201            },
202        );
203        Ok(Self {
204            registry_key,
205            mode: LockMode::Shared,
206        })
207    }
208}
209
210fn held_stores() -> MutexGuard<'static, BTreeMap<StoreKey, RegistryEntry>> {
211    match HELD_STORES.lock() {
212        Ok(held) => held,
213        Err(poisoned) => poisoned.into_inner(),
214    }
215}
216
217/// Opens an already-existing lock file without write or creation authority.
218fn open_shared_lock_file(path: &Path) -> std::io::Result<File> {
219    let file = open_shared_lock_handle(path)?;
220    if file.metadata()?.is_file() {
221        Ok(file)
222    } else {
223        Err(std::io::Error::new(
224            std::io::ErrorKind::IsADirectory,
225            "store lock path is not a regular file",
226        ))
227    }
228}
229
230/// Read-only handle on the existing lock file; never creates and never writes.
231#[cfg(not(windows))]
232fn open_shared_lock_handle(path: &Path) -> std::io::Result<File> {
233    let mut options = OpenOptions::new();
234    options.read(true);
235    #[cfg(unix)]
236    {
237        use std::os::unix::fs::OpenOptionsExt as _;
238        options.custom_flags(libc::O_NOFOLLOW);
239    }
240    options.open(path)
241}
242
243/// Read-only handle that additionally refuses to share deletion.
244///
245/// This is the same reasoning `open_lock_file` already documents, applied to
246/// readers. Rust's default share mode includes `FILE_SHARE_DELETE`, and a
247/// reader-held lock file that anyone may rename or unlink is no protection at
248/// all: a writer would then create a fresh `writer.lock`, take an uncontended
249/// exclusive range on it, and run beside live readers. Sharing read and write
250/// but not deletion keeps the file in place for as long as any reader holds
251/// it, while still admitting the further readers and the eventual writer that
252/// the lock range itself arbitrates. No write access is requested, because
253/// `LockFileEx` needs only `GENERIC_READ`.
254#[cfg(windows)]
255fn open_shared_lock_handle(path: &Path) -> std::io::Result<File> {
256    // The existing share mode prevents deletion while the handle is held.
257    use std::os::windows::fs::OpenOptionsExt as _;
258
259    OpenOptions::new()
260        .read(true)
261        .share_mode(crate::sys::windows::FILE_SHARE_READ | crate::sys::windows::FILE_SHARE_WRITE)
262        .open(path)
263}
264
265/// The store directory's stable filesystem identity.
266#[cfg(unix)]
267pub(super) fn store_identity(directory: &Path) -> std::io::Result<(u64, u64)> {
268    let metadata = std::fs::metadata(directory)?;
269    Ok((metadata.dev(), metadata.ino()))
270}
271
272#[cfg(windows)]
273pub(super) fn store_identity(directory: &Path) -> std::io::Result<(u64, u64)> {
274    crate::sys::windows::directory_identity(directory)
275}
276
277#[cfg(not(any(unix, windows)))]
278pub(super) fn store_identity(_directory: &Path) -> std::io::Result<(u64, u64)> {
279    Err(std::io::Error::new(
280        std::io::ErrorKind::Unsupported,
281        "store identity requires a supported platform",
282    ))
283}
284
285/// Opens the persistent lock file with the access the platform lock needs.
286#[cfg(not(windows))]
287fn open_lock_file(path: &Path, create: bool) -> std::io::Result<File> {
288    let mut options = OpenOptions::new();
289    options
290        .create(create)
291        .truncate(false)
292        .read(true)
293        .write(true);
294    #[cfg(unix)]
295    {
296        use std::os::unix::fs::OpenOptionsExt as _;
297        options.custom_flags(libc::O_NOFOLLOW);
298    }
299    options.open(path)
300}
301
302/// Opens the persistent lock file, additionally refusing to share deletion.
303///
304/// Rust opens files with `FILE_SHARE_READ | FILE_SHARE_WRITE |
305/// FILE_SHARE_DELETE` by default. Sharing deletion would let any process
306/// unlink or rename `writer.lock` out from under a live writer, after which a
307/// second writer would create a fresh lock file and be admitted. Narrowing the
308/// share mode makes the held lock file undeletable and unrenameable for as long
309/// as ownership lasts. Handles are non-inheritable by default, so a child
310/// process cannot inherit writer ownership either.
311#[cfg(windows)]
312fn open_lock_file(path: &Path, create: bool) -> std::io::Result<File> {
313    // The existing share mode prevents deletion while the handle is held.
314    use std::os::windows::fs::OpenOptionsExt as _;
315
316    OpenOptions::new()
317        .create(create)
318        .truncate(false)
319        .read(true)
320        .write(true)
321        .share_mode(crate::sys::windows::FILE_SHARE_READ | crate::sys::windows::FILE_SHARE_WRITE)
322        .open(path)
323}
324
325#[cfg(unix)]
326fn lock_exclusive(file: &File) -> std::io::Result<()> {
327    let mut lock = unsafe {
328        // SAFETY: all-zero is a valid `flock` value before named fields are set.
329        std::mem::zeroed::<libc::flock>()
330    };
331    lock.l_type = libc::F_WRLCK as libc::c_short;
332    lock.l_whence = libc::SEEK_SET as libc::c_short;
333    lock.l_start = 0;
334    lock.l_len = 0;
335    let result = unsafe {
336        // SAFETY: `file` owns a live descriptor and `lock` remains valid for
337        // the duration of this non-blocking `F_SETLK` call.
338        libc::fcntl(file.as_raw_fd(), libc::F_SETLK, &lock)
339    };
340    if result == -1 {
341        let error = std::io::Error::last_os_error();
342        if error
343            .raw_os_error()
344            .is_some_and(|errno| errno == libc::EAGAIN || errno == libc::EACCES)
345        {
346            Err(std::io::Error::from(std::io::ErrorKind::WouldBlock))
347        } else {
348            Err(error)
349        }
350    } else {
351        Ok(())
352    }
353}
354
355#[cfg(unix)]
356fn lock_shared(file: &File) -> std::io::Result<()> {
357    let mut lock = unsafe {
358        // SAFETY: all-zero is a valid `flock` value before named fields are set.
359        std::mem::zeroed::<libc::flock>()
360    };
361    lock.l_type = libc::F_RDLCK as libc::c_short;
362    lock.l_whence = libc::SEEK_SET as libc::c_short;
363    lock.l_start = 0;
364    lock.l_len = 0;
365    let result = unsafe {
366        // SAFETY: `file` owns a live descriptor and `lock` remains valid for
367        // the duration of this non-blocking `F_SETLK` call.
368        libc::fcntl(file.as_raw_fd(), libc::F_SETLK, &lock)
369    };
370    if result == -1 {
371        let error = std::io::Error::last_os_error();
372        if error
373            .raw_os_error()
374            .is_some_and(|errno| errno == libc::EAGAIN || errno == libc::EACCES)
375        {
376            Err(std::io::Error::from(std::io::ErrorKind::WouldBlock))
377        } else {
378            Err(error)
379        }
380    } else {
381        Ok(())
382    }
383}
384
385/// Takes the cross-process lock with `LockFileEx`, non-blocking and shared,
386/// over the same fixed range the exclusive arm uses.
387///
388/// Contention with a live writer arrives as [`std::io::ErrorKind::WouldBlock`];
389/// a permission failure stays a permission failure and is never reported as
390/// contention.
391#[cfg(windows)]
392fn lock_shared(file: &File) -> std::io::Result<()> {
393    crate::sys::windows::lock_file_shared(file)
394}
395
396#[cfg(not(any(unix, windows)))]
397fn lock_shared(_: &File) -> std::io::Result<()> {
398    Err(std::io::Error::new(
399        std::io::ErrorKind::Unsupported,
400        "shared store locks require a supported file-locking platform",
401    ))
402}
403
404/// Takes the cross-process lock with `LockFileEx`, non-blocking and exclusive,
405/// over the fixed range documented by `sys::windows::WRITER_LOCK_RANGE`.
406///
407/// Contention arrives as [`std::io::ErrorKind::WouldBlock`]; a permission
408/// failure stays a permission failure and is never reported as contention.
409#[cfg(windows)]
410fn lock_exclusive(file: &File) -> std::io::Result<()> {
411    crate::sys::windows::lock_file_exclusive(file)
412}
413
414#[cfg(not(any(unix, windows)))]
415fn lock_exclusive(_: &File) -> std::io::Result<()> {
416    Err(std::io::Error::new(
417        std::io::ErrorKind::Unsupported,
418        "store locks require a supported file-locking platform",
419    ))
420}
421
422impl Drop for StoreLock {
423    fn drop(&mut self) {
424        let mut held = held_stores();
425        let remove = match held.get_mut(&self.registry_key) {
426            Some(entry) if entry.mode == LockMode::Shared && self.mode == LockMode::Shared => {
427                if entry.shared_count > 1 {
428                    entry.shared_count -= 1;
429                    false
430                } else {
431                    true
432                }
433            }
434            Some(entry)
435                if entry.mode == LockMode::Exclusive && self.mode == LockMode::Exclusive =>
436            {
437                true
438            }
439            _ => false,
440        };
441        if remove {
442            // POSIX closes of any descriptor for the file release this
443            // process's record locks. Remove and close the registry-owned sole
444            // descriptor while local admission remains excluded.
445            drop(held.remove(&self.registry_key));
446        }
447    }
448}
449
450/// Failure to open or acquire the single-writer lock.
451#[derive(Debug)]
452pub enum StoreLockError {
453    /// A named filesystem or lock operation failed.
454    Io {
455        /// Lock-file path.
456        path: PathBuf,
457        /// Underlying platform error.
458        source: std::io::Error,
459    },
460}
461
462impl std::fmt::Display for StoreLockError {
463    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
464        match self {
465            Self::Io { path, source } => {
466                write!(formatter, "store lock {}: {source}", path.display())
467            }
468        }
469    }
470}
471
472impl std::error::Error for StoreLockError {
473    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
474        match self {
475            Self::Io { source, .. } => Some(source),
476        }
477    }
478}