Skip to main content

objects/store/fs/
fs_pack.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Pack and prune operations for FsStore.
3
4use std::{
5    fs,
6    path::{Path, PathBuf},
7};
8
9use super::{
10    FsStore,
11    fs_io::{list_hashes_from_dir, read_file_bytes},
12    fs_paths::{blobs_dir, hash_path, packs_dir, trees_dir},
13};
14use crate::{
15    object::ContentHash,
16    store::{
17        HeddleError, ObjectStore, Result, codec,
18        pack::{ObjectType as PackObjectType, PackBuilder},
19    },
20};
21
22/// Paths of `*.pack` files in `packs_dir` that have no matching `*.idx`.
23///
24/// L8 residual: crash between durable pack and index publish can leave an
25/// unpaired pack that [`FsStore::reload_packs`] ignores. Listing supports
26/// optional GC (design: `docs/program/L8_PACK_INSTALL_JOURNAL.md` Option D).
27/// Does not delete anything.
28pub(crate) fn list_unpaired_pack_files(packs_dir: &Path) -> std::io::Result<Vec<PathBuf>> {
29    if !packs_dir.exists() {
30        return Ok(Vec::new());
31    }
32    let mut unpaired = Vec::new();
33    for entry in fs::read_dir(packs_dir)? {
34        let entry = entry?;
35        let path = entry.path();
36        if path.extension().and_then(|e| e.to_str()) != Some("pack") {
37            continue;
38        }
39        let idx = path.with_extension("idx");
40        if !idx.exists() {
41            unpaired.push(path);
42        }
43    }
44    unpaired.sort();
45    Ok(unpaired)
46}
47
48/// Remove unpaired `*.pack` files (no matching `*.idx`) under `packs_dir`.
49///
50/// Safe for correctness: loaders never open unpaired packs. Bounds L8 disk
51/// leak. Returns `(removed_count, bytes_freed)`.
52pub(crate) fn prune_unpaired_pack_files(packs_dir: &Path) -> std::io::Result<(u64, u64)> {
53    let mut removed = 0u64;
54    let mut bytes_freed = 0u64;
55    for path in list_unpaired_pack_files(packs_dir)? {
56        let bytes = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
57        match fs::remove_file(&path) {
58            Ok(()) => {
59                removed += 1;
60                bytes_freed = bytes_freed.saturating_add(bytes);
61            }
62            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
63            Err(e) => return Err(e),
64        }
65    }
66    Ok((removed, bytes_freed))
67}
68
69fn remove_file_ignore_missing(path: &std::path::Path) -> Result<()> {
70    match fs::remove_file(path) {
71        Ok(()) => Ok(()),
72        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
73        Err(e) => Err(HeddleError::from(e)),
74    }
75}
76
77impl FsStore {
78    /// Bulk-install many blobs as a single packfile. Two fsyncs total
79    /// (one for `.pack`, one for `.idx`) regardless of blob count —
80    /// vs. N×fsync if each blob were written loose. Used by the
81    /// snapshot hot path; called at the end of the tree walk with
82    /// every new blob accumulated in memory.
83    ///
84    /// Skips blobs already in the store (whether loose or packed) so
85    /// re-snapshotting an unchanged worktree doesn't churn the pack
86    /// directory. With every blob already known, this is a no-op.
87    pub(super) fn put_blobs_packed_impl(&self, blobs: Vec<(ContentHash, Vec<u8>)>) -> Result<()> {
88        if blobs.is_empty() {
89            return Ok(());
90        }
91        // Snapshot-time pack: skip the sliding-window delta search.
92        // It's a CPU win on similar-content files (the GC packer
93        // benefits) but for a single snapshot the inputs are
94        // unrelated content (random binaries, small text, etc.) and
95        // every pair-wise delta estimate runs across the full
96        // payloads — for 16×4MB blobs that's tens of seconds of
97        // hashing for ~zero compression benefit. GC's
98        // `pack_objects_impl` keeps the full delta search; this
99        // path only optimizes durability + write throughput.
100        let mut compression = self.compression;
101        compression.max_delta_size = 0;
102        let mut builder = PackBuilder::new(compression);
103        let mut staged: Vec<(ContentHash, Vec<u8>)> = Vec::with_capacity(blobs.len());
104        for (hash, data) in blobs {
105            if ObjectStore::has_blob(self, &hash)? {
106                continue;
107            }
108            staged.push((hash, data.clone()));
109            builder.add(hash, PackObjectType::Blob, data);
110        }
111        if staged.is_empty() {
112            return Ok(());
113        }
114        let (pack_data, index_data, _stats) = builder.build()?;
115
116        // Install the pack files. `install_pack_files` clears the
117        // recent-objects caches because a generic pack install (e.g.
118        // received over the network) might shadow loose objects we
119        // didn't write. For our locally-built pack we know exactly
120        // what we just installed, so we re-populate `recent_blobs`
121        // with the staged contents immediately afterwards. Without
122        // this the snapshot hot path takes a cache miss on every
123        // blob it just wrote, and `seed_large_repository` style
124        // benchmarks that snapshot-many-times-in-a-loop end up
125        // re-reading every parent state from disk between
126        // iterations.
127        self.install_pack_files(&pack_data, &index_data)?;
128        if let Ok(mut cache) = self.recent_blobs.write() {
129            for (hash, data) in staged {
130                cache.insert(hash, crate::object::Blob::from_slice(&data));
131            }
132        }
133        Ok(())
134    }
135
136    /// Consolidate the object store into a single pack.
137    ///
138    /// GC must *shrink* the set of places a reader has to look, not grow
139    /// it. The naive "pack the loose objects into a fresh pack" strategy
140    /// regressed read performance badly: every `maintenance gc` minted a
141    /// brand-new pack *alongside* the existing pack(s) and (by default)
142    /// left the now-redundant loose copies in place. The result was an
143    /// object store with strictly MORE sources to search — loose objects
144    /// plus an ever-growing fleet of packs — and `PackManager::get_object`
145    /// probes every pack linearly, so each extra pack roughly doubled the
146    /// cost of the object lookups that `status`/`diff`/verification do.
147    ///
148    /// This implementation does a true repack: it folds every object
149    /// already living in a pack *together with* the loose blobs and trees
150    /// into one new consolidated pack, installs it, and then deletes the
151    /// superseded packs. Combined with the caller's
152    /// `prune_loose_objects`, the store ends a GC with exactly one pack
153    /// and no loose duplicates — strictly fewer read sources than it
154    /// started with. Running GC again over an already-consolidated store
155    /// is a no-op (nothing loose, one pack already covers everything).
156    ///
157    /// State objects are addressed by `ChangeId` and may have a stale
158    /// packed body shadowed by a fresher loose copy (#570). We re-pack
159    /// the packed state body verbatim; the loose copy (which `prune`
160    /// never touches) keeps shadowing it on read, so the shadow semantics
161    /// are preserved across the repack.
162    pub(super) fn pack_objects_impl(&self, aggressive: bool) -> Result<(u64, u64)> {
163        let loose_blobs = list_hashes_from_dir(&blobs_dir(&self.root))?;
164        let loose_trees = list_hashes_from_dir(&trees_dir(&self.root))?;
165
166        // Snapshot what the existing packs already hold, plus the file
167        // paths we'll retire once the consolidated pack is installed.
168        let (existing_ids, old_pack_files) = {
169            let manager = self.pack_manager().read().map_err(|_| {
170                HeddleError::Config("Failed to acquire pack manager lock".to_string())
171            })?;
172            let ids = manager.list_all_ids()?;
173            let files: Vec<(std::path::PathBuf, std::path::PathBuf)> = manager
174                .pack_file_paths()
175                .into_iter()
176                .map(|(pack, index)| (pack.to_path_buf(), index.to_path_buf()))
177                .collect();
178            (ids, files)
179        };
180
181        // Nothing loose and at most one pack already — the store is
182        // already consolidated; don't churn a fresh identical pack.
183        if loose_blobs.is_empty() && loose_trees.is_empty() && old_pack_files.len() <= 1 {
184            return Ok((0, 0));
185        }
186
187        // Consolidation packs every object that's already packed plus the
188        // loose ones. The default path SKIPS the sliding-window delta
189        // search: it runs across the full payloads of every object and on
190        // a large native store (tens of MB across thousands of objects)
191        // costs minutes for a near-zero size win, because the carried-
192        // forward objects are already zstd-compressed. `--aggressive`
193        // opts back into the full delta search for the rare "shrink the
194        // pack at all costs" case. This mirrors the snapshot hot path
195        // (`put_blobs_packed_impl`), which disables delta for the same
196        // reason.
197        let mut compression = self.compression;
198        if !aggressive {
199            compression.max_delta_size = 0;
200        }
201        let mut builder = PackBuilder::new(compression);
202        let loose_tree_set: std::collections::HashSet<ContentHash> =
203            loose_trees.iter().copied().collect();
204        let mut seen: std::collections::HashSet<crate::store::pack::PackObjectId> =
205            std::collections::HashSet::new();
206
207        // 1. Carry forward everything already in a pack so the old packs
208        //    can be retired. `get_object` resolves the body + type for
209        //    any id (blob/tree/state/action), and `add_id` preserves
210        //    ChangeId-keyed state objects.
211        for id in existing_ids {
212            if !seen.insert(id) {
213                continue;
214            }
215            let obj_type = {
216                let manager = self.pack_manager().read().map_err(|_| {
217                    HeddleError::Config("Failed to acquire pack manager lock".to_string())
218                })?;
219                manager.get_object(&id)?
220            };
221            if let Some((obj_type, mut data)) = obj_type {
222                if let crate::store::pack::PackObjectId::Hash(hash) = id
223                    && obj_type == PackObjectType::Tree
224                    && loose_tree_set.contains(&hash)
225                    && let Some(loose_data) = ObjectStore::get_tree_serialized(self, &hash)?
226                {
227                    data = loose_data;
228                }
229                builder.add_id(id, obj_type, data);
230            }
231        }
232
233        // 2. Fold in the loose blobs and trees. Skip any whose hash is
234        //    already covered by a carried-forward pack entry.
235        for hash in &loose_blobs {
236            let id = crate::store::pack::PackObjectId::Hash(*hash);
237            if seen.contains(&id) {
238                continue;
239            }
240            if let Some(blob) = ObjectStore::get_blob(self, hash)? {
241                seen.insert(id);
242                builder.add(*hash, PackObjectType::Blob, blob.content().to_vec());
243            }
244        }
245        for hash in &loose_trees {
246            let id = crate::store::pack::PackObjectId::Hash(*hash);
247            if seen.contains(&id) {
248                continue;
249            }
250            if let Some(tree) = ObjectStore::get_tree(self, hash)? {
251                let data = rmp_serde::to_vec(&tree)?;
252                seen.insert(id);
253                builder.add(*hash, PackObjectType::Tree, data);
254            }
255        }
256
257        if seen.is_empty() {
258            return Ok((0, 0));
259        }
260
261        let (pack_data, index_data, stats) = builder.build()?;
262        self.install_pack_files(&pack_data, &index_data)?;
263        // GC packs *replace* loose objects (followed by
264        // `prune_loose_objects`). Bust the recent-objects caches so
265        // a subsequent get_* doesn't return a stale `Blob`/`Tree`
266        // pointing at a path we're about to delete. The snapshot hot
267        // path doesn't go through here — it calls
268        // `install_pack_files` directly via `put_blobs_packed_impl`,
269        // which keeps its caches warm.
270        self.clear_recent_object_caches();
271
272        // Retire the superseded packs now that the consolidated pack is
273        // durably installed and every object they held has been carried
274        // forward. The consolidated pack is content-addressed, so if it
275        // happened to hash-collide with an old pack (a store that was
276        // already a single consolidated pack) that file is excluded here.
277        // Stack hex digest; compare as &str — no format!/String intermediate.
278        let new_pack_name = blake3::hash(&pack_data).to_hex();
279        for (pack_path, index_path) in &old_pack_files {
280            let is_new_pack = pack_path
281                .file_stem()
282                .and_then(|stem| stem.to_str())
283                .map(|stem| stem == new_pack_name.as_str())
284                .unwrap_or(false);
285            if is_new_pack {
286                continue;
287            }
288            remove_file_ignore_missing(pack_path)?;
289            remove_file_ignore_missing(index_path)?;
290        }
291        // Disk shrank (packs removed), so `reload_if_disk_grew` would
292        // not pick this up — force a full reload of the pack list.
293        self.reload_packs()?;
294        self.clear_recent_object_caches();
295
296        let saved = stats.total_uncompressed - stats.total_compressed;
297        Ok((stats.object_count, saved))
298    }
299
300    pub(super) fn install_pack_files(&self, pack_data: &[u8], index_data: &[u8]) -> Result<()> {
301        let packs = packs_dir(&self.root);
302        // L8 A+: durable staging + intent journal for in-memory pack install
303        // (same crash-safety as install_pack_files_streaming).
304        // Design: docs/program/L8_PACK_INSTALL_JOURNAL.md
305        let _pack_name = super::pack_install_journal::install_pack_bytes_journaled(
306            &packs, pack_data, index_data,
307        )?;
308        // Pack manager picks up the new files. We do *not* clear the
309        // recent-object caches here — every caller that follows this
310        // with a destructive prune is responsible for clearing them
311        // explicitly. Snapshot installs rely on cache stickiness to
312        // keep tight snapshot loops fast (see
313        // `put_blobs_packed_impl`).
314        self.reload_packs()?;
315        Ok(())
316    }
317
318    /// Move a pack and its index already on disk into the store's
319    /// pack directory, computing the pack's content-hash by streaming
320    /// the file (constant memory regardless of pack size). Pairs with
321    /// `StreamingPackBuilder`: pack data, the index, *and* this
322    /// installation step never load the full pack or index into
323    /// memory.
324    ///
325    /// Sources are staged then published via the L8 A+ install journal
326    /// ([`super::pack_install_journal`]): durable staging + intent, then
327    /// pack/index publish with crash recovery on reload.
328    pub(super) fn install_pack_files_streaming(
329        &self,
330        src_pack_path: &std::path::Path,
331        src_index_path: &std::path::Path,
332    ) -> Result<()> {
333        use std::io::Read;
334
335        let packs = packs_dir(&self.root);
336        crate::fs_atomic::create_dir_all_durable(&packs)?;
337
338        // Stream-hash the pack file to derive its name. 64 KiB chunks
339        // keep the hasher's working set tiny.
340        let mut hasher = blake3::Hasher::new();
341        let mut file = fs::File::open(src_pack_path)?;
342        let mut buf = vec![0u8; 64 * 1024];
343        loop {
344            let n = file.read(&mut buf)?;
345            if n == 0 {
346                break;
347            }
348            hasher.update(&buf[..n]);
349        }
350        drop(file);
351        // Native digest for potential callers; hex String only for the journal
352        // path/name boundary (filenames + intent JSON).
353        let pack_hash = hasher.finalize();
354        let pack_name = pack_hash.to_hex().to_string();
355
356        // L8 A+: durable staging + intent journal, then pack/index publish.
357        // Recovery on reload finishes or aborts incomplete installs.
358        // Design: docs/program/L8_PACK_INSTALL_JOURNAL.md
359        super::pack_install_journal::install_pack_files_journaled(
360            &packs,
361            src_pack_path,
362            src_index_path,
363            &pack_name,
364        )?;
365
366        self.clear_recent_object_caches();
367        self.reload_packs()?;
368        Ok(())
369    }
370
371    /// Remove L8 orphan packs (`.pack` without `.idx`) from this store.
372    pub fn prune_unpaired_packs(&self) -> Result<(u64, u64)> {
373        let packs = packs_dir(&self.root);
374        Ok(prune_unpaired_pack_files(&packs)?)
375    }
376
377    pub(super) fn prune_loose_objects_impl(&self) -> Result<(u64, u64)> {
378        let mut removed = 0u64;
379        let mut bytes_freed = 0u64;
380
381        let blobs = list_hashes_from_dir(&blobs_dir(&self.root))?;
382        let trees = list_hashes_from_dir(&trees_dir(&self.root))?;
383
384        let pack_manager = self
385            .pack_manager()
386            .read()
387            .map_err(|_| HeddleError::Config("Failed to acquire pack manager lock".to_string()))?;
388
389        for hash in &blobs {
390            if pack_manager.get_hashed_object(hash)?.is_some() {
391                let path = hash_path(&blobs_dir(&self.root), hash);
392                match fs::metadata(&path) {
393                    Ok(metadata) => match fs::remove_file(&path) {
394                        Ok(()) => {
395                            bytes_freed += metadata.len();
396                            removed += 1;
397                        }
398                        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
399                        Err(e) => return Err(HeddleError::from(e)),
400                    },
401                    Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
402                    Err(e) => return Err(HeddleError::from(e)),
403                }
404            }
405        }
406
407        for hash in &trees {
408            let Some((obj_type, packed_data)) = pack_manager.get_hashed_object(hash)? else {
409                continue;
410            };
411            if obj_type != PackObjectType::Tree {
412                continue;
413            }
414            let path = hash_path(&trees_dir(&self.root), hash);
415            let Some(loose_data) = read_file_bytes(&path)? else {
416                continue;
417            };
418            let loose_data = codec::decode_tree_body(loose_data.as_slice())?;
419            if packed_data == loose_data {
420                match fs::metadata(&path) {
421                    Ok(metadata) => match fs::remove_file(&path) {
422                        Ok(()) => {
423                            bytes_freed += metadata.len();
424                            removed += 1;
425                        }
426                        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
427                        Err(e) => return Err(HeddleError::from(e)),
428                    },
429                    Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
430                    Err(e) => return Err(HeddleError::from(e)),
431                }
432            }
433        }
434
435        Ok((removed, bytes_freed))
436    }
437}
438
439#[cfg(test)]
440mod unpaired_pack_tests {
441    use std::fs;
442
443    use super::{list_unpaired_pack_files, prune_unpaired_pack_files};
444
445    #[test]
446    fn list_and_prune_unpaired_packs() {
447        let dir = tempfile::tempdir().unwrap();
448        let packs = dir.path();
449        fs::write(packs.join("aaa.pack"), b"pack-only").unwrap();
450        fs::write(packs.join("bbb.pack"), b"paired-pack").unwrap();
451        fs::write(packs.join("bbb.idx"), b"paired-idx").unwrap();
452        fs::write(packs.join("ccc.idx"), b"index-only").unwrap();
453
454        let listed = list_unpaired_pack_files(packs).unwrap();
455        assert_eq!(listed.len(), 1);
456        assert!(listed[0].ends_with("aaa.pack"));
457
458        let (removed, bytes) = prune_unpaired_pack_files(packs).unwrap();
459        assert_eq!(removed, 1);
460        assert_eq!(bytes, b"pack-only".len() as u64);
461        assert!(!packs.join("aaa.pack").exists());
462        assert!(packs.join("bbb.pack").exists());
463        assert!(packs.join("bbb.idx").exists());
464        assert!(packs.join("ccc.idx").exists());
465        assert!(list_unpaired_pack_files(packs).unwrap().is_empty());
466    }
467
468    #[test]
469    fn missing_packs_dir_is_empty() {
470        let dir = tempfile::tempdir().unwrap();
471        let missing = dir.path().join("nope");
472        assert!(list_unpaired_pack_files(&missing).unwrap().is_empty());
473        assert_eq!(prune_unpaired_pack_files(&missing).unwrap(), (0, 0));
474    }
475}