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/// 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}