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    num::NonZeroUsize,
7    path::{Path, PathBuf},
8    sync::Arc,
9};
10
11use super::{
12    FsStore,
13    fs_impl::validate_state_serialized,
14    fs_io::{list_hashes_from_dir, list_state_ids_from_dir, read_file_bytes},
15    fs_paths::{blobs_dir, hash_path, packs_dir, state_path, states_dir, trees_dir},
16};
17use crate::{
18    object::{ContentHash, State, StateAttachment, StateAttachmentId, Tree},
19    store::{
20        FsRepackOperation, HeddleError, ObjectStore, RepackPolicy, RepackResourceLimits,
21        RepackSchedule, RepackScheduler, Result, SnapshotCommitArtifact, SnapshotCommitDescriptor,
22        codec,
23        pack::{ObjectType as PackObjectType, PackBuilder, PackObjectId, PackReader},
24        snapshot_commit::snapshot_commit_marker_path,
25    },
26};
27
28/// Paths of `*.pack` files in `packs_dir` that have no matching `*.idx`.
29///
30/// L8 residual: crash between durable pack and index publish can leave an
31/// unpaired pack that [`FsStore::reload_packs`] ignores. Listing supports
32/// optional GC (design: `docs/program/L8_PACK_INSTALL_JOURNAL.md` Option D).
33/// Does not delete anything.
34pub(crate) fn list_unpaired_pack_files(packs_dir: &Path) -> std::io::Result<Vec<PathBuf>> {
35    if !packs_dir.exists() {
36        return Ok(Vec::new());
37    }
38    let mut unpaired = Vec::new();
39    for entry in fs::read_dir(packs_dir)? {
40        let entry = entry?;
41        let path = entry.path();
42        if path.extension().and_then(|e| e.to_str()) != Some("pack") {
43            continue;
44        }
45        let idx = path.with_extension("idx");
46        if !idx.exists() {
47            unpaired.push(path);
48        }
49    }
50    unpaired.sort();
51    Ok(unpaired)
52}
53
54/// Remove unpaired `*.pack` files (no matching `*.idx`) under `packs_dir`.
55///
56/// Safe for correctness: loaders never open unpaired packs. Bounds L8 disk
57/// leak. Returns `(removed_count, bytes_freed)`.
58pub(crate) fn prune_unpaired_pack_files(packs_dir: &Path) -> std::io::Result<(u64, u64)> {
59    let mut removed = 0u64;
60    let mut bytes_freed = 0u64;
61    for path in list_unpaired_pack_files(packs_dir)? {
62        let bytes = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
63        match fs::remove_file(&path) {
64            Ok(()) => {
65                removed += 1;
66                bytes_freed = bytes_freed.saturating_add(bytes);
67            }
68            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
69            Err(e) => return Err(e),
70        }
71    }
72    Ok((removed, bytes_freed))
73}
74
75fn remove_file_ignore_missing(path: &std::path::Path) -> Result<()> {
76    match fs::remove_file(path) {
77        Ok(()) => Ok(()),
78        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
79        Err(e) => Err(HeddleError::from(e)),
80    }
81}
82
83fn remove_file_counted(path: &Path) -> Result<Option<u64>> {
84    let metadata = match fs::metadata(path) {
85        Ok(metadata) => metadata,
86        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
87        Err(error) => return Err(HeddleError::from(error)),
88    };
89    match fs::remove_file(path) {
90        Ok(()) => Ok(Some(metadata.len())),
91        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
92        Err(error) => Err(HeddleError::from(error)),
93    }
94}
95
96impl FsStore {
97    /// Rewrite all packs without `hash`, remove its loose copy, and verify that
98    /// neither local nor external object lookup can still serve the bytes.
99    pub fn remove_blob_everywhere(&self, hash: &ContentHash) -> Result<bool> {
100        let was_present = ObjectStore::has_blob_locally(self, hash)?;
101        if was_present {
102            let scheduler = RepackScheduler::new(
103                RepackPolicy::default(),
104                RepackResourceLimits::new(NonZeroUsize::MIN),
105            );
106            let operation = Arc::new(FsRepackOperation::new(self.clone()).excluding_blob(*hash));
107            let RepackSchedule::Started(handle) = scheduler
108                .repack_now(operation)
109                .map_err(|error| HeddleError::InvalidObject(error.to_string()))?
110            else {
111                return Err(HeddleError::InvalidObject(
112                    "exclusive purge repack did not start".to_string(),
113                ));
114            };
115            handle
116                .wait()
117                .map_err(|error| HeddleError::InvalidObject(error.to_string()))?;
118
119            // The repack operation owns a clone of this store, so its atomic
120            // cutover updates that clone's in-memory pack manager. Reload the
121            // caller's manager from the newly published generation before
122            // checking whether the purged object is still reachable.
123            self.reload_packs()?;
124
125            let loose = hash_path(&blobs_dir(&self.root), hash);
126            match fs::remove_file(&loose) {
127                Ok(()) => {
128                    if let Some(parent) = loose.parent() {
129                        crate::fs_atomic::sync_directory(parent)?;
130                    }
131                }
132                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
133                Err(error) => return Err(error.into()),
134            }
135        }
136        self.clear_recent_object_caches();
137        if ObjectStore::has_blob_locally(self, hash)?
138            || ObjectStore::get_blob(self, hash)?.is_some()
139            || ObjectStore::get_blob_bytes(self, hash)?.is_some()
140        {
141            return Err(HeddleError::InvalidObject(format!(
142                "purged blob {} remains readable after pack rewrite",
143                hash.short()
144            )));
145        }
146        Ok(was_present)
147    }
148
149    pub(crate) fn put_committed_snapshot_objects_packed_impl(
150        &self,
151        blobs: Vec<(ContentHash, Vec<u8>)>,
152        trees: Vec<Tree>,
153        tree: &Tree,
154        state: &State,
155        attachments: Vec<StateAttachment>,
156        artifact: SnapshotCommitArtifact,
157    ) -> Result<SnapshotCommitDescriptor> {
158        self.put_snapshot_objects_packed_impl(
159            blobs,
160            trees,
161            tree,
162            state,
163            attachments,
164            Some(artifact),
165        )?
166        .ok_or_else(|| {
167            HeddleError::InvalidObject(
168                "committed snapshot pack did not expose its artifact descriptor".to_string(),
169            )
170        })
171    }
172
173    /// Install blobs, root tree, state, and immutable authored attachments
174    /// through one pack publication. Ordinary callers treat the pack as
175    /// pre-oplog staging; committed structured snapshots add their local trust
176    /// marker in the same directory barrier and make the pack authoritative.
177    pub(super) fn put_snapshot_objects_packed_impl(
178        &self,
179        blobs: Vec<(ContentHash, Vec<u8>)>,
180        trees: Vec<Tree>,
181        tree: &Tree,
182        state: &State,
183        attachments: Vec<StateAttachment>,
184        commit_artifact: Option<SnapshotCommitArtifact>,
185    ) -> Result<Option<SnapshotCommitDescriptor>> {
186        // A committed snapshot artifact is installed only after exact-once and
187        // isolation validation. Its freshly-authored StateId cannot be a retry
188        // (dedup returns before this callback), so avoid an expected-negative
189        // pack-directory rescan on every native capture.
190        let state_was_present = if commit_artifact.is_some() {
191            false
192        } else {
193            <Self as ObjectStore>::has_state(self, &state.id())?
194        };
195        let mut compression = self.compression;
196        if !self.snapshot_delta_search {
197            compression.max_delta_size = 0;
198        }
199        let mut builder = PackBuilder::new(compression);
200        let mut staged_blobs = Vec::with_capacity(blobs.len());
201
202        for (hash, data) in blobs {
203            if commit_artifact.is_none() && ObjectStore::has_blob_locally(self, &hash)? {
204                continue;
205            }
206            staged_blobs.push((hash, data.clone()));
207            builder.add(hash, PackObjectType::Blob, data);
208        }
209
210        let tree_hash = tree.hash();
211        let mut staged_trees = Vec::with_capacity(trees.len() + 1);
212        let mut seen_trees = std::collections::HashSet::with_capacity(trees.len() + 1);
213        for authored_tree in trees {
214            let authored_hash = authored_tree.hash();
215            if seen_trees.insert(authored_hash)
216                && (commit_artifact.is_some()
217                    || !ObjectStore::has_tree_locally(self, &authored_hash)?)
218            {
219                builder.add(
220                    authored_hash,
221                    PackObjectType::Tree,
222                    rmp_serde::to_vec_named(&authored_tree)?,
223                );
224                staged_trees.push((authored_hash, authored_tree));
225            }
226        }
227        if (commit_artifact.is_some() || !ObjectStore::has_tree_locally(self, &tree_hash)?)
228            && seen_trees.insert(tree_hash)
229        {
230            builder.add(
231                tree_hash,
232                PackObjectType::Tree,
233                rmp_serde::to_vec_named(tree)?,
234            );
235            staged_trees.push((tree_hash, tree.clone()));
236        }
237
238        let state_id = state.id();
239        builder.add_id(
240            PackObjectId::StateId(state_id),
241            PackObjectType::State,
242            rmp_serde::to_vec_named(state)?,
243        );
244        let attachment_ids = attachments
245            .iter()
246            .map(|attachment| {
247                if attachment.state_id != state_id {
248                    return Err(HeddleError::InvalidObject(
249                        "snapshot attachment targets a different state".to_string(),
250                    ));
251                }
252                let id = attachment.id();
253                builder.add(
254                    *id.as_hash(),
255                    PackObjectType::StateAttachment,
256                    rmp_serde::to_vec_named(attachment)?,
257                );
258                Ok(id)
259            })
260            .collect::<Result<Vec<StateAttachmentId>>>()?;
261        let artifact_id = commit_artifact.as_ref().map(SnapshotCommitArtifact::id);
262        let artifact_bytes = commit_artifact
263            .as_ref()
264            .map(rmp_serde::to_vec_named)
265            .transpose()?;
266        if let Some(artifact) = &commit_artifact {
267            artifact.validate()?;
268            builder.add(
269                artifact.id(),
270                PackObjectType::SnapshotCommit,
271                artifact_bytes.clone().expect("artifact bytes are present"),
272            );
273        }
274
275        let (pack_data, index_data, _stats) = builder.build()?;
276        let packs = packs_dir(&self.root);
277        let installed_pack_name = if commit_artifact.is_some() {
278            super::pack_install_journal::install_committed_snapshot_pack_bytes(
279                &packs,
280                pack_data,
281                index_data,
282                artifact_id.expect("commit artifact id is present"),
283                artifact_bytes.expect("commit artifact bytes are present"),
284            )?
285        } else {
286            super::pack_install_journal::install_snapshot_pack_bytes(&packs, pack_data, index_data)?
287        };
288        {
289            let mut manager = self.pack_manager().write().map_err(|_| {
290                HeddleError::Config("Failed to acquire pack manager lock".to_string())
291            })?;
292            manager.add_pack(
293                packs.join(format!("{installed_pack_name}.pack")),
294                packs.join(format!("{installed_pack_name}.idx")),
295            )?;
296        }
297        self.materialize_packed_attachment_index(&state_id, &attachment_ids, state_was_present)?;
298
299        if let Ok(mut cache) = self.recent_blobs.write() {
300            for (hash, data) in staged_blobs {
301                cache.insert(hash, crate::object::Blob::from_slice(&data));
302            }
303        }
304        if let Ok(mut cache) = self.recent_trees.write() {
305            for (hash, authored_tree) in staged_trees {
306                cache.insert(hash, authored_tree);
307            }
308        }
309        if let Ok(mut cache) = self.recent_states.write() {
310            let mut cached = state.clone();
311            cached.state_id = state_id;
312            cache.insert(state_id, cached);
313        }
314        let descriptor = if let Some(artifact) = commit_artifact {
315            let pack_path = packs.join(format!("{installed_pack_name}.pack"));
316            let index_path = packs.join(format!("{installed_pack_name}.idx"));
317            let object_ids = PackReader::open(&pack_path, &index_path)?.list_ids()?;
318            Some(SnapshotCommitDescriptor {
319                artifact,
320                pack_name: installed_pack_name,
321                pack_path,
322                object_ids,
323            })
324        } else {
325            None
326        };
327        Ok(descriptor)
328    }
329
330    /// Bulk-install many blobs as a single packfile. Two fsyncs total
331    /// (one for `.pack`, one for `.idx`) regardless of blob count —
332    /// vs. N×fsync if each blob were written loose. Used by the
333    /// snapshot hot path; called at the end of the tree walk with
334    /// every new blob accumulated in memory.
335    ///
336    /// Skips blobs already in the store (whether loose or packed) so
337    /// re-snapshotting an unchanged worktree doesn't churn the pack
338    /// directory. With every blob already known, this is a no-op.
339    pub(super) fn put_blobs_packed_impl(&self, blobs: Vec<(ContentHash, Vec<u8>)>) -> Result<()> {
340        if blobs.is_empty() {
341            return Ok(());
342        }
343        // Snapshot-time pack: skip the sliding-window delta search.
344        // It's a CPU win on similar-content files (the GC packer
345        // benefits) but for a single snapshot the inputs are
346        // unrelated content (random binaries, small text, etc.) and
347        // every pair-wise delta estimate runs across the full
348        // payloads — for 16×4MB blobs that's tens of seconds of
349        // hashing for ~zero compression benefit. GC's
350        // `pack_objects_impl` keeps the full delta search; this
351        // path only optimizes durability + write throughput.
352        let mut compression = self.compression;
353        if !self.snapshot_delta_search {
354            compression.max_delta_size = 0;
355        }
356        let mut builder = PackBuilder::new(compression);
357        let mut staged: Vec<(ContentHash, Vec<u8>)> = Vec::with_capacity(blobs.len());
358        for (hash, data) in blobs {
359            if ObjectStore::has_blob_locally(self, &hash)? {
360                continue;
361            }
362            staged.push((hash, data.clone()));
363            builder.add(hash, PackObjectType::Blob, data);
364        }
365        if staged.is_empty() {
366            return Ok(());
367        }
368        let (pack_data, index_data, _stats) = builder.build()?;
369
370        // Install the pack files. `install_pack_files` clears the
371        // recent-objects caches because a generic pack install (e.g.
372        // received over the network) might shadow loose objects we
373        // didn't write. For our locally-built pack we know exactly
374        // what we just installed, so we re-populate `recent_blobs`
375        // with the staged contents immediately afterwards. Without
376        // this the snapshot hot path takes a cache miss on every
377        // blob it just wrote, and `seed_large_repository` style
378        // benchmarks that snapshot-many-times-in-a-loop end up
379        // re-reading every parent state from disk between
380        // iterations.
381        self.install_pack_files(&pack_data, &index_data)?;
382        if let Ok(mut cache) = self.recent_blobs.write() {
383            for (hash, data) in staged {
384                cache.insert(hash, crate::object::Blob::from_slice(&data));
385            }
386        }
387        Ok(())
388    }
389
390    /// Consolidate the object store into a single pack.
391    ///
392    /// GC must *shrink* the set of places a reader has to look, not grow
393    /// it. The naive "pack the loose objects into a fresh pack" strategy
394    /// regressed read performance badly: every `maintenance gc` minted a
395    /// brand-new pack *alongside* the existing pack(s) and (by default)
396    /// left the now-redundant loose copies in place. The result was an
397    /// object store with strictly MORE sources to search — loose objects
398    /// plus an ever-growing fleet of packs — and `PackManager::get_object`
399    /// probes every pack linearly, so each extra pack roughly doubled the
400    /// cost of the object lookups that `status`/`diff`/verification do.
401    ///
402    /// This implementation does a true repack: it folds every object
403    /// already living in a pack *together with* the loose blobs and trees
404    /// into one new consolidated pack, installs it, and then deletes the
405    /// superseded packs. Combined with the caller's
406    /// `prune_loose_objects`, the store ends a GC with exactly one pack
407    /// and no loose duplicates — strictly fewer read sources than it
408    /// started with. Running GC again over an already-consolidated store
409    /// is a no-op (nothing loose, one pack already covers everything).
410    ///
411    pub(super) fn pack_objects_impl(&self, delta_search: bool) -> Result<(u64, u64)> {
412        // Serialize every source-pack-retiring path with background repack,
413        // including callers in another process. Ordinary immutable pack
414        // installs remain concurrent and are preserved at scheduler cutover.
415        let _repack_lock = super::repack::acquire_repack_lock_blocking(&packs_dir(&self.root))?;
416        let loose_blobs = list_hashes_from_dir(&blobs_dir(&self.root))?;
417        let loose_trees = list_hashes_from_dir(&trees_dir(&self.root))?;
418
419        // Snapshot what the existing packs already hold, plus the file
420        // paths we'll retire once the consolidated pack is installed.
421        let (existing_ids, old_pack_files, commit_artifact_ids) = {
422            let manager = self.pack_manager().read().map_err(|_| {
423                HeddleError::Config("Failed to acquire pack manager lock".to_string())
424            })?;
425            let ids = manager.list_all_ids()?;
426            let commit_artifact_ids = manager
427                .snapshot_commit_descriptors()?
428                .into_iter()
429                .map(|descriptor| descriptor.artifact.id())
430                .collect::<Vec<_>>();
431            let files: Vec<(std::path::PathBuf, std::path::PathBuf)> = manager
432                .pack_file_paths()
433                .into_iter()
434                .map(|(pack, index)| (pack.to_path_buf(), index.to_path_buf()))
435                .collect();
436            (ids, files, commit_artifact_ids)
437        };
438
439        // Nothing loose and at most one pack already — the store is
440        // already consolidated; don't churn a fresh identical pack.
441        if loose_blobs.is_empty() && loose_trees.is_empty() && old_pack_files.len() <= 1 {
442            return Ok((0, 0));
443        }
444
445        // Consolidation packs every object that's already packed plus the
446        // loose ones. The default path skips the sliding-window delta search
447        // to keep foreground GC latency bounded: it searches the full payloads
448        // of every object and can turn a seconds-long consolidation into
449        // minutes. The caller resolves the repository's GC policy and the
450        // `--aggressive` override into the `delta_search` argument. This
451        // mirrors the snapshot hot path, whose policy is held by the store.
452        let mut compression = self.compression;
453        if !delta_search {
454            compression.max_delta_size = 0;
455        }
456        let mut builder = PackBuilder::new(compression);
457        let loose_tree_set: std::collections::HashSet<ContentHash> =
458            loose_trees.iter().copied().collect();
459        let mut seen: std::collections::HashSet<crate::store::pack::PackObjectId> =
460            std::collections::HashSet::new();
461
462        // 1. Carry forward everything already in a pack so the old packs
463        //    can be retired. `get_object` resolves the body + type for
464        //    any id (blob/tree/state/action), and `add_id` preserves
465        //    content-addressed state objects.
466        for id in existing_ids {
467            if !seen.insert(id) {
468                continue;
469            }
470            let obj_type = {
471                let manager = self.pack_manager().read().map_err(|_| {
472                    HeddleError::Config("Failed to acquire pack manager lock".to_string())
473                })?;
474                manager.get_object(&id)?
475            };
476            if let Some((obj_type, mut data)) = obj_type {
477                if let crate::store::pack::PackObjectId::Hash(hash) = id
478                    && obj_type == PackObjectType::Tree
479                    && loose_tree_set.contains(&hash)
480                    && let Some(loose_data) = ObjectStore::get_tree_serialized(self, &hash)?
481                {
482                    data = loose_data;
483                }
484                builder.add_id(id, obj_type, data);
485            }
486        }
487
488        // 2. Fold in the loose blobs and trees. Skip any whose hash is
489        //    already covered by a carried-forward pack entry.
490        for hash in &loose_blobs {
491            let id = crate::store::pack::PackObjectId::Hash(*hash);
492            if seen.contains(&id) {
493                continue;
494            }
495            if let Some(blob) = ObjectStore::get_blob(self, hash)? {
496                seen.insert(id);
497                builder.add(*hash, PackObjectType::Blob, blob.content().to_vec());
498            }
499        }
500        for hash in &loose_trees {
501            let id = crate::store::pack::PackObjectId::Hash(*hash);
502            if seen.contains(&id) {
503                continue;
504            }
505            if let Some(tree) = ObjectStore::get_tree(self, hash)? {
506                let data = rmp_serde::to_vec(&tree)?;
507                seen.insert(id);
508                builder.add(*hash, PackObjectType::Tree, data);
509            }
510        }
511
512        if seen.is_empty() {
513            return Ok((0, 0));
514        }
515
516        let (pack_data, index_data, stats) = builder.build()?;
517        let new_pack_name = blake3::hash(&pack_data).to_hex();
518        if commit_artifact_ids.is_empty() {
519            self.install_pack_files(&pack_data, &index_data)?;
520        } else {
521            super::pack_install_journal::install_snapshot_pack_bytes_with_commit_markers(
522                &packs_dir(&self.root),
523                pack_data,
524                index_data,
525                &commit_artifact_ids,
526            )?;
527            self.reload_packs()?;
528        }
529        // GC packs *replace* loose objects (followed by
530        // `prune_loose_objects`). Bust the recent-objects caches so
531        // a subsequent get_* doesn't return a stale `Blob`/`Tree`
532        // pointing at a path we're about to delete. The snapshot hot
533        // path doesn't go through here — it calls
534        // `install_pack_files` directly via `put_blobs_packed_impl`,
535        // which keeps its caches warm.
536        self.clear_recent_object_caches();
537
538        // Retire the superseded packs now that the consolidated pack is
539        // durably installed and every object they held has been carried
540        // forward. The consolidated pack is content-addressed, so if it
541        // happened to hash-collide with an old pack (a store that was
542        // already a single consolidated pack) that file is excluded here.
543        // Stack hex digest; compare as &str — no format!/String intermediate.
544        for (pack_path, index_path) in &old_pack_files {
545            let is_new_pack = pack_path
546                .file_stem()
547                .and_then(|stem| stem.to_str())
548                .map(|stem| stem == new_pack_name.as_str())
549                .unwrap_or(false);
550            if is_new_pack {
551                continue;
552            }
553            remove_file_ignore_missing(pack_path)?;
554            remove_file_ignore_missing(index_path)?;
555            for artifact_id in &commit_artifact_ids {
556                remove_file_ignore_missing(&snapshot_commit_marker_path(pack_path, artifact_id))?;
557            }
558        }
559        // Retiring source packs requires a full reload of the pack list.
560        self.reload_packs()?;
561        self.clear_recent_object_caches();
562
563        let saved = stats.total_uncompressed - stats.total_compressed;
564        Ok((stats.object_count, saved))
565    }
566
567    pub(super) fn install_pack_files(&self, pack_data: &[u8], index_data: &[u8]) -> Result<()> {
568        let packs = packs_dir(&self.root);
569        // L8 A+: durable staging + intent journal for in-memory pack install
570        // (same crash-safety as install_pack_files_streaming).
571        // Design: docs/program/L8_PACK_INSTALL_JOURNAL.md
572        let _pack_name = super::pack_install_journal::install_pack_bytes_journaled(
573            &packs, pack_data, index_data,
574        )?;
575        // Pack manager picks up the new files. We do *not* clear the
576        // recent-object caches here — every caller that follows this
577        // with a destructive prune is responsible for clearing them
578        // explicitly. Snapshot installs rely on cache stickiness to
579        // keep tight snapshot loops fast (see
580        // `put_blobs_packed_impl`).
581        self.reload_packs()?;
582        Ok(())
583    }
584
585    /// Move a pack and its index already on disk into the store's
586    /// pack directory, computing the pack's content-hash by streaming
587    /// the file (constant memory regardless of pack size). Pairs with
588    /// `StreamingPackBuilder`: pack data, the index, *and* this
589    /// installation step never load the full pack or index into
590    /// memory.
591    ///
592    /// Sources are staged then published via the L8 A+ install journal
593    /// ([`super::pack_install_journal`]): durable staging + intent, then
594    /// pack/index publish with crash recovery on reload.
595    pub(super) fn install_pack_files_streaming(
596        &self,
597        src_pack_path: &std::path::Path,
598        src_index_path: &std::path::Path,
599    ) -> Result<()> {
600        use std::io::Read;
601
602        let packs = packs_dir(&self.root);
603        crate::fs_atomic::create_dir_all_durable(&packs)?;
604
605        // Stream-hash the pack file to derive its name. 64 KiB chunks
606        // keep the hasher's working set tiny.
607        let mut hasher = blake3::Hasher::new();
608        let mut file = fs::File::open(src_pack_path)?;
609        let mut buf = vec![0u8; 64 * 1024];
610        loop {
611            let n = file.read(&mut buf)?;
612            if n == 0 {
613                break;
614            }
615            hasher.update(&buf[..n]);
616        }
617        drop(file);
618        // Native digest for potential callers; hex String only for the journal
619        // path/name boundary (filenames + intent JSON).
620        let pack_hash = hasher.finalize();
621        let pack_name = pack_hash.to_hex().to_string();
622
623        // L8 A+: durable staging + intent journal, then pack/index publish.
624        // Recovery on reload finishes or aborts incomplete installs.
625        // Design: docs/program/L8_PACK_INSTALL_JOURNAL.md
626        super::pack_install_journal::install_pack_files_journaled(
627            &packs,
628            src_pack_path,
629            src_index_path,
630            &pack_name,
631        )?;
632
633        self.clear_recent_object_caches();
634        self.reload_packs()?;
635        Ok(())
636    }
637
638    /// Remove L8 orphan packs (`.pack` without `.idx`) from this store.
639    pub fn prune_unpaired_packs(&self) -> Result<(u64, u64)> {
640        let packs = packs_dir(&self.root);
641        Ok(prune_unpaired_pack_files(&packs)?)
642    }
643
644    pub(super) fn prune_loose_objects_impl(&self) -> Result<(u64, u64)> {
645        let mut removed = 0u64;
646        let mut bytes_freed = 0u64;
647
648        let blobs = list_hashes_from_dir(&blobs_dir(&self.root))?;
649        let trees = list_hashes_from_dir(&trees_dir(&self.root))?;
650        let states = list_state_ids_from_dir(&states_dir(&self.root))?;
651
652        let pack_manager = self
653            .pack_manager()
654            .read()
655            .map_err(|_| HeddleError::Config("Failed to acquire pack manager lock".to_string()))?;
656
657        for hash in &blobs {
658            if pack_manager.get_hashed_object(hash)?.is_some() {
659                let path = hash_path(&blobs_dir(&self.root), hash);
660                if let Some(bytes) = remove_file_counted(&path)? {
661                    bytes_freed = bytes_freed.saturating_add(bytes);
662                    removed += 1;
663                }
664            }
665        }
666
667        for hash in &trees {
668            let Some((obj_type, packed_data)) = pack_manager.get_hashed_object(hash)? else {
669                continue;
670            };
671            if obj_type != PackObjectType::Tree {
672                continue;
673            }
674            let path = hash_path(&trees_dir(&self.root), hash);
675            let Some(loose_data) = read_file_bytes(&path)? else {
676                continue;
677            };
678            let loose_tree = codec::decode_tree(loose_data.as_slice())?;
679            let found = loose_tree.hash();
680            if found != *hash {
681                return Err(HeddleError::Corruption {
682                    expected: *hash,
683                    found,
684                });
685            }
686            // A loose current tree can intentionally shadow an older packed
687            // schema at the same semantic hash. Preserve that migration copy
688            // until consolidation replaces the legacy body.
689            let Ok(packed_tree) = codec::decode_tree_serialized(&packed_data) else {
690                continue;
691            };
692            let packed_found = packed_tree.hash();
693            if packed_found != *hash {
694                return Err(HeddleError::Corruption {
695                    expected: *hash,
696                    found: packed_found,
697                });
698            }
699            if packed_tree == loose_tree
700                && let Some(bytes) = remove_file_counted(&path)?
701            {
702                bytes_freed = bytes_freed.saturating_add(bytes);
703                removed += 1;
704            }
705        }
706
707        for id in &states {
708            let Some((obj_type, packed_data)) =
709                pack_manager.get_object(&PackObjectId::StateId(*id))?
710            else {
711                continue;
712            };
713            if obj_type != PackObjectType::State {
714                continue;
715            }
716            let path = state_path(&self.root, id);
717            let Some(loose_data) = read_file_bytes(&path)? else {
718                continue;
719            };
720            let loose_state = codec::decode_state(loose_data.as_slice())?;
721            let packed_state = validate_state_serialized(&packed_data, *id)?;
722            if loose_state.id() != *id {
723                return Err(HeddleError::InvalidObject(format!(
724                    "loose state id mismatch while pruning: expected {id}, computed {}",
725                    loose_state.id()
726                )));
727            }
728            if packed_state == loose_state
729                && let Some(bytes) = remove_file_counted(&path)?
730            {
731                bytes_freed = bytes_freed.saturating_add(bytes);
732                removed += 1;
733            }
734        }
735
736        Ok((removed, bytes_freed))
737    }
738}
739
740#[cfg(test)]
741mod unpaired_pack_tests {
742    use std::fs;
743
744    use super::{list_unpaired_pack_files, prune_unpaired_pack_files};
745
746    #[test]
747    fn list_and_prune_unpaired_packs() {
748        let dir = tempfile::tempdir().unwrap();
749        let packs = dir.path();
750        fs::write(packs.join("aaa.pack"), b"pack-only").unwrap();
751        fs::write(packs.join("bbb.pack"), b"paired-pack").unwrap();
752        fs::write(packs.join("bbb.idx"), b"paired-idx").unwrap();
753        fs::write(packs.join("ccc.idx"), b"index-only").unwrap();
754
755        let listed = list_unpaired_pack_files(packs).unwrap();
756        assert_eq!(listed.len(), 1);
757        assert!(listed[0].ends_with("aaa.pack"));
758
759        let (removed, bytes) = prune_unpaired_pack_files(packs).unwrap();
760        assert_eq!(removed, 1);
761        assert_eq!(bytes, b"pack-only".len() as u64);
762        assert!(!packs.join("aaa.pack").exists());
763        assert!(packs.join("bbb.pack").exists());
764        assert!(packs.join("bbb.idx").exists());
765        assert!(packs.join("ccc.idx").exists());
766        assert!(list_unpaired_pack_files(packs).unwrap().is_empty());
767    }
768
769    #[test]
770    fn missing_packs_dir_is_empty() {
771        let dir = tempfile::tempdir().unwrap();
772        let missing = dir.path().join("nope");
773        assert!(list_unpaired_pack_files(&missing).unwrap().is_empty());
774        assert_eq!(prune_unpaired_pack_files(&missing).unwrap(), (0, 0));
775    }
776}