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}