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}