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, tree_lineage_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(&tree_lineage_dir(&self.root))?;
369 crate::fs_atomic::create_dir_all_durable(&states_dir(&self.root))?;
370 crate::fs_atomic::create_dir_all_durable(&actions_dir(&self.root))?;
371 crate::fs_atomic::create_dir_all_durable(&packs_dir(&self.root))?;
372 Ok(())
373 }
374
375 /// Get the root path.
376 pub fn root(&self) -> &Path {
377 &self.root
378 }
379
380 /// Get the compression configuration.
381 pub fn compression(&self) -> CompressionConfig {
382 self.compression
383 }
384
385 /// Set the compression configuration.
386 pub fn set_compression(&mut self, compression: CompressionConfig) {
387 self.compression = compression;
388 }
389
390 /// Enable or disable sliding-window delta search for snapshot packs.
391 pub fn set_snapshot_delta_search(&mut self, enabled: bool) {
392 self.snapshot_delta_search = enabled;
393 }
394
395 pub fn loose_object_write_mode(&self) -> LooseObjectWriteMode {
396 self.loose_object_write_mode
397 }
398
399 pub fn set_loose_object_write_mode(&mut self, mode: LooseObjectWriteMode) {
400 self.loose_object_write_mode = mode;
401 }
402
403 /// Configure a read-through source for objects not present in the native
404 /// store. Writes always remain native.
405 pub fn set_external_source(&mut self, source: Arc<dyn super::super::ExternalObjectSource>) {
406 self.external_source = Some(source);
407 }
408
409 fn flush_pending_directory_syncs(&self) -> Result<usize> {
410 let pending_dirs = {
411 let mut guard = self.pending_directory_syncs.lock().map_err(|_| {
412 crate::store::HeddleError::Config(
413 "Failed to acquire pending directory sync lock".to_string(),
414 )
415 })?;
416 if guard.is_empty() {
417 return Ok(0);
418 }
419 let dirs = guard.iter().cloned().collect::<Vec<_>>();
420 guard.clear();
421 dirs
422 };
423
424 for (index, dir) in pending_dirs.iter().enumerate() {
425 if let Err(error) = sync_directory(dir) {
426 if let Ok(mut guard) = self.pending_directory_syncs.lock() {
427 guard.extend(pending_dirs[index..].iter().cloned());
428 }
429 return Err(error.into());
430 }
431 }
432
433 Ok(pending_dirs.len())
434 }
435
436 /// Reload pack files from disk.
437 ///
438 /// Runs L8 install-intent recovery first so crash windows between pack
439 /// and index publish are finished or aborted before packs are loaded.
440 /// Uses the default intent TTL so abandoned staging is swept.
441 pub fn reload_packs(&self) -> Result<()> {
442 let packs = packs_dir(&self.root);
443 let _ = super::pack_install_journal::recover_pack_install_intents_with_ttl(
444 &packs,
445 Some(super::pack_install_journal::DEFAULT_PACK_INSTALL_INTENT_TTL_SECS),
446 )?;
447 // Option D backstop: remove any legacy unpaired packs without intent.
448 let _ = super::fs_pack::prune_unpaired_pack_files(&packs)?;
449 let mut manager = self.pack_manager.write().map_err(|_| {
450 crate::store::HeddleError::Config("Failed to acquire pack manager lock".to_string())
451 })?;
452 manager.reload()
453 }
454
455 /// Reload pack files only if the immutable pack set changed on disk.
456 /// Cheap discovery when nothing changed; full reload when a sibling
457 /// `FsStore` installed a pack or atomically replaced a generation.
458 ///
459 /// Returns `true` when a reload happened. Used by `get_*` and
460 /// `has_*` paths after an in-memory miss to recover from the
461 /// "two FsStores backing the same `.heddle/` directory" case
462 /// (typical for lightweight thread worktrees).
463 ///
464 /// Double-checked locking: the read-lock fast path means a
465 /// thundering herd of concurrent misses doesn't serialize on
466 /// the write lock; only the first thread that observes a stale
467 /// view escalates and does the reload.
468 pub(super) fn reload_packs_if_stale(&self) -> Result<bool> {
469 // Fast path: read-lock and bail out if the disk snapshot still matches.
470 {
471 let manager = self.pack_manager.read().map_err(|_| {
472 crate::store::HeddleError::Config("Failed to acquire pack manager lock".to_string())
473 })?;
474 if !manager.needs_reload()? {
475 return Ok(false);
476 }
477 }
478 // Slow path: take the write lock and re-check (another
479 // thread may have already reloaded between our drop and
480 // re-acquire).
481 let mut manager = self.pack_manager.write().map_err(|_| {
482 crate::store::HeddleError::Config("Failed to acquire pack manager lock".to_string())
483 })?;
484 manager.reload_if_stale()
485 }
486
487 /// Get the pack manager for pack operations.
488 pub fn pack_manager(&self) -> &RwLock<SnapshotPackManager> {
489 &self.pack_manager
490 }
491
492 pub fn clear_recent_object_caches(&self) {
493 if let Ok(mut blobs) = self.recent_blobs.write() {
494 *blobs = RecentObjectCache::with_byte_budget(
495 RECENT_BLOB_CACHE_CAPACITY,
496 RECENT_BLOB_CACHE_MAX_TOTAL_BYTES,
497 |blob: &Blob| blob.content().len(),
498 );
499 }
500 if let Ok(mut trees) = self.recent_trees.write() {
501 *trees = RecentObjectCache::with_capacity(RECENT_TREE_CACHE_CAPACITY);
502 }
503 if let Ok(mut states) = self.recent_states.write() {
504 *states = RecentObjectCache::with_capacity(RECENT_TREE_CACHE_CAPACITY);
505 }
506 }
507
508 /// Drop a single blob hash from the in-process `recent_blobs`
509 /// cache. Targeted counterpart to the redaction-`purge` cache drop:
510 /// after the loose bytes are physically deleted, a long-lived
511 /// process must not keep serving (or reporting present) the purged
512 /// content from cache. Idempotent — a miss is a no-op. Test-only:
513 /// the production purge path crosses the generic `ObjectStore` seam
514 /// and drops the whole cache via `clear_recent_caches`.
515 #[cfg(test)]
516 pub(super) fn evict_recent_blob(&self, hash: &ContentHash) {
517 if let Ok(mut cache) = self.recent_blobs.write() {
518 cache.remove(hash);
519 }
520 }
521
522 pub fn pack_ids(&self) -> Result<Vec<PackObjectId>> {
523 let manager = self.pack_manager.read().map_err(|_| {
524 crate::store::HeddleError::Config("Failed to acquire pack manager lock".to_string())
525 })?;
526 manager.list_all_ids()
527 }
528
529 pub(super) fn write_loose_object_atomic(&self, path: &Path, data: &[u8]) -> Result<()> {
530 let batch_active = SNAPSHOT_WRITE_BATCH_DEPTHS
531 .with(|depths| depths.borrow().get(&self.root).copied().unwrap_or_default() > 0);
532 let configured_mode = if batch_active {
533 LooseObjectWriteMode::BatchDirectorySync
534 } else {
535 self.loose_object_write_mode
536 };
537
538 let mode = match configured_mode {
539 LooseObjectWriteMode::Durable => AtomicWriteMode::Durable,
540 LooseObjectWriteMode::BatchDirectorySync => AtomicWriteMode::BatchDirectorySync,
541 };
542 write_atomic(path, data, mode, Some(&self.pending_directory_syncs))
543 }
544
545 /// Durable atomic write for pack/index bytes when not going through the
546 /// L8 journal (tests / rare call sites). Prefer
547 /// [`super::pack_install_journal::install_pack_bytes_journaled`].
548 #[allow(dead_code)]
549 pub(super) fn write_pack_atomic(&self, path: &Path, data: &[u8]) -> Result<()> {
550 write_atomic(path, data, AtomicWriteMode::Durable, None)
551 }
552
553 /// Atomic write tuned for *cache-mirror* loose objects: no fsync
554 /// at any level. The authoritative copy lives in a pack; if a
555 /// crash leaves the cache mirror torn, the read-side hash check
556 /// catches it and `promote_to_loose_uncompressed` rebuilds it
557 /// from the pack on the next access.
558 ///
559 /// On macOS APFS, `sync_data` alone costs ~5 ms per call (it
560 /// behaves like `F_FULLFSYNC` for tiny writes), and the parent
561 /// directory fsync is ~3-10 ms on top. For 1k blobs, that's
562 /// 5-15 seconds of pure fsync wallclock — the dominant cost in
563 /// the cold materialize path. Dropping both pays back ~30× on
564 /// raw create+rename throughput (measured: 200/s with sync_data
565 /// vs 5500/s without).
566 ///
567 /// Safety contract: this is only valid for files whose authority
568 /// lives elsewhere. Used by `promote_to_loose_uncompressed`; the
569 /// matching `loose_blob_path` reader hash-verifies before
570 /// trusting the bytes. Do *not* use for `put_blob` / `put_tree`
571 /// / `put_state` — those are the authoritative copy and must
572 /// survive a crash.
573 pub(super) fn write_loose_object_cache(&self, path: &Path, data: &[u8]) -> Result<()> {
574 self.write_reconstructible_cache(path, data)
575 }
576
577 /// Atomically publish reconstructible cache bytes without a durability
578 /// barrier. The caller must be able to rebuild the file from an
579 /// authoritative object after a crash.
580 pub(super) fn write_reconstructible_cache(&self, path: &Path, data: &[u8]) -> Result<()> {
581 write_atomic(path, data, AtomicWriteMode::NoSync, None)
582 }
583
584 pub(super) fn begin_snapshot_write_batch_impl(&self) -> Result<()> {
585 SNAPSHOT_WRITE_BATCH_DEPTHS.with(|depths| {
586 *depths.borrow_mut().entry(self.root.clone()).or_default() += 1;
587 });
588 Ok(())
589 }
590
591 pub(super) fn flush_snapshot_write_batch_impl(&self) -> Result<()> {
592 let had_batch = SNAPSHOT_WRITE_BATCH_DEPTHS.with(|depths| {
593 let mut depths = depths.borrow_mut();
594 let Some(depth) = depths.get_mut(&self.root) else {
595 return false;
596 };
597 *depth -= 1;
598 if *depth == 0 {
599 depths.remove(&self.root);
600 }
601 true
602 });
603 if !had_batch {
604 return Ok(());
605 }
606
607 #[cfg(test)]
608 self.snapshot_batch_flushes.fetch_add(1, Ordering::Relaxed);
609
610 // Batches may overlap across snapshot preparers. Each successful
611 // preparer must establish durability for its own writes before it can
612 // publish an oplog edge, even while another batch remains active.
613 // Draining the shared set is safe: entries taken by another flush are
614 // already durable, and every write from this batch was queued before
615 // this call acquired the set.
616 let _ = self.flush_pending_directory_syncs()?;
617 Ok(())
618 }
619
620 pub(super) fn abort_snapshot_write_batch_impl(&self) {
621 let should_flush = SNAPSHOT_WRITE_BATCH_DEPTHS.with(|depths| {
622 let mut depths = depths.borrow_mut();
623 let Some(depth) = depths.get_mut(&self.root) else {
624 // A preceding flush may have removed the thread-local batch
625 // before its directory sync failed. Preserve abort's
626 // conservative retry of those pending syncs.
627 return true;
628 };
629 *depth -= 1;
630 if *depth == 0 {
631 depths.remove(&self.root);
632 true
633 } else {
634 false
635 }
636 });
637 // Immutable objects staged by a failed snapshot are harmless orphans.
638 // Never clear another concurrent preparation's pending directory syncs;
639 // when this was the last batch, conservatively make every staged rename
640 // durable before returning.
641 if should_flush {
642 let _ = self.flush_pending_directory_syncs();
643 }
644 }
645
646 #[cfg(test)]
647 pub(super) fn pending_directory_sync_count(&self) -> usize {
648 self.pending_directory_syncs
649 .lock()
650 .map(|pending| pending.len())
651 .unwrap_or(0)
652 }
653
654 #[cfg(test)]
655 pub(super) fn snapshot_batch_flush_count(&self) -> usize {
656 self.snapshot_batch_flushes.load(Ordering::Relaxed)
657 }
658}