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}