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/// Mailbox key of a path: lexically absolute, so one directory keys to
150/// one mailbox regardless of path spelling (`x/./m`, `x//m`). Purely
151/// lexical ([`std::path::absolute`]): no filesystem access, so
152/// not-yet-created directories key correctly; `..` stays literal.
153pub(crate) fn lock_key(path: &Path) -> String {
154 std::path::absolute(path)
155 .unwrap_or_else(|_| path.to_path_buf())
156 .to_string_lossy()
157 .into_owned()
158}
159
160/// One commit-actor mailbox per metadata path.
161fn lock_for(metadata_path: &Path) -> Arc<Mutex<()>> {
162 let key = lock_key(metadata_path);
163 weak_lookup(&COMMIT_LOCKS, key, Arc::new(Mutex::new(())))
164}
165
166/// One dataset-write mailbox per dataset directory. The mailbox is
167/// in-process only — cross-process exclusion is the flock leg of
168/// [`try_with_dataset_file_lock`].
169fn dataset_lock_for(dataset_dir: &Path) -> Arc<StdMutex<()>> {
170 let key = lock_key(dataset_dir);
171 weak_lookup(&DATASET_LOCKS, key, Arc::new(StdMutex::new(())))
172}
173
174/// Runs `commit` (a full metadata read-modify-write cycle) under the
175/// metadata path's commit actor: at most one cycle runs at a time for the
176/// same path **within this process**.
177///
178/// Public for downstream consumers (#100): any code that performs its own
179/// load → mutate → publish cycle over a metadata file outside the `save_*`
180/// registry paths routes the whole cycle through this function with the
181/// same `metadata_path` the storage instance uses, so cycles from both
182/// sides are serialized against each other. Cross-process arbitration is a
183/// separate concern — wrap the cycle in [`with_file_lock`] (see the module
184/// docs for the recipe).
185pub async fn with_commit_actor<T, F, Fut>(metadata_path: &Path, commit: F) -> StorageResult<T>
186where
187 F: FnOnce() -> Fut,
188 Fut: std::future::Future<Output = StorageResult<T>>,
189{
190 let mailbox = lock_for(metadata_path);
191 let _guard = mailbox.lock().await;
192 commit().await
193}
194
195/// Cross-process commit arbitration (#100): an advisory `flock` on
196/// `lock_path`, held for the closure's scope — lock → read-modify-write →
197/// publish → release. Independent processes (e.g. separate CLI
198/// invocations) that take the same lock file cannot interleave their
199/// metadata cycles.
200///
201/// The blessed lock-file location for a metadata file is
202/// [`lock_file_for_metadata`] (`{metadata-stem}.lock` next to the file);
203/// the lock file is created on demand (missing parent directories
204/// included) and left in place after release — it is a rendezvous point,
205/// not a commit artifact.
206///
207/// The closure is synchronous and runs on the blocking pool: the flock can
208/// block arbitrarily long on a competing holder, so it must never run on
209/// an async executor thread. Because the closure is sync, it **cannot**
210/// await [`with_commit_actor`] — a cycle run here is serialized across
211/// processes but not against in-process async `save_*` cycles; use
212/// [`with_metadata_file_lock`] when both are required. Off unix this fails
213/// with [`StorageError::UnsupportedFormat`] rather than silently skipping
214/// arbitration.
215///
216/// The blocking-pool caveats documented on [`with_metadata_file_lock`]
217/// apply here identically: unbounded waits, non-abortable waiters,
218/// blocking-thread capacity.
219pub async fn with_file_lock<T, F>(lock_path: &Path, f: F) -> StorageResult<T>
220where
221 T: Send + 'static,
222 F: FnOnce() -> StorageResult<T> + Send + 'static,
223{
224 let lock_path = lock_path.to_path_buf();
225 tokio::task::spawn_blocking(move || {
226 let _lock = FileLock::acquire(&lock_path)?;
227 f()
228 })
229 .await
230 .map_err(|e| StorageError::Io(format!("file lock task failed: {e}")))?
231}
232
233/// Fail-fast counterpart of [`with_file_lock`] (#105): the same advisory
234/// `flock` on `lock_path` and the same hold scope — lock → read-modify-write
235/// → publish → release — but acquisition is non-blocking
236/// (`flock(LOCK_EX | LOCK_NB)`). On contention the call returns
237/// immediately with [`StorageError::LockWouldBlock`] naming the lock file
238/// instead of parking the waiter on the blocking pool, so consumers whose
239/// contract is fail-fast on contention (e.g. a multi-process CLI append
240/// that must exit non-zero on a concurrent append) can adopt the blessed
241/// convention without waiting.
242///
243/// Contention is distinctable: match on `StorageError::LockWouldBlock {
244/// path }` and map it into your own taxonomy (IO errors, task-join
245/// failures and the closure's own errors keep their original shapes).
246/// Everything [`with_file_lock`] documents holds here too: the lock file
247/// is created on demand (missing parents included) and left in place — a
248/// rendezvous point, not a commit artifact; the closure runs on the
249/// blocking pool and cannot await [`with_commit_actor`]; off unix this
250/// fails with [`StorageError::UnsupportedFormat`]. Advisory means only
251/// cooperating writers that resolve the same lock file are excluded.
252pub async fn try_with_file_lock<T, F>(lock_path: &Path, f: F) -> StorageResult<T>
253where
254 T: Send + 'static,
255 F: FnOnce() -> StorageResult<T> + Send + 'static,
256{
257 let lock_path = lock_path.to_path_buf();
258 tokio::task::spawn_blocking(move || {
259 let _lock = FileLock::try_acquire(&lock_path)?;
260 f()
261 })
262 .await
263 .map_err(|e| StorageError::Io(format!("file lock task failed: {e}")))?
264}
265
266/// The blessed lock-file path for a metadata file (#100):
267/// `{metadata-stem}.lock` next to it — `ds__g1_metadata.json` locks through
268/// `ds__g1_metadata.lock`. All cooperating writers of the same metadata
269/// file must resolve the lock through this function so the convention
270/// holds.
271pub fn lock_file_for_metadata(metadata_path: &Path) -> PathBuf {
272 match metadata_path.file_stem() {
273 Some(stem) => metadata_path.with_file_name(format!("{}.lock", stem.to_string_lossy())),
274 None => metadata_path.with_file_name(format!(
275 "{}.lock",
276 metadata_path.as_os_str().to_string_lossy()
277 )),
278 }
279}
280
281/// RAII advisory lock (unix `flock(2)`, exclusive). The lock is held by the
282/// open file description, so two `acquire`/`try_acquire` calls — in this
283/// process or another — exclude each other until the guard drops (explicit
284/// `LOCK_UN`, and again on close).
285#[cfg(unix)]
286struct FileLock(std::fs::File);
287
288#[cfg(unix)]
289impl FileLock {
290 fn acquire(lock_path: &Path) -> StorageResult<Self> {
291 use std::os::unix::io::AsRawFd;
292
293 let file = open_lock_file(lock_path)?;
294 // SAFETY: fd is valid; flock(2) has no preconditions beyond it.
295 let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) };
296 if rc != 0 {
297 return Err(StorageError::Io(format!(
298 "flock {lock_path:?}: {}",
299 std::io::Error::last_os_error()
300 )));
301 }
302 Ok(FileLock(file))
303 }
304
305 /// Non-blocking variant (#105): `flock(LOCK_EX | LOCK_NB)`. On
306 /// contention (EWOULDBLOCK) the error is
307 /// [`StorageError::LockWouldBlock`] naming `lock_path`, so fail-fast
308 /// consumers can map it into their own taxonomy; the lock file is
309 /// still created if missing (the rendezvous point must exist for the
310 /// next taker).
311 fn try_acquire(lock_path: &Path) -> StorageResult<Self> {
312 use std::os::unix::io::AsRawFd;
313
314 let file = open_lock_file(lock_path)?;
315 // SAFETY: fd is valid; flock(2) has no preconditions beyond it.
316 let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
317 if rc != 0 {
318 let err = std::io::Error::last_os_error();
319 if err.kind() == std::io::ErrorKind::WouldBlock {
320 return Err(StorageError::LockWouldBlock {
321 path: lock_path.to_path_buf(),
322 });
323 }
324 return Err(StorageError::Io(format!("flock {lock_path:?}: {err}")));
325 }
326 Ok(FileLock(file))
327 }
328}
329
330/// Opens (creating if missing) the lock file for `acquire`/`try_acquire`:
331/// missing parent directories are created, the file is opened
332/// write-only without truncation (it carries no data).
333#[cfg(unix)]
334fn open_lock_file(lock_path: &Path) -> StorageResult<std::fs::File> {
335 if let Some(parent) = lock_path.parent()
336 && !parent.as_os_str().is_empty()
337 {
338 std::fs::create_dir_all(parent)
339 .map_err(|e| StorageError::Io(format!("create lock parent {parent:?}: {e}")))?;
340 }
341 std::fs::OpenOptions::new()
342 .create(true)
343 .truncate(false)
344 .write(true)
345 .open(lock_path)
346 .map_err(|e| StorageError::Io(format!("open lock {lock_path:?}: {e}")))
347}
348
349#[cfg(unix)]
350impl Drop for FileLock {
351 fn drop(&mut self) {
352 use std::os::unix::io::AsRawFd;
353
354 // SAFETY: fd is still owned by self; the lock is released here and
355 // again (idempotently) when the file closes.
356 unsafe { libc::flock(self.0.as_raw_fd(), libc::LOCK_UN) };
357 }
358}
359
360/// Off unix there is no blessed arbitration primitive; fail typed instead
361/// of silently skipping cross-process serialization (#100).
362#[cfg(not(unix))]
363struct FileLock;
364
365#[cfg(not(unix))]
366impl FileLock {
367 fn acquire(_lock_path: &Path) -> StorageResult<Self> {
368 Err(StorageError::UnsupportedFormat(
369 "cross-process file locking (with_file_lock) requires a POSIX platform".into(),
370 ))
371 }
372
373 fn try_acquire(_lock_path: &Path) -> StorageResult<Self> {
374 Err(StorageError::UnsupportedFormat(
375 "cross-process file locking (try_with_file_lock) requires a POSIX platform".into(),
376 ))
377 }
378}
379
380/// The blessed recipe for a multi-process consumer's metadata
381/// read-modify-write cycle (#100, review composition fix): the advisory
382/// file lock ([`lock_file_for_metadata`]) is held across the **whole**
383/// awaited commit-actor cycle — cross-process exclusion and in-process
384/// actor serialization are both active for the duration of `cycle`.
385///
386/// The closure is async: a full load → mutate → publish cycle (including
387/// any `save_*`-style actor work) runs inside, while independent processes
388/// taking the same lock file serialize behind it. Lock acquisition runs on
389/// the blocking pool (the flock can park on a competing holder); the lock
390/// is released when the cycle's future completes.
391///
392/// For consumers whose RMW is entirely synchronous, [`with_file_lock`]
393/// wraps the same lock file — but a sync closure cannot await the commit
394/// actor; only this helper composes the two locks.
395///
396/// # Operational caution (blocking-pool waits)
397///
398/// The flock wait is unbounded and runs through `spawn_blocking`, which
399/// Tokio documents for blocking work that is bounded and eventually
400/// completes: a waiter that has already parked cannot be reliably
401/// aborted, and many long-lived blocked waiters can exhaust the runtime's
402/// blocking-thread capacity. This design is reasonable for metadata
403/// commits when:
404///
405/// - commit cycles are short — the hold scope is a JSON load → mutate →
406/// publish, not compute;
407/// - contention is normally brief;
408/// - callers do not hold the lock across lengthy compute, network I/O,
409/// user interaction, or indefinite waits;
410/// - shutdown behavior with a stuck lock holder is understood: blocked
411/// `spawn_blocking` tasks are not aborted by task cancellation;
412/// [`tokio::runtime::Runtime::shutdown_timeout`] abandons them after a
413/// grace period while [`tokio::runtime::Runtime::shutdown_background`]
414/// waits them out — and process exit always closes the descriptor,
415/// releasing the flock.
416///
417/// If lock waits could be prolonged or numerous, prefer a dedicated
418/// lock-management thread, an explicit timeout/cancellation strategy
419/// (bounded wait before acquisition), or a storage system with
420/// transactional coordination over unbounded `flock` waits here.
421pub async fn with_metadata_file_lock<T, F, Fut>(metadata_path: &Path, cycle: F) -> StorageResult<T>
422where
423 F: FnOnce() -> Fut,
424 Fut: std::future::Future<Output = StorageResult<T>>,
425{
426 let lock_path = lock_file_for_metadata(metadata_path);
427 let lock_path2 = lock_path.clone();
428 let _lock = tokio::task::spawn_blocking(move || FileLock::acquire(&lock_path2))
429 .await
430 .map_err(|e| StorageError::Io(format!("file lock task failed: {e}")))??;
431 with_commit_actor(metadata_path, cycle).await
432}
433
434/// Fail-fast counterpart of [`with_metadata_file_lock`] (#105): the lock
435/// file is resolved through [`lock_file_for_metadata`] and acquired with
436/// [`FileLock::try_acquire`] — on cross-process contention the call
437/// returns [`StorageError::LockWouldBlock`] naming the derived lock file
438/// without parking; uncontended, the file lock is held across the whole
439/// awaited commit-actor cycle exactly as in [`with_metadata_file_lock`]
440/// (cross-process exclusion *and* in-process actor serialization).
441///
442/// The try applies to the **flock only**: once acquired, the awaited
443/// commit-actor cycle still serializes against in-process cycles as
444/// usual. Advisory, cooperating-writers-only, rendezvous-point semantics
445/// are identical to the blocking form; off unix this fails with
446/// [`StorageError::UnsupportedFormat`].
447pub async fn try_with_metadata_file_lock<T, F, Fut>(
448 metadata_path: &Path,
449 cycle: F,
450) -> StorageResult<T>
451where
452 F: FnOnce() -> Fut,
453 Fut: std::future::Future<Output = StorageResult<T>>,
454{
455 let lock_path = lock_file_for_metadata(metadata_path);
456 let lock_path2 = lock_path.clone();
457 let _lock = tokio::task::spawn_blocking(move || FileLock::try_acquire(&lock_path2))
458 .await
459 .map_err(|e| StorageError::Io(format!("file lock task failed: {e}")))??;
460 with_commit_actor(metadata_path, cycle).await
461}
462
463/// Runs `write` under the dataset's write mailbox. `write` must be a
464/// non-async closure: the whole dataset write — version allocation through
465/// commit-point publish — happens under the lock. In-process only; use
466/// [`try_with_dataset_file_lock`] when cross-process exclusion is required.
467pub(crate) fn with_dataset_write_lock<T>(
468 dataset_dir: &Path,
469 write: impl FnOnce() -> StorageResult<T>,
470) -> StorageResult<T> {
471 let mailbox = dataset_lock_for(dataset_dir);
472 let _guard = mailbox
473 .lock()
474 .map_err(|_| StorageError::InvalidState("dataset write lock poisoned".into()))?;
475 write()
476}
477
478/// Runs `write` under the dataset's composed write lock — the write-path
479/// analog of [`try_with_metadata_file_lock`], in two legs:
480///
481/// 1. **In-process mailbox first** (`with_dataset_write_lock`): writers
482/// within this process queue behind each other, so concurrent
483/// in-process appends serialize and all succeed.
484/// 2. **Cross-process flock second**: a fail-fast `LOCK_NB` acquisition of
485/// `lock_path`; a foreign holder surfaces
486/// [`StorageError::LockWouldBlock`] naming the lock file before any
487/// write happens.
488///
489/// The mailbox-first order is what keeps in-process queueing intact: a
490/// same-process writer never contends on the flock against its own
491/// mailbox siblings (an `flock` taken by a different file description in
492/// this process excludes like a foreign one), so only a writer from
493/// *another* process can observe contention.
494///
495/// `lock_path` is the rendezvous convention of the caller (e.g. the Zarr
496/// backend's `{root}/.arro/locks/{dataset_id}.lock`); the file is created
497/// on demand, carries no data, and is left in place after release.
498///
499/// Sync: call from blocking context (the dataset write itself is blocking
500/// I/O). Off unix there is no flock leg — writes serialize in-process
501/// only, and the kernel logs one warning per process; cross-process
502/// writes to one root are not supported off unix (the LockFileEx escape
503/// hatch is tracked as tuned-org-uk/genegraph-storage#124).
504pub fn try_with_dataset_file_lock<T>(
505 lock_path: &Path,
506 dataset_dir: &Path,
507 write: impl FnOnce() -> StorageResult<T>,
508) -> StorageResult<T> {
509 with_dataset_write_lock(dataset_dir, || {
510 dataset_flock(lock_path)?;
511 write()
512 })
513}
514
515/// Fail-fast flock leg of [`try_with_dataset_file_lock`]: unix takes the
516/// advisory lock; other platforms keep the in-process mailbox only.
517#[cfg(unix)]
518fn dataset_flock(lock_path: &Path) -> StorageResult<()> {
519 FileLock::try_acquire(lock_path).map(|_| ())
520}
521
522#[cfg(not(unix))]
523fn dataset_flock(_lock_path: &Path) -> StorageResult<()> {
524 static WARNED: OnceLock<()> = OnceLock::new();
525 if WARNED.set(()).is_ok() {
526 log::warn!(
527 "cross-process dataset locks require POSIX flock; dataset \
528 writes serialize in-process only on this platform"
529 );
530 }
531 Ok(())
532}
533
534/// Test hook: (commit-registry size, dataset-registry size).
535#[cfg(test)]
536pub(crate) fn registry_sizes() -> (usize, usize) {
537 let commit = COMMIT_LOCKS
538 .get()
539 .map(|m| m.lock().unwrap().len())
540 .unwrap_or(0);
541 let dataset = DATASET_LOCKS
542 .get()
543 .map(|m| m.lock().unwrap().len())
544 .unwrap_or(0);
545 (commit, dataset)
546}