Skip to main content

genegraph_storage/
commit.rs

1//! Commit serialization for metadata read-modify-write cycles and dataset
2//! writes.
3//!
4//! Base concurrency-safety model borrowed from duva's actor design
5//! ([`<https://github.com/Migorithm/duva>`]): every storage instance has a single
6//! logical *commit actor*. All metadata mutations (the `load_metadata →
7//! mutate → save_metadata` cycle performed by every `save_*` call) are
8//! serialized through it, so concurrent writers cannot interleave their
9//! cycles and lose each other's registry entries (lost update) — the same
10//! reason duva routes writes through one actor mailbox instead of shared
11//! mutable state.
12//!
13//! The same mailbox shape extends to Lance dataset writes
14//! ([`with_dataset_write_lock`]): manifest-version allocation and the
15//! commit-point publish of one dataset directory are serialized, so two
16//! concurrent overwrites cannot mint the same `N.manifest` (#95).
17//!
18//! Both registries hold **weak** references (#98): a mailbox stays alive
19//! only while some caller holds its `Arc` (i.e. while a commit cycle or
20//! dataset write is in flight), and dead entries are swept on insert.
21//! Instances that churn (create/drop thousands of collections) keep the
22//! registries bounded.
23//!
24//! Durability of the commit itself is the tmp + fsync + rename discipline
25//! ([`crate::generations::write_json_atomic`] for metadata, the same
26//! sequence inside the lancefmt writer for data/txn/manifest files):
27//! readers never observe a half-written commit pointer, and a read after a
28//! completed commit observes its effects (read-your-own-writes).
29//!
30//! # Downstream contract (#100)
31//!
32//! The commit actor is an **in-process** mailbox: it serializes concurrent
33//! tasks inside one process, never two independent processes. Two levels of
34//! arbitration are exposed:
35//!
36//! 1. **In-process** — [`with_commit_actor`] wraps a full metadata
37//!    read-modify-write cycle (load → mutate → publish). Every cycle over a
38//!    given metadata file runs under the same per-path mailbox, so cycles
39//!    from your code and cycles from the `save_*` registry paths cannot
40//!    interleave and lose updates:
41//!
42//!    ```no_run
43//!    use std::path::Path;
44//!    use genegraph_storage::commit::with_commit_actor;
45//!
46//!    futures::executor::block_on(async {
47//!        let metadata_path = Path::new("base/ds__g1_metadata.json");
48//!        let cycle: genegraph_storage::StorageResult<()> = with_commit_actor(metadata_path, || async {
49//!            // load → mutate → publish (via generations::write_json_atomic)
50//!            Ok(())
51//!        })
52//!        .await;
53//!        let _ = cycle;
54//!    });
55//!    ```
56//!
57//! 2. **Cross-process** — an advisory `flock` on a lock file, held for the
58//!    documented hold scope (the whole read-modify-write cycle: lock →
59//!    load → mutate → publish → release). The blessed convention is one
60//!    lock file next to the metadata file, named by
61//!    [`lock_file_for_metadata`] (`{metadata-stem}.lock`). Two forms
62//!    exist:
63//!
64//!    - [`with_metadata_file_lock`] — the **composed** recipe: the file
65//!      lock is held across the whole awaited commit-actor cycle, so
66//!      cross-process exclusion *and* in-process actor serialization are
67//!      both active for the duration of the cycle. This is what
68//!      multi-process consumers whose metadata file is also touched by
69//!      async `save_*` paths must use:
70//!
71//!      ```no_run
72//!      use std::path::Path;
73//!      use genegraph_storage::commit::with_metadata_file_lock;
74//!
75//!      futures::executor::block_on(async {
76//!          let metadata_path = Path::new("base/ds__g1_metadata.json");
77//!          let cycle = with_metadata_file_lock(metadata_path, || async {
78//!              // load → mutate → publish (via generations::write_json_atomic)
79//!              Ok(())
80//!          })
81//!          .await;
82//!          let _ = cycle;
83//!      });
84//!      ```
85//!
86//!    - [`with_file_lock`] — the raw lock for consumers whose whole cycle
87//!      is **synchronous**. Its closure runs on the blocking pool and
88//!      cannot await the commit actor; it therefore does *not* serialize
89//!      against in-process async `save_*` cycles. If that matters, use
90//!      [`with_metadata_file_lock`].
91//!
92//!    - **Fail-fast variants (#105)** — [`try_with_file_lock`] and
93//!      [`try_with_metadata_file_lock`] take the same lock files with the
94//!      same hold scopes, but acquisition is non-blocking
95//!      (`flock(LOCK_EX | LOCK_NB)`): on contention the caller gets
96//!      [`StorageError::LockWouldBlock`] naming the lock file immediately
97//!      instead of parking on the blocking pool. Consumers whose contract
98//!      is fail-fast on contention (a second concurrent append must exit
99//!      non-zero, never wait) map that variant onto their own taxonomy.
100//!
101//! The lock file is a rendezvous point for cooperating writers, not a
102//! commit artifact: it carries no data and is left in place after release.
103//! Arbitration is only as strong as the convention — every writer of the
104//! same metadata file must take the same lock file before mutating it,
105//! and advisory locking excludes only those cooperating writers, never
106//! arbitrary readers or unaware processes.
107//!
108//! The blocking lock forms wait on the blocking pool; see the operational
109//! caution on [`with_metadata_file_lock`] for the assumptions this puts on
110//! commit cycles and what to prefer when waits could be prolonged or
111//! numerous. Where that trade is unacceptable, the try forms above fail
112//! fast instead of waiting.
113
114use std::collections::HashMap;
115use std::path::{Path, PathBuf};
116use std::sync::{Arc, Mutex as StdMutex, OnceLock, Weak};
117
118use tokio::sync::Mutex;
119
120use crate::{StorageError, StorageResult};
121
122/// Commit-actor mailboxes, keyed by metadata path (weak-valued, #98).
123static COMMIT_LOCKS: OnceLock<StdMutex<HashMap<String, Weak<Mutex<()>>>>> = OnceLock::new();
124/// Dataset-write mailboxes, keyed by dataset dir (weak-valued, #98).
125static DATASET_LOCKS: OnceLock<StdMutex<HashMap<String, Weak<StdMutex<()>>>>> = OnceLock::new();
126
127/// Shared weak-registry lookup (#98): reuse the live mailbox for `key` if
128/// one exists, otherwise sweep dead entries and insert `fresh`.
129pub(crate) fn weak_lookup<T>(
130    registry: &'static OnceLock<StdMutex<HashMap<String, Weak<T>>>>,
131    key: String,
132    fresh: Arc<T>,
133) -> Arc<T> {
134    let mut map = registry
135        .get_or_init(|| StdMutex::new(HashMap::new()))
136        .lock()
137        .unwrap_or_else(|poisoned| poisoned.into_inner());
138    if let Some(weak) = map.get(&key)
139        && let Some(strong) = weak.upgrade()
140    {
141        return strong;
142    }
143    map.retain(|_, weak| weak.strong_count() > 0);
144    let arc = Arc::clone(&fresh);
145    map.insert(key, Arc::downgrade(&fresh));
146    arc
147}
148
149/// One commit-actor mailbox per metadata path.
150fn lock_for(metadata_path: &Path) -> Arc<Mutex<()>> {
151    let key = metadata_path.to_string_lossy().to_string();
152    weak_lookup(&COMMIT_LOCKS, key, Arc::new(Mutex::new(())))
153}
154
155/// One dataset-write mailbox per dataset directory.
156fn dataset_lock_for(dataset_dir: &Path) -> Arc<StdMutex<()>> {
157    let key = dataset_dir.to_string_lossy().to_string();
158    weak_lookup(&DATASET_LOCKS, key, Arc::new(StdMutex::new(())))
159}
160
161/// Runs `commit` (a full metadata read-modify-write cycle) under the
162/// metadata path's commit actor: at most one cycle runs at a time for the
163/// same path **within this process**.
164///
165/// Public for downstream consumers (#100): any code that performs its own
166/// load → mutate → publish cycle over a metadata file outside the `save_*`
167/// registry paths routes the whole cycle through this function with the
168/// same `metadata_path` the storage instance uses, so cycles from both
169/// sides are serialized against each other. Cross-process arbitration is a
170/// separate concern — wrap the cycle in [`with_file_lock`] (see the module
171/// docs for the recipe).
172pub async fn with_commit_actor<T, F, Fut>(
173    metadata_path: &Path,
174    commit: F,
175) -> StorageResult<T>
176where
177    F: FnOnce() -> Fut,
178    Fut: std::future::Future<Output = StorageResult<T>>,
179{
180    let mailbox = lock_for(metadata_path);
181    let _guard = mailbox.lock().await;
182    commit().await
183}
184
185/// Cross-process commit arbitration (#100): an advisory `flock` on
186/// `lock_path`, held for the closure's scope — lock → read-modify-write →
187/// publish → release. Independent processes (e.g. separate CLI
188/// invocations) that take the same lock file cannot interleave their
189/// metadata cycles.
190///
191/// The blessed lock-file location for a metadata file is
192/// [`lock_file_for_metadata`] (`{metadata-stem}.lock` next to the file);
193/// the lock file is created on demand (missing parent directories
194/// included) and left in place after release — it is a rendezvous point,
195/// not a commit artifact.
196///
197/// The closure is synchronous and runs on the blocking pool: the flock can
198/// block arbitrarily long on a competing holder, so it must never run on
199/// an async executor thread. Because the closure is sync, it **cannot**
200/// await [`with_commit_actor`] — a cycle run here is serialized across
201/// processes but not against in-process async `save_*` cycles; use
202/// [`with_metadata_file_lock`] when both are required. Off unix this fails
203/// with [`StorageError::UnsupportedFormat`] rather than silently skipping
204/// arbitration.
205///
206/// The blocking-pool caveats documented on [`with_metadata_file_lock`]
207/// apply here identically: unbounded waits, non-abortable waiters,
208/// blocking-thread capacity.
209pub async fn with_file_lock<T, F>(lock_path: &Path, f: F) -> StorageResult<T>
210where
211    T: Send + 'static,
212    F: FnOnce() -> StorageResult<T> + Send + 'static,
213{
214    let lock_path = lock_path.to_path_buf();
215    tokio::task::spawn_blocking(move || {
216        let _lock = FileLock::acquire(&lock_path)?;
217        f()
218    })
219    .await
220    .map_err(|e| StorageError::Io(format!("file lock task failed: {e}")))?
221}
222
223/// Fail-fast counterpart of [`with_file_lock`] (#105): the same advisory
224/// `flock` on `lock_path` and the same hold scope — lock → read-modify-write
225/// → publish → release — but acquisition is non-blocking
226/// (`flock(LOCK_EX | LOCK_NB)`). On contention the call returns
227/// immediately with [`StorageError::LockWouldBlock`] naming the lock file
228/// instead of parking the waiter on the blocking pool, so consumers whose
229/// contract is fail-fast on contention (e.g. a multi-process CLI append
230/// that must exit non-zero on a concurrent append) can adopt the blessed
231/// convention without waiting.
232///
233/// Contention is distinctable: match on `StorageError::LockWouldBlock {
234/// path }` and map it into your own taxonomy (IO errors, task-join
235/// failures and the closure's own errors keep their original shapes).
236/// Everything [`with_file_lock`] documents holds here too: the lock file
237/// is created on demand (missing parents included) and left in place — a
238/// rendezvous point, not a commit artifact; the closure runs on the
239/// blocking pool and cannot await [`with_commit_actor`]; off unix this
240/// fails with [`StorageError::UnsupportedFormat`]. Advisory means only
241/// cooperating writers that resolve the same lock file are excluded.
242pub async fn try_with_file_lock<T, F>(lock_path: &Path, f: F) -> StorageResult<T>
243where
244    T: Send + 'static,
245    F: FnOnce() -> StorageResult<T> + Send + 'static,
246{
247    let lock_path = lock_path.to_path_buf();
248    tokio::task::spawn_blocking(move || {
249        let _lock = FileLock::try_acquire(&lock_path)?;
250        f()
251    })
252    .await
253    .map_err(|e| StorageError::Io(format!("file lock task failed: {e}")))?
254}
255
256/// The blessed lock-file path for a metadata file (#100):
257/// `{metadata-stem}.lock` next to it — `ds__g1_metadata.json` locks through
258/// `ds__g1_metadata.lock`. All cooperating writers of the same metadata
259/// file must resolve the lock through this function so the convention
260/// holds.
261pub fn lock_file_for_metadata(metadata_path: &Path) -> PathBuf {
262    match metadata_path.file_stem() {
263        Some(stem) => metadata_path.with_file_name(format!("{}.lock", stem.to_string_lossy())),
264        None => metadata_path.with_file_name(format!(
265            "{}.lock",
266            metadata_path.as_os_str().to_string_lossy()
267        )),
268    }
269}
270
271/// RAII advisory lock (unix `flock(2)`, exclusive). The lock is held by the
272/// open file description, so two `acquire`/`try_acquire` calls — in this
273/// process or another — exclude each other until the guard drops (explicit
274/// `LOCK_UN`, and again on close).
275#[cfg(unix)]
276struct FileLock(std::fs::File);
277
278#[cfg(unix)]
279impl FileLock {
280    fn acquire(lock_path: &Path) -> StorageResult<Self> {
281        use std::os::unix::io::AsRawFd;
282
283        let file = open_lock_file(lock_path)?;
284        // SAFETY: fd is valid; flock(2) has no preconditions beyond it.
285        let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) };
286        if rc != 0 {
287            return Err(StorageError::Io(format!(
288                "flock {lock_path:?}: {}",
289                std::io::Error::last_os_error()
290            )));
291        }
292        Ok(FileLock(file))
293    }
294
295    /// Non-blocking variant (#105): `flock(LOCK_EX | LOCK_NB)`. On
296    /// contention (EWOULDBLOCK) the error is
297    /// [`StorageError::LockWouldBlock`] naming `lock_path`, so fail-fast
298    /// consumers can map it into their own taxonomy; the lock file is
299    /// still created if missing (the rendezvous point must exist for the
300    /// next taker).
301    fn try_acquire(lock_path: &Path) -> StorageResult<Self> {
302        use std::os::unix::io::AsRawFd;
303
304        let file = open_lock_file(lock_path)?;
305        // SAFETY: fd is valid; flock(2) has no preconditions beyond it.
306        let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
307        if rc != 0 {
308            let err = std::io::Error::last_os_error();
309            if err.kind() == std::io::ErrorKind::WouldBlock {
310                return Err(StorageError::LockWouldBlock {
311                    path: lock_path.to_path_buf(),
312                });
313            }
314            return Err(StorageError::Io(format!("flock {lock_path:?}: {err}")));
315        }
316        Ok(FileLock(file))
317    }
318}
319
320/// Opens (creating if missing) the lock file for `acquire`/`try_acquire`:
321/// missing parent directories are created, the file is opened
322/// write-only without truncation (it carries no data).
323#[cfg(unix)]
324fn open_lock_file(lock_path: &Path) -> StorageResult<std::fs::File> {
325    if let Some(parent) = lock_path.parent()
326        && !parent.as_os_str().is_empty()
327    {
328        std::fs::create_dir_all(parent)
329            .map_err(|e| StorageError::Io(format!("create lock parent {parent:?}: {e}")))?;
330    }
331    std::fs::OpenOptions::new()
332        .create(true)
333        .truncate(false)
334        .write(true)
335        .open(lock_path)
336        .map_err(|e| StorageError::Io(format!("open lock {lock_path:?}: {e}")))
337}
338
339#[cfg(unix)]
340impl Drop for FileLock {
341    fn drop(&mut self) {
342        use std::os::unix::io::AsRawFd;
343
344        // SAFETY: fd is still owned by self; the lock is released here and
345        // again (idempotently) when the file closes.
346        unsafe { libc::flock(self.0.as_raw_fd(), libc::LOCK_UN) };
347    }
348}
349
350/// Off unix there is no blessed arbitration primitive; fail typed instead
351/// of silently skipping cross-process serialization (#100).
352#[cfg(not(unix))]
353struct FileLock;
354
355#[cfg(not(unix))]
356impl FileLock {
357    fn acquire(_lock_path: &Path) -> StorageResult<Self> {
358        Err(StorageError::UnsupportedFormat(
359            "cross-process file locking (with_file_lock) requires a POSIX platform".into(),
360        ))
361    }
362
363    fn try_acquire(_lock_path: &Path) -> StorageResult<Self> {
364        Err(StorageError::UnsupportedFormat(
365            "cross-process file locking (try_with_file_lock) requires a POSIX platform".into(),
366        ))
367    }
368}
369
370/// The blessed recipe for a multi-process consumer's metadata
371/// read-modify-write cycle (#100, review composition fix): the advisory
372/// file lock ([`lock_file_for_metadata`]) is held across the **whole**
373/// awaited commit-actor cycle — cross-process exclusion and in-process
374/// actor serialization are both active for the duration of `cycle`.
375///
376/// The closure is async: a full load → mutate → publish cycle (including
377/// any `save_*`-style actor work) runs inside, while independent processes
378/// taking the same lock file serialize behind it. Lock acquisition runs on
379/// the blocking pool (the flock can park on a competing holder); the lock
380/// is released when the cycle's future completes.
381///
382/// For consumers whose RMW is entirely synchronous, [`with_file_lock`]
383/// wraps the same lock file — but a sync closure cannot await the commit
384/// actor; only this helper composes the two locks.
385///
386/// # Operational caution (blocking-pool waits)
387///
388/// The flock wait is unbounded and runs through `spawn_blocking`, which
389/// Tokio documents for blocking work that is bounded and eventually
390/// completes: a waiter that has already parked cannot be reliably
391/// aborted, and many long-lived blocked waiters can exhaust the runtime's
392/// blocking-thread capacity. This design is reasonable for metadata
393/// commits when:
394///
395/// - commit cycles are short — the hold scope is a JSON load → mutate →
396///   publish, not compute;
397/// - contention is normally brief;
398/// - callers do not hold the lock across lengthy compute, network I/O,
399///   user interaction, or indefinite waits;
400/// - shutdown behavior with a stuck lock holder is understood: blocked
401///   `spawn_blocking` tasks are not aborted by task cancellation;
402///   [`tokio::runtime::Runtime::shutdown_timeout`] abandons them after a
403///   grace period while [`tokio::runtime::Runtime::shutdown_background`]
404///   waits them out — and process exit always closes the descriptor,
405///   releasing the flock.
406///
407/// If lock waits could be prolonged or numerous, prefer a dedicated
408/// lock-management thread, an explicit timeout/cancellation strategy
409/// (bounded wait before acquisition), or a storage system with
410/// transactional coordination over unbounded `flock` waits here.
411pub async fn with_metadata_file_lock<T, F, Fut>(
412    metadata_path: &Path,
413    cycle: F,
414) -> StorageResult<T>
415where
416    F: FnOnce() -> Fut,
417    Fut: std::future::Future<Output = StorageResult<T>>,
418{
419    let lock_path = lock_file_for_metadata(metadata_path);
420    let lock_path2 = lock_path.clone();
421    let _lock = tokio::task::spawn_blocking(move || FileLock::acquire(&lock_path2))
422        .await
423        .map_err(|e| StorageError::Io(format!("file lock task failed: {e}")))??;
424    with_commit_actor(metadata_path, cycle).await
425}
426
427/// Fail-fast counterpart of [`with_metadata_file_lock`] (#105): the lock
428/// file is resolved through [`lock_file_for_metadata`] and acquired with
429/// [`FileLock::try_acquire`] — on cross-process contention the call
430/// returns [`StorageError::LockWouldBlock`] naming the derived lock file
431/// without parking; uncontended, the file lock is held across the whole
432/// awaited commit-actor cycle exactly as in [`with_metadata_file_lock`]
433/// (cross-process exclusion *and* in-process actor serialization).
434///
435/// The try applies to the **flock only**: once acquired, the awaited
436/// commit-actor cycle still serializes against in-process cycles as
437/// usual. Advisory, cooperating-writers-only, rendezvous-point semantics
438/// are identical to the blocking form; off unix this fails with
439/// [`StorageError::UnsupportedFormat`].
440pub async fn try_with_metadata_file_lock<T, F, Fut>(
441    metadata_path: &Path,
442    cycle: F,
443) -> StorageResult<T>
444where
445    F: FnOnce() -> Fut,
446    Fut: std::future::Future<Output = StorageResult<T>>,
447{
448    let lock_path = lock_file_for_metadata(metadata_path);
449    let lock_path2 = lock_path.clone();
450    let _lock = tokio::task::spawn_blocking(move || FileLock::try_acquire(&lock_path2))
451        .await
452        .map_err(|e| StorageError::Io(format!("file lock task failed: {e}")))??;
453    with_commit_actor(metadata_path, cycle).await
454}
455
456/// Runs `write` under the dataset's write mailbox. `write` must be a
457/// non-async closure: the whole dataset write — version allocation through
458/// commit-point publish — happens under the lock.
459pub(crate) fn with_dataset_write_lock<T>(
460    dataset_dir: &Path,
461    write: impl FnOnce() -> StorageResult<T>,
462) -> StorageResult<T> {
463    let mailbox = dataset_lock_for(dataset_dir);
464    let _guard = mailbox
465        .lock()
466        .map_err(|_| StorageError::InvalidState("dataset write lock poisoned".into()))?;
467    write()
468}
469
470/// Test hook: (commit-registry size, dataset-registry size).
471#[cfg(test)]
472pub(crate) fn registry_sizes() -> (usize, usize) {
473    let commit = COMMIT_LOCKS
474        .get()
475        .map(|m| m.lock().unwrap().len())
476        .unwrap_or(0);
477    let dataset = DATASET_LOCKS
478        .get()
479        .map(|m| m.lock().unwrap().len())
480        .unwrap_or(0);
481    (commit, dataset)
482}