Skip to main content

objects/store/fs/
fs_store.rs

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