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_locally(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 pub(super) fn pack_objects_impl(&self, aggressive: bool) -> Result<(u64, u64)> {
158 let loose_blobs = list_hashes_from_dir(&blobs_dir(&self.root))?;
159 let loose_trees = list_hashes_from_dir(&trees_dir(&self.root))?;
160
161 // Snapshot what the existing packs already hold, plus the file
162 // paths we'll retire once the consolidated pack is installed.
163 let (existing_ids, old_pack_files) = {
164 let manager = self.pack_manager().read().map_err(|_| {
165 HeddleError::Config("Failed to acquire pack manager lock".to_string())
166 })?;
167 let ids = manager.list_all_ids()?;
168 let files: Vec<(std::path::PathBuf, std::path::PathBuf)> = manager
169 .pack_file_paths()
170 .into_iter()
171 .map(|(pack, index)| (pack.to_path_buf(), index.to_path_buf()))
172 .collect();
173 (ids, files)
174 };
175
176 // Nothing loose and at most one pack already — the store is
177 // already consolidated; don't churn a fresh identical pack.
178 if loose_blobs.is_empty() && loose_trees.is_empty() && old_pack_files.len() <= 1 {
179 return Ok((0, 0));
180 }
181
182 // Consolidation packs every object that's already packed plus the
183 // loose ones. The default path SKIPS the sliding-window delta
184 // search: it runs across the full payloads of every object and on
185 // a large native store (tens of MB across thousands of objects)
186 // costs minutes for a near-zero size win, because the carried-
187 // forward objects are already zstd-compressed. `--aggressive`
188 // opts back into the full delta search for the rare "shrink the
189 // pack at all costs" case. This mirrors the snapshot hot path
190 // (`put_blobs_packed_impl`), which disables delta for the same
191 // reason.
192 let mut compression = self.compression;
193 if !aggressive {
194 compression.max_delta_size = 0;
195 }
196 let mut builder = PackBuilder::new(compression);
197 let loose_tree_set: std::collections::HashSet<ContentHash> =
198 loose_trees.iter().copied().collect();
199 let mut seen: std::collections::HashSet<crate::store::pack::PackObjectId> =
200 std::collections::HashSet::new();
201
202 // 1. Carry forward everything already in a pack so the old packs
203 // can be retired. `get_object` resolves the body + type for
204 // any id (blob/tree/state/action), and `add_id` preserves
205 // content-addressed state objects.
206 for id in existing_ids {
207 if !seen.insert(id) {
208 continue;
209 }
210 let obj_type = {
211 let manager = self.pack_manager().read().map_err(|_| {
212 HeddleError::Config("Failed to acquire pack manager lock".to_string())
213 })?;
214 manager.get_object(&id)?
215 };
216 if let Some((obj_type, mut data)) = obj_type {
217 if let crate::store::pack::PackObjectId::Hash(hash) = id
218 && obj_type == PackObjectType::Tree
219 && loose_tree_set.contains(&hash)
220 && let Some(loose_data) = ObjectStore::get_tree_serialized(self, &hash)?
221 {
222 data = loose_data;
223 }
224 builder.add_id(id, obj_type, data);
225 }
226 }
227
228 // 2. Fold in the loose blobs and trees. Skip any whose hash is
229 // already covered by a carried-forward pack entry.
230 for hash in &loose_blobs {
231 let id = crate::store::pack::PackObjectId::Hash(*hash);
232 if seen.contains(&id) {
233 continue;
234 }
235 if let Some(blob) = ObjectStore::get_blob(self, hash)? {
236 seen.insert(id);
237 builder.add(*hash, PackObjectType::Blob, blob.content().to_vec());
238 }
239 }
240 for hash in &loose_trees {
241 let id = crate::store::pack::PackObjectId::Hash(*hash);
242 if seen.contains(&id) {
243 continue;
244 }
245 if let Some(tree) = ObjectStore::get_tree(self, hash)? {
246 let data = rmp_serde::to_vec(&tree)?;
247 seen.insert(id);
248 builder.add(*hash, PackObjectType::Tree, data);
249 }
250 }
251
252 if seen.is_empty() {
253 return Ok((0, 0));
254 }
255
256 let (pack_data, index_data, stats) = builder.build()?;
257 self.install_pack_files(&pack_data, &index_data)?;
258 // GC packs *replace* loose objects (followed by
259 // `prune_loose_objects`). Bust the recent-objects caches so
260 // a subsequent get_* doesn't return a stale `Blob`/`Tree`
261 // pointing at a path we're about to delete. The snapshot hot
262 // path doesn't go through here — it calls
263 // `install_pack_files` directly via `put_blobs_packed_impl`,
264 // which keeps its caches warm.
265 self.clear_recent_object_caches();
266
267 // Retire the superseded packs now that the consolidated pack is
268 // durably installed and every object they held has been carried
269 // forward. The consolidated pack is content-addressed, so if it
270 // happened to hash-collide with an old pack (a store that was
271 // already a single consolidated pack) that file is excluded here.
272 // Stack hex digest; compare as &str — no format!/String intermediate.
273 let new_pack_name = blake3::hash(&pack_data).to_hex();
274 for (pack_path, index_path) in &old_pack_files {
275 let is_new_pack = pack_path
276 .file_stem()
277 .and_then(|stem| stem.to_str())
278 .map(|stem| stem == new_pack_name.as_str())
279 .unwrap_or(false);
280 if is_new_pack {
281 continue;
282 }
283 remove_file_ignore_missing(pack_path)?;
284 remove_file_ignore_missing(index_path)?;
285 }
286 // Disk shrank (packs removed), so `reload_if_disk_grew` would
287 // not pick this up — force a full reload of the pack list.
288 self.reload_packs()?;
289 self.clear_recent_object_caches();
290
291 let saved = stats.total_uncompressed - stats.total_compressed;
292 Ok((stats.object_count, saved))
293 }
294
295 pub(super) fn install_pack_files(&self, pack_data: &[u8], index_data: &[u8]) -> Result<()> {
296 let packs = packs_dir(&self.root);
297 // L8 A+: durable staging + intent journal for in-memory pack install
298 // (same crash-safety as install_pack_files_streaming).
299 // Design: docs/program/L8_PACK_INSTALL_JOURNAL.md
300 let _pack_name = super::pack_install_journal::install_pack_bytes_journaled(
301 &packs, pack_data, index_data,
302 )?;
303 // Pack manager picks up the new files. We do *not* clear the
304 // recent-object caches here — every caller that follows this
305 // with a destructive prune is responsible for clearing them
306 // explicitly. Snapshot installs rely on cache stickiness to
307 // keep tight snapshot loops fast (see
308 // `put_blobs_packed_impl`).
309 self.reload_packs()?;
310 Ok(())
311 }
312
313 /// Move a pack and its index already on disk into the store's
314 /// pack directory, computing the pack's content-hash by streaming
315 /// the file (constant memory regardless of pack size). Pairs with
316 /// `StreamingPackBuilder`: pack data, the index, *and* this
317 /// installation step never load the full pack or index into
318 /// memory.
319 ///
320 /// Sources are staged then published via the L8 A+ install journal
321 /// ([`super::pack_install_journal`]): durable staging + intent, then
322 /// pack/index publish with crash recovery on reload.
323 pub(super) fn install_pack_files_streaming(
324 &self,
325 src_pack_path: &std::path::Path,
326 src_index_path: &std::path::Path,
327 ) -> Result<()> {
328 use std::io::Read;
329
330 let packs = packs_dir(&self.root);
331 crate::fs_atomic::create_dir_all_durable(&packs)?;
332
333 // Stream-hash the pack file to derive its name. 64 KiB chunks
334 // keep the hasher's working set tiny.
335 let mut hasher = blake3::Hasher::new();
336 let mut file = fs::File::open(src_pack_path)?;
337 let mut buf = vec![0u8; 64 * 1024];
338 loop {
339 let n = file.read(&mut buf)?;
340 if n == 0 {
341 break;
342 }
343 hasher.update(&buf[..n]);
344 }
345 drop(file);
346 // Native digest for potential callers; hex String only for the journal
347 // path/name boundary (filenames + intent JSON).
348 let pack_hash = hasher.finalize();
349 let pack_name = pack_hash.to_hex().to_string();
350
351 // L8 A+: durable staging + intent journal, then pack/index publish.
352 // Recovery on reload finishes or aborts incomplete installs.
353 // Design: docs/program/L8_PACK_INSTALL_JOURNAL.md
354 super::pack_install_journal::install_pack_files_journaled(
355 &packs,
356 src_pack_path,
357 src_index_path,
358 &pack_name,
359 )?;
360
361 self.clear_recent_object_caches();
362 self.reload_packs()?;
363 Ok(())
364 }
365
366 /// Remove L8 orphan packs (`.pack` without `.idx`) from this store.
367 pub fn prune_unpaired_packs(&self) -> Result<(u64, u64)> {
368 let packs = packs_dir(&self.root);
369 Ok(prune_unpaired_pack_files(&packs)?)
370 }
371
372 pub(super) fn prune_loose_objects_impl(&self) -> Result<(u64, u64)> {
373 let mut removed = 0u64;
374 let mut bytes_freed = 0u64;
375
376 let blobs = list_hashes_from_dir(&blobs_dir(&self.root))?;
377 let trees = list_hashes_from_dir(&trees_dir(&self.root))?;
378
379 let pack_manager = self
380 .pack_manager()
381 .read()
382 .map_err(|_| HeddleError::Config("Failed to acquire pack manager lock".to_string()))?;
383
384 for hash in &blobs {
385 if pack_manager.get_hashed_object(hash)?.is_some() {
386 let path = hash_path(&blobs_dir(&self.root), hash);
387 match fs::metadata(&path) {
388 Ok(metadata) => match fs::remove_file(&path) {
389 Ok(()) => {
390 bytes_freed += metadata.len();
391 removed += 1;
392 }
393 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
394 Err(e) => return Err(HeddleError::from(e)),
395 },
396 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
397 Err(e) => return Err(HeddleError::from(e)),
398 }
399 }
400 }
401
402 for hash in &trees {
403 let Some((obj_type, packed_data)) = pack_manager.get_hashed_object(hash)? else {
404 continue;
405 };
406 if obj_type != PackObjectType::Tree {
407 continue;
408 }
409 let path = hash_path(&trees_dir(&self.root), hash);
410 let Some(loose_data) = read_file_bytes(&path)? else {
411 continue;
412 };
413 let loose_data = codec::decode_tree_body(loose_data.as_slice())?;
414 if packed_data == loose_data {
415 match fs::metadata(&path) {
416 Ok(metadata) => match fs::remove_file(&path) {
417 Ok(()) => {
418 bytes_freed += metadata.len();
419 removed += 1;
420 }
421 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
422 Err(e) => return Err(HeddleError::from(e)),
423 },
424 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
425 Err(e) => return Err(HeddleError::from(e)),
426 }
427 }
428 }
429
430 Ok((removed, bytes_freed))
431 }
432}
433
434#[cfg(test)]
435mod unpaired_pack_tests {
436 use std::fs;
437
438 use super::{list_unpaired_pack_files, prune_unpaired_pack_files};
439
440 #[test]
441 fn list_and_prune_unpaired_packs() {
442 let dir = tempfile::tempdir().unwrap();
443 let packs = dir.path();
444 fs::write(packs.join("aaa.pack"), b"pack-only").unwrap();
445 fs::write(packs.join("bbb.pack"), b"paired-pack").unwrap();
446 fs::write(packs.join("bbb.idx"), b"paired-idx").unwrap();
447 fs::write(packs.join("ccc.idx"), b"index-only").unwrap();
448
449 let listed = list_unpaired_pack_files(packs).unwrap();
450 assert_eq!(listed.len(), 1);
451 assert!(listed[0].ends_with("aaa.pack"));
452
453 let (removed, bytes) = prune_unpaired_pack_files(packs).unwrap();
454 assert_eq!(removed, 1);
455 assert_eq!(bytes, b"pack-only".len() as u64);
456 assert!(!packs.join("aaa.pack").exists());
457 assert!(packs.join("bbb.pack").exists());
458 assert!(packs.join("bbb.idx").exists());
459 assert!(packs.join("ccc.idx").exists());
460 assert!(list_unpaired_pack_files(packs).unwrap().is_empty());
461 }
462
463 #[test]
464 fn missing_packs_dir_is_empty() {
465 let dir = tempfile::tempdir().unwrap();
466 let missing = dir.path().join("nope");
467 assert!(list_unpaired_pack_files(&missing).unwrap().is_empty());
468 assert_eq!(prune_unpaired_pack_files(&missing).unwrap(), (0, 0));
469 }
470}