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