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//! Cross-process arbitration stays with the transactional-generations work
30//! (#93/#81-P5).
31
32use std::collections::HashMap;
33use std::path::Path;
34use std::sync::{Arc, Mutex as StdMutex, OnceLock, Weak};
35
36use tokio::sync::Mutex;
37
38use crate::{StorageError, StorageResult};
39
40/// Commit-actor mailboxes, keyed by metadata path (weak-valued, #98).
41static COMMIT_LOCKS: OnceLock<StdMutex<HashMap<String, Weak<Mutex<()>>>>> = OnceLock::new();
42/// Dataset-write mailboxes, keyed by dataset dir (weak-valued, #98).
43static DATASET_LOCKS: OnceLock<StdMutex<HashMap<String, Weak<StdMutex<()>>>>> = OnceLock::new();
44
45/// Shared weak-registry lookup (#98): reuse the live mailbox for `key` if
46/// one exists, otherwise sweep dead entries and insert `fresh`.
47pub(crate) fn weak_lookup<T>(
48 registry: &'static OnceLock<StdMutex<HashMap<String, Weak<T>>>>,
49 key: String,
50 fresh: Arc<T>,
51) -> Arc<T> {
52 let mut map = registry
53 .get_or_init(|| StdMutex::new(HashMap::new()))
54 .lock()
55 .unwrap_or_else(|poisoned| poisoned.into_inner());
56 if let Some(weak) = map.get(&key)
57 && let Some(strong) = weak.upgrade()
58 {
59 return strong;
60 }
61 map.retain(|_, weak| weak.strong_count() > 0);
62 let arc = Arc::clone(&fresh);
63 map.insert(key, Arc::downgrade(&fresh));
64 arc
65}
66
67/// One commit-actor mailbox per metadata path.
68fn lock_for(metadata_path: &Path) -> Arc<Mutex<()>> {
69 let key = metadata_path.to_string_lossy().to_string();
70 weak_lookup(&COMMIT_LOCKS, key, Arc::new(Mutex::new(())))
71}
72
73/// One dataset-write mailbox per dataset directory.
74fn dataset_lock_for(dataset_dir: &Path) -> Arc<StdMutex<()>> {
75 let key = dataset_dir.to_string_lossy().to_string();
76 weak_lookup(&DATASET_LOCKS, key, Arc::new(StdMutex::new(())))
77}
78
79/// Runs `commit` (a full metadata read-modify-write cycle) under the
80/// instance's commit actor: at most one cycle runs at a time for the same
81/// metadata path.
82pub(crate) async fn with_commit_actor<T, F, Fut>(
83 metadata_path: &Path,
84 commit: F,
85) -> StorageResult<T>
86where
87 F: FnOnce() -> Fut,
88 Fut: std::future::Future<Output = StorageResult<T>>,
89{
90 let mailbox = lock_for(metadata_path);
91 let _guard = mailbox.lock().await;
92 commit().await
93}
94
95/// Runs `write` under the dataset's write mailbox. `write` must be a
96/// non-async closure: the whole dataset write — version allocation through
97/// commit-point publish — happens under the lock.
98pub(crate) fn with_dataset_write_lock<T>(
99 dataset_dir: &Path,
100 write: impl FnOnce() -> StorageResult<T>,
101) -> StorageResult<T> {
102 let mailbox = dataset_lock_for(dataset_dir);
103 let _guard = mailbox
104 .lock()
105 .map_err(|_| StorageError::InvalidState("dataset write lock poisoned".into()))?;
106 write()
107}
108
109/// Test hook: (commit-registry size, dataset-registry size).
110#[cfg(test)]
111pub(crate) fn registry_sizes() -> (usize, usize) {
112 let commit = COMMIT_LOCKS
113 .get()
114 .map(|m| m.lock().unwrap().len())
115 .unwrap_or(0);
116 let dataset = DATASET_LOCKS
117 .get()
118 .map(|m| m.lock().unwrap().len())
119 .unwrap_or(0);
120 (commit, dataset)
121}