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 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 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 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 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 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 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 pub(super) fn pack_objects_impl(&self, delta_search: bool) -> Result<(u64, u64)> {
412 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 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 if loose_blobs.is_empty() && loose_trees.is_empty() && old_pack_files.len() <= 1 {
442 return Ok((0, 0));
443 }
444
445 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 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 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 self.clear_recent_object_caches();
537
538 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 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 let _pack_name = super::pack_install_journal::install_pack_bytes_journaled(
573 &packs, pack_data, index_data,
574 )?;
575 self.reload_packs()?;
582 Ok(())
583 }
584
585 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 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 let pack_hash = hasher.finalize();
621 let pack_name = pack_hash.to_hex().to_string();
622
623 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 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 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}