1use 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
28pub(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
54pub(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 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 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 #[doc(hidden)]
152 pub fn put_committed_snapshot_objects_packed(
153 &self,
154 blobs: Vec<(ContentHash, Vec<u8>)>,
155 trees: Vec<Tree>,
156 tree: &Tree,
157 state: &State,
158 attachments: Vec<StateAttachment>,
159 artifact: SnapshotCommitArtifact,
160 ) -> Result<SnapshotCommitDescriptor> {
161 self.put_snapshot_objects_packed_impl(
162 blobs,
163 trees,
164 tree,
165 state,
166 attachments,
167 Some(artifact),
168 )?
169 .ok_or_else(|| {
170 HeddleError::InvalidObject(
171 "committed snapshot pack did not expose its artifact descriptor".to_string(),
172 )
173 })
174 }
175
176 pub(super) fn put_snapshot_objects_packed_impl(
181 &self,
182 blobs: Vec<(ContentHash, Vec<u8>)>,
183 trees: Vec<Tree>,
184 tree: &Tree,
185 state: &State,
186 attachments: Vec<StateAttachment>,
187 commit_artifact: Option<SnapshotCommitArtifact>,
188 ) -> Result<Option<SnapshotCommitDescriptor>> {
189 let state_was_present = if commit_artifact.is_some() {
194 false
195 } else {
196 <Self as ObjectStore>::has_state(self, &state.id())?
197 };
198 let mut compression = self.compression;
199 if !self.snapshot_delta_search {
200 compression.max_delta_size = 0;
201 }
202 let mut builder = PackBuilder::new(compression);
203
204 for (hash, data) in blobs {
205 if commit_artifact.is_none() && ObjectStore::has_blob_locally(self, &hash)? {
206 continue;
207 }
208 builder.add(hash, PackObjectType::Blob, data);
209 }
210
211 let tree_hash = tree.hash();
212 let mut staged_trees = Vec::with_capacity(trees.len() + 1);
213 let mut seen_trees = std::collections::HashSet::with_capacity(trees.len() + 1);
214 for authored_tree in trees {
215 let authored_hash = authored_tree.hash();
216 if seen_trees.insert(authored_hash)
217 && (commit_artifact.is_some()
218 || !ObjectStore::has_tree_locally(self, &authored_hash)?)
219 {
220 builder.add(
221 authored_hash,
222 PackObjectType::Tree,
223 authored_tree.encode_canonical()?,
224 );
225 staged_trees.push((authored_hash, authored_tree));
226 }
227 }
228 if (commit_artifact.is_some() || !ObjectStore::has_tree_locally(self, &tree_hash)?)
229 && seen_trees.insert(tree_hash)
230 {
231 builder.add(tree_hash, PackObjectType::Tree, tree.encode_canonical()?);
232 staged_trees.push((tree_hash, tree.clone()));
233 }
234
235 let state_id = state.id();
236 builder.add_id(
237 PackObjectId::StateId(state_id),
238 PackObjectType::State,
239 rmp_serde::to_vec_named(state)?,
240 );
241 let attachment_ids = attachments
242 .iter()
243 .map(|attachment| {
244 if attachment.state_id != state_id {
245 return Err(HeddleError::InvalidObject(
246 "snapshot attachment targets a different state".to_string(),
247 ));
248 }
249 let id = attachment.id();
250 builder.add(
251 *id.as_hash(),
252 PackObjectType::StateAttachment,
253 rmp_serde::to_vec_named(attachment)?,
254 );
255 Ok(id)
256 })
257 .collect::<Result<Vec<StateAttachmentId>>>()?;
258 let artifact_id = commit_artifact.as_ref().map(SnapshotCommitArtifact::id);
259 let artifact_bytes = commit_artifact
260 .as_ref()
261 .map(rmp_serde::to_vec_named)
262 .transpose()?;
263 if let Some(artifact) = &commit_artifact {
264 artifact.validate()?;
265 builder.add(
266 artifact.id(),
267 PackObjectType::SnapshotCommit,
268 artifact_bytes.clone().expect("artifact bytes are present"),
269 );
270 }
271
272 let (pack_data, index_data, _stats, retained_objects) =
273 builder.build_retaining_objects()?;
274 let packs = packs_dir(&self.root);
275 let installed_pack_name = if commit_artifact.is_some() {
276 super::pack_install_journal::install_committed_snapshot_pack_bytes(
277 &packs,
278 pack_data,
279 index_data,
280 artifact_id.expect("commit artifact id is present"),
281 artifact_bytes.expect("commit artifact bytes are present"),
282 )?
283 } else {
284 super::pack_install_journal::install_snapshot_pack_bytes(&packs, pack_data, index_data)?
285 };
286 {
287 let mut manager = self.pack_manager().write().map_err(|_| {
288 HeddleError::Config("Failed to acquire pack manager lock".to_string())
289 })?;
290 manager.add_pack(
291 packs.join(format!("{installed_pack_name}.pack")),
292 packs.join(format!("{installed_pack_name}.idx")),
293 )?;
294 }
295 self.materialize_packed_attachment_index(&state_id, &attachment_ids, state_was_present)?;
296
297 if let Ok(mut cache) = self.recent_blobs.write() {
298 for (id, object_type, data) in retained_objects {
299 if let (PackObjectId::Hash(hash), PackObjectType::Blob) = (id, object_type) {
300 cache.insert(hash, crate::object::Blob::new(data));
301 }
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 pub(super) fn put_blobs_packed_impl(&self, blobs: Vec<(ContentHash, Vec<u8>)>) -> Result<()> {
340 if blobs.is_empty() {
341 return Ok(());
342 }
343 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 added = 0usize;
358 for (hash, data) in blobs {
359 if ObjectStore::has_blob_locally(self, &hash)? {
360 continue;
361 }
362 builder.add(hash, PackObjectType::Blob, data);
363 added += 1;
364 }
365 if added == 0 {
366 return Ok(());
367 }
368 let (pack_data, index_data, _stats, retained_objects) =
369 builder.build_retaining_objects()?;
370
371 self.install_pack_files(&pack_data, &index_data)?;
376 if let Ok(mut cache) = self.recent_blobs.write() {
377 for (id, object_type, data) in retained_objects {
378 if let (PackObjectId::Hash(hash), PackObjectType::Blob) = (id, object_type) {
379 cache.insert(hash, crate::object::Blob::new(data));
380 }
381 }
382 }
383 Ok(())
384 }
385
386 pub(super) fn pack_objects_impl(&self, delta_search: bool) -> Result<(u64, u64)> {
408 let _repack_lock = super::repack::acquire_repack_lock_blocking(&packs_dir(&self.root))?;
412 let loose_blobs = list_hashes_from_dir(&blobs_dir(&self.root))?;
413 let loose_trees = list_hashes_from_dir(&trees_dir(&self.root))?;
414
415 let (existing_ids, old_pack_files, commit_artifact_ids) = {
418 let manager = self.pack_manager().read().map_err(|_| {
419 HeddleError::Config("Failed to acquire pack manager lock".to_string())
420 })?;
421 let ids = manager.list_all_ids()?;
422 let commit_artifact_ids = manager
423 .snapshot_commit_descriptors()?
424 .into_iter()
425 .map(|descriptor| descriptor.artifact.id())
426 .collect::<Vec<_>>();
427 let files: Vec<(std::path::PathBuf, std::path::PathBuf)> = manager
428 .pack_file_paths()
429 .into_iter()
430 .map(|(pack, index)| (pack.to_path_buf(), index.to_path_buf()))
431 .collect();
432 (ids, files, commit_artifact_ids)
433 };
434
435 if loose_blobs.is_empty() && loose_trees.is_empty() && old_pack_files.len() <= 1 {
438 return Ok((0, 0));
439 }
440
441 let mut compression = self.compression;
449 if !delta_search {
450 compression.max_delta_size = 0;
451 }
452 let mut builder = PackBuilder::new(compression);
453 let loose_tree_set: std::collections::HashSet<ContentHash> =
454 loose_trees.iter().copied().collect();
455 let mut seen: std::collections::HashSet<crate::store::pack::PackObjectId> =
456 std::collections::HashSet::new();
457
458 for id in existing_ids {
463 if !seen.insert(id) {
464 continue;
465 }
466 let obj_type = {
467 let manager = self.pack_manager().read().map_err(|_| {
468 HeddleError::Config("Failed to acquire pack manager lock".to_string())
469 })?;
470 manager.get_object(&id)?
471 };
472 if let Some((obj_type, mut data)) = obj_type {
473 if let crate::store::pack::PackObjectId::Hash(hash) = id
474 && obj_type == PackObjectType::Tree
475 && loose_tree_set.contains(&hash)
476 && let Some(loose_data) = ObjectStore::get_tree_serialized(self, &hash)?
477 {
478 data = loose_data;
479 }
480 builder.add_id(id, obj_type, data);
481 }
482 }
483
484 for hash in &loose_blobs {
487 let id = crate::store::pack::PackObjectId::Hash(*hash);
488 if seen.contains(&id) {
489 continue;
490 }
491 if let Some(blob) = ObjectStore::get_blob(self, hash)? {
492 seen.insert(id);
493 builder.add(*hash, PackObjectType::Blob, blob.content().to_vec());
494 }
495 }
496 for hash in &loose_trees {
497 let id = crate::store::pack::PackObjectId::Hash(*hash);
498 if seen.contains(&id) {
499 continue;
500 }
501 if let Some(tree) = ObjectStore::get_tree(self, hash)? {
502 let data = tree.encode_canonical()?;
503 seen.insert(id);
504 builder.add(*hash, PackObjectType::Tree, data);
505 }
506 }
507
508 if seen.is_empty() {
509 return Ok((0, 0));
510 }
511
512 let (pack_data, index_data, stats) = builder.build()?;
513 let new_pack_name = blake3::hash(&pack_data).to_hex();
514 if commit_artifact_ids.is_empty() {
515 self.install_pack_files(&pack_data, &index_data)?;
516 } else {
517 super::pack_install_journal::install_snapshot_pack_bytes_with_commit_markers(
518 &packs_dir(&self.root),
519 pack_data,
520 index_data,
521 &commit_artifact_ids,
522 )?;
523 self.reload_packs()?;
524 }
525 self.clear_recent_object_caches();
533
534 for (pack_path, index_path) in &old_pack_files {
541 let is_new_pack = pack_path
542 .file_stem()
543 .and_then(|stem| stem.to_str())
544 .map(|stem| stem == new_pack_name.as_str())
545 .unwrap_or(false);
546 if is_new_pack {
547 continue;
548 }
549 remove_file_ignore_missing(pack_path)?;
550 remove_file_ignore_missing(index_path)?;
551 for artifact_id in &commit_artifact_ids {
552 remove_file_ignore_missing(&snapshot_commit_marker_path(pack_path, artifact_id))?;
553 }
554 }
555 self.reload_packs()?;
557 self.clear_recent_object_caches();
558
559 let saved = stats.total_uncompressed - stats.total_compressed;
560 Ok((stats.object_count, saved))
561 }
562
563 pub(super) fn install_pack_files(&self, pack_data: &[u8], index_data: &[u8]) -> Result<()> {
564 let packs = packs_dir(&self.root);
565 let _pack_name = super::pack_install_journal::install_pack_bytes_journaled(
569 &packs, pack_data, index_data,
570 )?;
571 self.reload_packs()?;
578 Ok(())
579 }
580
581 pub(super) fn install_pack_files_streaming(
592 &self,
593 src_pack_path: &std::path::Path,
594 src_index_path: &std::path::Path,
595 ) -> Result<()> {
596 use std::io::Read;
597
598 let packs = packs_dir(&self.root);
599 crate::fs_atomic::create_dir_all_durable(&packs)?;
600
601 let mut hasher = blake3::Hasher::new();
604 let mut file = fs::File::open(src_pack_path)?;
605 let mut buf = vec![0u8; 64 * 1024];
606 loop {
607 let n = file.read(&mut buf)?;
608 if n == 0 {
609 break;
610 }
611 hasher.update(&buf[..n]);
612 }
613 drop(file);
614 let pack_hash = hasher.finalize();
617 let pack_name = pack_hash.to_hex().to_string();
618
619 super::pack_install_journal::install_pack_files_journaled(
623 &packs,
624 src_pack_path,
625 src_index_path,
626 &pack_name,
627 )?;
628
629 self.clear_recent_object_caches();
630 self.reload_packs()?;
631 Ok(())
632 }
633
634 pub fn prune_unpaired_packs(&self) -> Result<(u64, u64)> {
636 let packs = packs_dir(&self.root);
637 Ok(prune_unpaired_pack_files(&packs)?)
638 }
639
640 pub(super) fn prune_loose_objects_impl(&self) -> Result<(u64, u64)> {
641 let mut removed = 0u64;
642 let mut bytes_freed = 0u64;
643
644 let blobs = list_hashes_from_dir(&blobs_dir(&self.root))?;
645 let trees = list_hashes_from_dir(&trees_dir(&self.root))?;
646 let states = list_state_ids_from_dir(&states_dir(&self.root))?;
647
648 let pack_manager = self
649 .pack_manager()
650 .read()
651 .map_err(|_| HeddleError::Config("Failed to acquire pack manager lock".to_string()))?;
652
653 for hash in &blobs {
654 if pack_manager.get_hashed_object(hash)?.is_some() {
655 let path = hash_path(&blobs_dir(&self.root), hash);
656 if let Some(bytes) = remove_file_counted(&path)? {
657 bytes_freed = bytes_freed.saturating_add(bytes);
658 removed += 1;
659 }
660 }
661 }
662
663 for hash in &trees {
664 let Some((obj_type, packed_data)) = pack_manager.get_hashed_object(hash)? else {
665 continue;
666 };
667 if obj_type != PackObjectType::Tree {
668 continue;
669 }
670 let path = hash_path(&trees_dir(&self.root), hash);
671 let Some(loose_data) = read_file_bytes(&path)? else {
672 continue;
673 };
674 let loose_tree = codec::decode_tree(loose_data.as_slice())?;
675 let found = loose_tree.hash();
676 if found != *hash {
677 return Err(HeddleError::Corruption {
678 expected: *hash,
679 found,
680 });
681 }
682 let Ok(packed_tree) = codec::decode_tree_serialized(&packed_data) else {
686 continue;
687 };
688 let packed_found = packed_tree.hash();
689 if packed_found != *hash {
690 return Err(HeddleError::Corruption {
691 expected: *hash,
692 found: packed_found,
693 });
694 }
695 if packed_tree == loose_tree
696 && let Some(bytes) = remove_file_counted(&path)?
697 {
698 bytes_freed = bytes_freed.saturating_add(bytes);
699 removed += 1;
700 }
701 }
702
703 for id in &states {
704 let Some((obj_type, packed_data)) =
705 pack_manager.get_object(&PackObjectId::StateId(*id))?
706 else {
707 continue;
708 };
709 if obj_type != PackObjectType::State {
710 continue;
711 }
712 let path = state_path(&self.root, id);
713 let Some(loose_data) = read_file_bytes(&path)? else {
714 continue;
715 };
716 let loose_state = codec::decode_state(loose_data.as_slice())?;
717 let packed_state = validate_state_serialized(&packed_data, *id)?;
718 if !loose_state.accepts_stored_id(id) {
719 return Err(HeddleError::InvalidObject(format!(
720 "loose state id mismatch while pruning: expected {id}, computed {}",
721 loose_state.id()
722 )));
723 }
724 if packed_state == loose_state
725 && let Some(bytes) = remove_file_counted(&path)?
726 {
727 bytes_freed = bytes_freed.saturating_add(bytes);
728 removed += 1;
729 }
730 }
731
732 Ok((removed, bytes_freed))
733 }
734}
735
736#[cfg(test)]
737mod unpaired_pack_tests {
738 use std::fs;
739
740 use super::{list_unpaired_pack_files, prune_unpaired_pack_files};
741
742 #[test]
743 fn list_and_prune_unpaired_packs() {
744 let dir = tempfile::tempdir().unwrap();
745 let packs = dir.path();
746 fs::write(packs.join("aaa.pack"), b"pack-only").unwrap();
747 fs::write(packs.join("bbb.pack"), b"paired-pack").unwrap();
748 fs::write(packs.join("bbb.idx"), b"paired-idx").unwrap();
749 fs::write(packs.join("ccc.idx"), b"index-only").unwrap();
750
751 let listed = list_unpaired_pack_files(packs).unwrap();
752 assert_eq!(listed.len(), 1);
753 assert!(listed[0].ends_with("aaa.pack"));
754
755 let (removed, bytes) = prune_unpaired_pack_files(packs).unwrap();
756 assert_eq!(removed, 1);
757 assert_eq!(bytes, b"pack-only".len() as u64);
758 assert!(!packs.join("aaa.pack").exists());
759 assert!(packs.join("bbb.pack").exists());
760 assert!(packs.join("bbb.idx").exists());
761 assert!(packs.join("ccc.idx").exists());
762 assert!(list_unpaired_pack_files(packs).unwrap().is_empty());
763 }
764
765 #[test]
766 fn missing_packs_dir_is_empty() {
767 let dir = tempfile::tempdir().unwrap();
768 let missing = dir.path().join("nope");
769 assert!(list_unpaired_pack_files(&missing).unwrap().is_empty());
770 assert_eq!(prune_unpaired_pack_files(&missing).unwrap(), (0, 0));
771 }
772}