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