Skip to main content

objects/store/fs/
fs_store.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Core FsStore structure.
3
4use std::{
5    collections::{BTreeSet, HashMap, VecDeque},
6    hash::Hash,
7    path::{Path, PathBuf},
8    sync::{Arc, Mutex, RwLock},
9};
10
11use heddle_format::compression::CompressionConfig;
12
13use super::{
14    fs_io::{AtomicWriteMode, write_atomic},
15    fs_paths::{actions_dir, blobs_dir, packs_dir, states_dir, trees_dir},
16};
17use crate::{
18    fs_atomic::sync_directory,
19    object::{Blob, ContentHash, State, StateId, Tree},
20    store::{
21        Result,
22        pack::{PackManager, PackObjectId},
23    },
24};
25
26const RECENT_BLOB_CACHE_CAPACITY: usize = 2_048;
27const RECENT_TREE_CACHE_CAPACITY: usize = 1_024;
28/// Soft cap on the in-process loose-blob verification cache. Each
29/// entry is one `ContentHash` (~32 bytes) so this is ≈2 MB of memory
30/// for the upper bound, and the LRU eviction is bounded by hash
31/// hits rather than store size. 65k entries covers the typical hot
32/// working set for million-blob monorepos; a daemon that materialises
33/// dozens of unrelated trees won't drift toward unbounded growth.
34const VERIFIED_LOOSE_BLOB_CACHE_CAPACITY: usize = 65_536;
35/// Blobs larger than this are not stored in `recent_blobs` so a single
36/// multi-MB read cannot thrash the hot working set. 4 MiB matches the
37/// typical "large file" boundary used elsewhere in the object path.
38pub(super) const RECENT_BLOB_CACHE_MAX_BYTES: usize = 4 * 1024 * 1024;
39/// Total-byte budget for `recent_blobs`. Without it, populate-on-read
40/// could retain `RECENT_BLOB_CACHE_CAPACITY` (2048) × the 4 MiB
41/// per-entry gate ≈ 8 GiB of deep-cloned blob bytes for a read-only
42/// workload (mount / `heddled`) that streams many cold blobs. 256 MiB
43/// caps the resident blob-cache footprint while still holding a deep
44/// hot working set of small objects (the common case).
45pub(super) const RECENT_BLOB_CACHE_MAX_TOTAL_BYTES: usize = 256 * 1024 * 1024;
46
47#[derive(Clone, Copy, Debug, Eq, PartialEq)]
48pub enum LooseObjectWriteMode {
49    Durable,
50    BatchDirectorySync,
51}
52
53/// Bounded in-process object cache with true LRU eviction.
54///
55/// Two independent caps are enforced on every [`insert`](Self::insert):
56///
57/// * `capacity` — the maximum entry *count*.
58/// * `byte_budget` — a soft cap on the cumulative *bytes* of the
59///   cached values, sized by the per-entry `sizer` closure. `None`
60///   disables the byte cap (caches whose values are effectively
61///   fixed-size, e.g. the `()`-valued verified-loose cache).
62///
63/// The byte budget is what keeps populate-on-read bounded: a read-only
64/// workload (mount / `heddled`) that streams many multi-MB blobs
65/// through `get_blob` can otherwise retain `capacity × max-entry-bytes`
66/// of deep-cloned `Vec`s. With the budget, inserting a new large blob
67/// evicts LRU entries until the total fits.
68///
69/// [`get`](Self::get) promotes the key to MRU; [`insert`](Self::insert)
70/// treats re-insert as a touch. Evicts from the front of `order` when
71/// over either cap.
72#[derive(Debug)]
73pub(super) struct RecentObjectCache<K, V> {
74    entries: HashMap<K, V>,
75    order: VecDeque<K>,
76    capacity: usize,
77    /// Soft cap on cumulative cached bytes; `None` = count-only.
78    byte_budget: Option<usize>,
79    /// `sizer(value)` in bytes. Only consulted when `byte_budget`
80    /// is `Some`.
81    sizer: fn(&V) -> usize,
82    /// Running sum of `sizer(v)` over all `entries`.
83    cached_bytes: usize,
84}
85
86impl<K, V> RecentObjectCache<K, V>
87where
88    K: Copy + Eq + Hash,
89{
90    /// Count-capped cache with no byte budget. Used for caches whose
91    /// values are effectively fixed-size (e.g. the verified-loose
92    /// marker cache).
93    pub(super) fn with_capacity(capacity: usize) -> Self {
94        Self {
95            entries: HashMap::new(),
96            order: VecDeque::new(),
97            capacity,
98            byte_budget: None,
99            sizer: |_| 0,
100            cached_bytes: 0,
101        }
102    }
103
104    /// Cache capped by *both* entry count and cumulative bytes.
105    /// `sizer` reports each value's heap-ish footprint; the cache
106    /// evicts LRU entries until both caps hold.
107    pub(super) fn with_byte_budget(
108        capacity: usize,
109        byte_budget: usize,
110        sizer: fn(&V) -> usize,
111    ) -> Self {
112        Self {
113            entries: HashMap::new(),
114            order: VecDeque::new(),
115            capacity,
116            byte_budget: Some(byte_budget),
117            sizer,
118            cached_bytes: 0,
119        }
120    }
121
122    /// Lookup with LRU promotion. Callers that hold only a read lock must
123    /// upgrade to a write lock before calling this (promotion mutates
124    /// `order`).
125    pub(super) fn get(&mut self, key: &K) -> Option<&V> {
126        if !self.entries.contains_key(key) {
127            return None;
128        }
129        self.promote(key);
130        self.entries.get(key)
131    }
132
133    /// Presence check without promotion. Cheap enough to run under a
134    /// read lock — used both by verified-loose probes and by `has_*`
135    /// existence checks that must not serialize concurrent readers on
136    /// the exclusive write lock a promoting `get` would need.
137    pub(super) fn contains(&self, key: &K) -> bool {
138        self.entries.contains_key(key)
139    }
140
141    /// Drop `key` from the cache entirely. Returns the evicted value if
142    /// present. Targeted counterpart to the redaction-`purge` cache
143    /// drop: a purged blob's bytes must not linger in `recent_blobs`
144    /// where a long-lived process would keep serving (or reporting
145    /// present) the destroyed content. The production purge path drops
146    /// the whole cache via `clear_recent_caches` (it crosses the
147    /// generic `ObjectStore` seam); this per-key variant backs the
148    /// store-level `evict_recent_blob` used in tests.
149    #[cfg(test)]
150    pub(super) fn remove(&mut self, key: &K) -> Option<V> {
151        let removed = self.entries.remove(key)?;
152        if let Some(position) = self.order.iter().position(|existing| existing == key) {
153            self.order.remove(position);
154        }
155        self.cached_bytes = self.cached_bytes.saturating_sub((self.sizer)(&removed));
156        Some(removed)
157    }
158
159    pub(super) fn insert(&mut self, key: K, value: V) {
160        if self.capacity == 0 {
161            return;
162        }
163        let new_bytes = self.byte_budget.map(|_| (self.sizer)(&value)).unwrap_or(0);
164        if let Some(old) = self.entries.insert(key, value) {
165            self.cached_bytes = self
166                .cached_bytes
167                .saturating_sub(self.byte_budget.map(|_| (self.sizer)(&old)).unwrap_or(0));
168            self.promote(&key);
169        } else {
170            self.order.push_back(key);
171        }
172        self.cached_bytes += new_bytes;
173        self.evict_to_fit();
174    }
175
176    /// Evict from the LRU front until both the count cap and the byte
177    /// budget hold. The freshly-inserted entry is at the MRU back, so
178    /// it is never the eviction target (a single entry larger than the
179    /// whole budget is kept — the budget is a soft cap, not a hard
180    /// per-entry gate; the per-entry `RECENT_BLOB_CACHE_MAX_BYTES` gate
181    /// already bounds the largest thing that reaches here).
182    fn evict_to_fit(&mut self) {
183        loop {
184            let over_count = self.entries.len() > self.capacity;
185            let over_bytes = self
186                .byte_budget
187                .is_some_and(|budget| self.cached_bytes > budget && self.entries.len() > 1);
188            if !over_count && !over_bytes {
189                break;
190            }
191            let Some(oldest) = self.order.pop_front() else {
192                break;
193            };
194            // A key appears at most once in `order`; if it was already
195            // removed by a concurrent logical path we just skip.
196            if let Some(evicted) = self.entries.remove(&oldest) {
197                self.cached_bytes = self.cached_bytes.saturating_sub(
198                    self.byte_budget
199                        .map(|_| (self.sizer)(&evicted))
200                        .unwrap_or(0),
201                );
202            }
203        }
204    }
205
206    fn promote(&mut self, key: &K) {
207        if let Some(position) = self.order.iter().position(|existing| existing == key) {
208            let key = self.order.remove(position).expect("position in range");
209            self.order.push_back(key);
210        }
211    }
212}
213
214/// Filesystem-based storage for Heddle objects.
215///
216/// Layout:
217/// ```text
218/// .heddle/
219///   objects/
220///     blobs/
221///       ab/
222///         cdef1234...
223///     trees/
224///       ab/
225///         cdef1234...
226///     states/
227///       <state_id>.state
228///   actions/
229///     <action_id>.action
230///   packs/
231///     <hash>.pack
232///     <hash>.idx
233/// ```
234pub struct FsStore {
235    pub(super) root: PathBuf,
236    pub(super) compression: CompressionConfig,
237    pack_manager: RwLock<PackManager>,
238    pub(super) recent_blobs: RwLock<RecentObjectCache<ContentHash, Blob>>,
239    pub(super) recent_trees: RwLock<RecentObjectCache<ContentHash, Tree>>,
240    pub(super) recent_states: RwLock<RecentObjectCache<StateId, State>>,
241    pub(super) external_source: Option<Arc<dyn super::super::ExternalObjectSource>>,
242    loose_object_write_mode: LooseObjectWriteMode,
243    snapshot_write_batch_depth: Mutex<usize>,
244    pending_directory_syncs: Mutex<BTreeSet<PathBuf>>,
245    /// In-process trust cache for loose-blob cache mirrors. A hash
246    /// enters this LRU when this process either (a) wrote the blob
247    /// itself via `promote_to_loose_uncompressed` or (b) successfully
248    /// hash-verified it on first read. Bytes-on-disk for any entry
249    /// in this cache can be trusted without a re-hash by subsequent
250    /// `loose_blob_path` calls within the same process.
251    ///
252    /// Capped at [`VERIFIED_LOOSE_BLOB_CACHE_CAPACITY`] entries so a
253    /// long-lived process (`heddled`) materialising many unrelated
254    /// trees doesn't drift into unbounded memory growth. LRU
255    /// eviction; an evicted hash pays one extra BLAKE3 on its next
256    /// read (cost-of-evict ≈ working-set-size BLAKE3 ops). Stored as
257    /// `RecentObjectCache<…, ()>` to share the LRU-eviction
258    /// machinery with the other on-store caches; the unit value is
259    /// a marker that the corresponding loose mirror was verified.
260    ///
261    /// Pairs with `AtomicWriteMode::NoSync` on the write side: a
262    /// crashed promote leaves a torn cache-mirror file, but its
263    /// hash won't match on the next process's first-read verify,
264    /// so the reader falls through to a fresh promote off the pack.
265    pub(super) verified_loose_blobs: RwLock<RecentObjectCache<ContentHash, ()>>,
266}
267
268impl Clone for FsStore {
269    fn clone(&self) -> Self {
270        let mut cloned = Self::with_compression(&self.root, self.compression);
271        cloned.loose_object_write_mode = self.loose_object_write_mode;
272        cloned.external_source = self.external_source.clone();
273        cloned
274    }
275}
276
277impl FsStore {
278    /// Create a new filesystem store rooted at the given path.
279    ///
280    /// The path should be the `.heddle` directory.
281    pub fn new(root: impl AsRef<Path>) -> Self {
282        let root = root.as_ref().to_path_buf();
283        let pack_manager = PackManager::new(packs_dir(&root));
284        Self {
285            root,
286            compression: CompressionConfig::default(),
287            pack_manager: RwLock::new(pack_manager),
288            recent_blobs: RwLock::new(RecentObjectCache::with_byte_budget(
289                RECENT_BLOB_CACHE_CAPACITY,
290                RECENT_BLOB_CACHE_MAX_TOTAL_BYTES,
291                |blob: &Blob| blob.content().len(),
292            )),
293            recent_trees: RwLock::new(RecentObjectCache::with_capacity(RECENT_TREE_CACHE_CAPACITY)),
294            recent_states: RwLock::new(RecentObjectCache::with_capacity(
295                RECENT_TREE_CACHE_CAPACITY,
296            )),
297            external_source: None,
298            loose_object_write_mode: LooseObjectWriteMode::Durable,
299            snapshot_write_batch_depth: Mutex::new(0),
300            pending_directory_syncs: Mutex::new(BTreeSet::new()),
301            verified_loose_blobs: RwLock::new(RecentObjectCache::with_capacity(
302                VERIFIED_LOOSE_BLOB_CACHE_CAPACITY,
303            )),
304        }
305    }
306
307    /// Create a new filesystem store with custom compression settings.
308    pub fn with_compression(root: impl AsRef<Path>, compression: CompressionConfig) -> Self {
309        let root = root.as_ref().to_path_buf();
310        let pack_manager = PackManager::new(packs_dir(&root));
311        Self {
312            root,
313            compression,
314            pack_manager: RwLock::new(pack_manager),
315            recent_blobs: RwLock::new(RecentObjectCache::with_byte_budget(
316                RECENT_BLOB_CACHE_CAPACITY,
317                RECENT_BLOB_CACHE_MAX_TOTAL_BYTES,
318                |blob: &Blob| blob.content().len(),
319            )),
320            recent_trees: RwLock::new(RecentObjectCache::with_capacity(RECENT_TREE_CACHE_CAPACITY)),
321            recent_states: RwLock::new(RecentObjectCache::with_capacity(
322                RECENT_TREE_CACHE_CAPACITY,
323            )),
324            external_source: None,
325            loose_object_write_mode: LooseObjectWriteMode::Durable,
326            snapshot_write_batch_depth: Mutex::new(0),
327            pending_directory_syncs: Mutex::new(BTreeSet::new()),
328            verified_loose_blobs: RwLock::new(RecentObjectCache::with_capacity(
329                VERIFIED_LOOSE_BLOB_CACHE_CAPACITY,
330            )),
331        }
332    }
333
334    /// Initialize the directory structure.
335    pub fn init(&self) -> Result<()> {
336        // Durable create so the object-store layout dirs survive crash
337        // between mkdir and first object write (L6 residual migration).
338        crate::fs_atomic::create_dir_all_durable(&blobs_dir(&self.root))?;
339        crate::fs_atomic::create_dir_all_durable(&trees_dir(&self.root))?;
340        crate::fs_atomic::create_dir_all_durable(&states_dir(&self.root))?;
341        crate::fs_atomic::create_dir_all_durable(&actions_dir(&self.root))?;
342        crate::fs_atomic::create_dir_all_durable(&packs_dir(&self.root))?;
343        Ok(())
344    }
345
346    /// Get the root path.
347    pub fn root(&self) -> &Path {
348        &self.root
349    }
350
351    /// Get the compression configuration.
352    pub fn compression(&self) -> CompressionConfig {
353        self.compression
354    }
355
356    /// Set the compression configuration.
357    pub fn set_compression(&mut self, compression: CompressionConfig) {
358        self.compression = compression;
359    }
360
361    pub fn loose_object_write_mode(&self) -> LooseObjectWriteMode {
362        self.loose_object_write_mode
363    }
364
365    pub fn set_loose_object_write_mode(&mut self, mode: LooseObjectWriteMode) {
366        self.loose_object_write_mode = mode;
367    }
368
369    /// Configure a read-through source for objects not present in the native
370    /// store. Writes always remain native.
371    pub fn set_external_source(&mut self, source: Arc<dyn super::super::ExternalObjectSource>) {
372        self.external_source = Some(source);
373    }
374
375    fn flush_pending_directory_syncs(&self) -> Result<usize> {
376        let pending_dirs = {
377            let mut guard = self.pending_directory_syncs.lock().map_err(|_| {
378                crate::store::HeddleError::Config(
379                    "Failed to acquire pending directory sync lock".to_string(),
380                )
381            })?;
382            if guard.is_empty() {
383                return Ok(0);
384            }
385            let dirs = guard.iter().cloned().collect::<Vec<_>>();
386            guard.clear();
387            dirs
388        };
389
390        for (index, dir) in pending_dirs.iter().enumerate() {
391            if let Err(error) = sync_directory(dir) {
392                if let Ok(mut guard) = self.pending_directory_syncs.lock() {
393                    guard.extend(pending_dirs[index..].iter().cloned());
394                }
395                return Err(error.into());
396            }
397        }
398
399        Ok(pending_dirs.len())
400    }
401
402    /// Reload pack files from disk.
403    ///
404    /// Runs L8 install-intent recovery first so crash windows between pack
405    /// and index publish are finished or aborted before packs are loaded.
406    /// Uses the default intent TTL so abandoned staging is swept.
407    pub fn reload_packs(&self) -> Result<()> {
408        let packs = packs_dir(&self.root);
409        let _ = super::pack_install_journal::recover_pack_install_intents_with_ttl(
410            &packs,
411            Some(super::pack_install_journal::DEFAULT_PACK_INSTALL_INTENT_TTL_SECS),
412        )?;
413        // Option D backstop: remove any legacy unpaired packs without intent.
414        let _ = super::fs_pack::prune_unpaired_pack_files(&packs)?;
415        let mut manager = self.pack_manager.write().map_err(|_| {
416            crate::store::HeddleError::Config("Failed to acquire pack manager lock".to_string())
417        })?;
418        manager.reload()
419    }
420
421    /// Reload pack files only if the packs directory has grown on
422    /// disk since we last read it. Cheap (one `read_dir` + count)
423    /// when nothing changed; full reload only when a sibling
424    /// `FsStore` has installed a new pack.
425    ///
426    /// Returns `true` when a reload happened. Used by `get_*` and
427    /// `has_*` paths after an in-memory miss to recover from the
428    /// "two FsStores backing the same `.heddle/` directory" case
429    /// (typical for lightweight thread worktrees).
430    ///
431    /// Double-checked locking: the read-lock fast path means a
432    /// thundering herd of concurrent misses doesn't serialize on
433    /// the write lock; only the first thread that observes a stale
434    /// view escalates and does the reload.
435    pub(super) fn reload_packs_if_stale(&self) -> Result<bool> {
436        // Fast path: read-lock and bail out if disk hasn't grown.
437        {
438            let manager = self.pack_manager.read().map_err(|_| {
439                crate::store::HeddleError::Config("Failed to acquire pack manager lock".to_string())
440            })?;
441            if !manager.needs_reload()? {
442                return Ok(false);
443            }
444        }
445        // Slow path: take the write lock and re-check (another
446        // thread may have already reloaded between our drop and
447        // re-acquire).
448        let mut manager = self.pack_manager.write().map_err(|_| {
449            crate::store::HeddleError::Config("Failed to acquire pack manager lock".to_string())
450        })?;
451        manager.reload_if_disk_grew()
452    }
453
454    /// Get the pack manager for pack operations.
455    pub fn pack_manager(&self) -> &RwLock<PackManager> {
456        &self.pack_manager
457    }
458
459    pub fn clear_recent_object_caches(&self) {
460        if let Ok(mut blobs) = self.recent_blobs.write() {
461            *blobs = RecentObjectCache::with_byte_budget(
462                RECENT_BLOB_CACHE_CAPACITY,
463                RECENT_BLOB_CACHE_MAX_TOTAL_BYTES,
464                |blob: &Blob| blob.content().len(),
465            );
466        }
467        if let Ok(mut trees) = self.recent_trees.write() {
468            *trees = RecentObjectCache::with_capacity(RECENT_TREE_CACHE_CAPACITY);
469        }
470        if let Ok(mut states) = self.recent_states.write() {
471            *states = RecentObjectCache::with_capacity(RECENT_TREE_CACHE_CAPACITY);
472        }
473    }
474
475    /// Drop a single blob hash from the in-process `recent_blobs`
476    /// cache. Targeted counterpart to the redaction-`purge` cache drop:
477    /// after the loose bytes are physically deleted, a long-lived
478    /// process must not keep serving (or reporting present) the purged
479    /// content from cache. Idempotent — a miss is a no-op. Test-only:
480    /// the production purge path crosses the generic `ObjectStore` seam
481    /// and drops the whole cache via `clear_recent_caches`.
482    #[cfg(test)]
483    pub(super) fn evict_recent_blob(&self, hash: &ContentHash) {
484        if let Ok(mut cache) = self.recent_blobs.write() {
485            cache.remove(hash);
486        }
487    }
488
489    pub fn pack_ids(&self) -> Result<Vec<PackObjectId>> {
490        let manager = self.pack_manager.read().map_err(|_| {
491            crate::store::HeddleError::Config("Failed to acquire pack manager lock".to_string())
492        })?;
493        manager.list_all_ids()
494    }
495
496    pub(super) fn write_loose_object_atomic(&self, path: &Path, data: &[u8]) -> Result<()> {
497        let batch_active = self.snapshot_write_batch_depth.lock().map_err(|_| {
498            crate::store::HeddleError::Config("Failed to acquire snapshot batch lock".to_string())
499        })?;
500        let configured_mode = if *batch_active > 0 {
501            LooseObjectWriteMode::BatchDirectorySync
502        } else {
503            self.loose_object_write_mode
504        };
505        drop(batch_active);
506
507        let mode = match configured_mode {
508            LooseObjectWriteMode::Durable => AtomicWriteMode::Durable,
509            LooseObjectWriteMode::BatchDirectorySync => AtomicWriteMode::BatchDirectorySync,
510        };
511        write_atomic(path, data, mode, Some(&self.pending_directory_syncs))
512    }
513
514    /// Durable atomic write for pack/index bytes when not going through the
515    /// L8 journal (tests / rare call sites). Prefer
516    /// [`super::pack_install_journal::install_pack_bytes_journaled`].
517    #[allow(dead_code)]
518    pub(super) fn write_pack_atomic(&self, path: &Path, data: &[u8]) -> Result<()> {
519        write_atomic(path, data, AtomicWriteMode::Durable, None)
520    }
521
522    /// Atomic write tuned for *cache-mirror* loose objects: no fsync
523    /// at any level. The authoritative copy lives in a pack; if a
524    /// crash leaves the cache mirror torn, the read-side hash check
525    /// catches it and `promote_to_loose_uncompressed` rebuilds it
526    /// from the pack on the next access.
527    ///
528    /// On macOS APFS, `sync_data` alone costs ~5 ms per call (it
529    /// behaves like `F_FULLFSYNC` for tiny writes), and the parent
530    /// directory fsync is ~3-10 ms on top. For 1k blobs, that's
531    /// 5-15 seconds of pure fsync wallclock — the dominant cost in
532    /// the cold materialize path. Dropping both pays back ~30× on
533    /// raw create+rename throughput (measured: 200/s with sync_data
534    /// vs 5500/s without).
535    ///
536    /// Safety contract: this is only valid for files whose authority
537    /// lives elsewhere. Used by `promote_to_loose_uncompressed`; the
538    /// matching `loose_blob_path` reader hash-verifies before
539    /// trusting the bytes. Do *not* use for `put_blob` / `put_tree`
540    /// / `put_state` — those are the authoritative copy and must
541    /// survive a crash.
542    pub(super) fn write_loose_object_cache(&self, path: &Path, data: &[u8]) -> Result<()> {
543        write_atomic(path, data, AtomicWriteMode::NoSync, None)
544    }
545
546    pub(super) fn begin_snapshot_write_batch_impl(&self) -> Result<()> {
547        let mut depth = self.snapshot_write_batch_depth.lock().map_err(|_| {
548            crate::store::HeddleError::Config("Failed to acquire snapshot batch lock".to_string())
549        })?;
550        *depth += 1;
551        Ok(())
552    }
553
554    pub(super) fn flush_snapshot_write_batch_impl(&self) -> Result<()> {
555        let should_flush = {
556            let mut depth = self.snapshot_write_batch_depth.lock().map_err(|_| {
557                crate::store::HeddleError::Config(
558                    "Failed to acquire snapshot batch lock".to_string(),
559                )
560            })?;
561            if *depth == 0 {
562                return Ok(());
563            }
564            *depth -= 1;
565            *depth == 0
566        };
567
568        if should_flush {
569            let _ = self.flush_pending_directory_syncs()?;
570        }
571
572        Ok(())
573    }
574
575    pub(super) fn abort_snapshot_write_batch_impl(&self) {
576        if let Ok(mut depth) = self.snapshot_write_batch_depth.lock() {
577            *depth = 0;
578        }
579        if let Ok(mut pending) = self.pending_directory_syncs.lock() {
580            pending.clear();
581        }
582    }
583
584    #[cfg(test)]
585    pub(super) fn pending_directory_sync_count(&self) -> usize {
586        self.pending_directory_syncs
587            .lock()
588            .map(|pending| pending.len())
589            .unwrap_or(0)
590    }
591}