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