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