1use 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, State, StateAttachment, StateAttachmentId, Tree},
16 store::{
17 HeddleError, ObjectStore, Result, SnapshotCommitArtifact, SnapshotCommitDescriptor, codec,
18 pack::{ObjectType as PackObjectType, PackBuilder, PackObjectId},
19 snapshot_commit::snapshot_commit_marker_path,
20 },
21};
22
23pub(crate) fn list_unpaired_pack_files(packs_dir: &Path) -> std::io::Result<Vec<PathBuf>> {
30 if !packs_dir.exists() {
31 return Ok(Vec::new());
32 }
33 let mut unpaired = Vec::new();
34 for entry in fs::read_dir(packs_dir)? {
35 let entry = entry?;
36 let path = entry.path();
37 if path.extension().and_then(|e| e.to_str()) != Some("pack") {
38 continue;
39 }
40 let idx = path.with_extension("idx");
41 if !idx.exists() {
42 unpaired.push(path);
43 }
44 }
45 unpaired.sort();
46 Ok(unpaired)
47}
48
49pub(crate) fn prune_unpaired_pack_files(packs_dir: &Path) -> std::io::Result<(u64, u64)> {
54 let mut removed = 0u64;
55 let mut bytes_freed = 0u64;
56 for path in list_unpaired_pack_files(packs_dir)? {
57 let bytes = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
58 match fs::remove_file(&path) {
59 Ok(()) => {
60 removed += 1;
61 bytes_freed = bytes_freed.saturating_add(bytes);
62 }
63 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
64 Err(e) => return Err(e),
65 }
66 }
67 Ok((removed, bytes_freed))
68}
69
70fn remove_file_ignore_missing(path: &std::path::Path) -> Result<()> {
71 match fs::remove_file(path) {
72 Ok(()) => Ok(()),
73 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
74 Err(e) => Err(HeddleError::from(e)),
75 }
76}
77
78impl FsStore {
79 pub(crate) fn put_committed_snapshot_objects_packed_impl(
80 &self,
81 blobs: Vec<(ContentHash, Vec<u8>)>,
82 tree: &Tree,
83 state: &State,
84 attachments: Vec<StateAttachment>,
85 artifact: SnapshotCommitArtifact,
86 ) -> Result<SnapshotCommitDescriptor> {
87 self.put_snapshot_objects_packed_impl(blobs, tree, state, attachments, Some(artifact))?
88 .ok_or_else(|| {
89 HeddleError::InvalidObject(
90 "committed snapshot pack did not expose its artifact descriptor".to_string(),
91 )
92 })
93 }
94
95 pub(super) fn put_snapshot_objects_packed_impl(
100 &self,
101 blobs: Vec<(ContentHash, Vec<u8>)>,
102 tree: &Tree,
103 state: &State,
104 attachments: Vec<StateAttachment>,
105 commit_artifact: Option<SnapshotCommitArtifact>,
106 ) -> Result<Option<SnapshotCommitDescriptor>> {
107 let state_was_present = if commit_artifact.is_some() {
112 false
113 } else {
114 <Self as ObjectStore>::has_state(self, &state.id())?
115 };
116 let mut compression = self.compression;
117 compression.max_delta_size = 0;
118 let mut builder = PackBuilder::new(compression);
119 let mut staged_blobs = Vec::with_capacity(blobs.len());
120
121 for (hash, data) in blobs {
122 if commit_artifact.is_none() && ObjectStore::has_blob_locally(self, &hash)? {
123 continue;
124 }
125 staged_blobs.push((hash, data.clone()));
126 builder.add(hash, PackObjectType::Blob, data);
127 }
128
129 let tree_hash = tree.hash();
130 if commit_artifact.is_some() || !ObjectStore::has_tree_locally(self, &tree_hash)? {
131 builder.add(
132 tree_hash,
133 PackObjectType::Tree,
134 rmp_serde::to_vec_named(tree)?,
135 );
136 }
137
138 let state_id = state.id();
139 builder.add_id(
140 PackObjectId::StateId(state_id),
141 PackObjectType::State,
142 rmp_serde::to_vec_named(state)?,
143 );
144 let attachment_ids = attachments
145 .iter()
146 .map(|attachment| {
147 if attachment.state_id != state_id {
148 return Err(HeddleError::InvalidObject(
149 "snapshot attachment targets a different state".to_string(),
150 ));
151 }
152 let id = attachment.id();
153 builder.add(
154 *id.as_hash(),
155 PackObjectType::StateAttachment,
156 rmp_serde::to_vec_named(attachment)?,
157 );
158 Ok(id)
159 })
160 .collect::<Result<Vec<StateAttachmentId>>>()?;
161 let artifact_id = commit_artifact.as_ref().map(SnapshotCommitArtifact::id);
162 if let Some(artifact) = &commit_artifact {
163 artifact.validate()?;
164 builder.add(
165 artifact.id(),
166 PackObjectType::SnapshotCommit,
167 rmp_serde::to_vec_named(artifact)?,
168 );
169 }
170
171 let (pack_data, index_data, _stats) = builder.build()?;
172 let packs = packs_dir(&self.root);
173 let installed_pack_name = if commit_artifact.is_some() {
174 super::pack_install_journal::install_committed_snapshot_pack_bytes(
175 &packs,
176 pack_data,
177 index_data,
178 artifact_id.expect("commit artifact id is present"),
179 )?
180 } else {
181 super::pack_install_journal::install_snapshot_pack_bytes(&packs, pack_data, index_data)?
182 };
183 {
184 let mut manager = self.pack_manager().write().map_err(|_| {
185 HeddleError::Config("Failed to acquire pack manager lock".to_string())
186 })?;
187 manager.add_pack(
188 packs.join(format!("{installed_pack_name}.pack")),
189 packs.join(format!("{installed_pack_name}.idx")),
190 )?;
191 }
192 self.materialize_packed_attachment_index(&state_id, &attachment_ids, state_was_present)?;
193
194 if let Ok(mut cache) = self.recent_blobs.write() {
195 for (hash, data) in staged_blobs {
196 cache.insert(hash, crate::object::Blob::from_slice(&data));
197 }
198 }
199 if let Ok(mut cache) = self.recent_trees.write() {
200 cache.insert(tree_hash, tree.clone());
201 }
202 if let Ok(mut cache) = self.recent_states.write() {
203 let mut cached = state.clone();
204 cached.state_id = state_id;
205 cache.insert(state_id, cached);
206 }
207 let descriptor = if let Some(artifact_id) = artifact_id {
208 self.pack_manager()
209 .read()
210 .map_err(|_| {
211 HeddleError::Config("Failed to acquire pack manager lock".to_string())
212 })?
213 .snapshot_commit_descriptor_for_state(&state_id)?
214 .filter(|descriptor| descriptor.artifact.id() == artifact_id)
215 } else {
216 None
217 };
218 Ok(descriptor)
219 }
220
221 pub(super) fn put_blobs_packed_impl(&self, blobs: Vec<(ContentHash, Vec<u8>)>) -> Result<()> {
231 if blobs.is_empty() {
232 return Ok(());
233 }
234 let mut compression = self.compression;
244 compression.max_delta_size = 0;
245 let mut builder = PackBuilder::new(compression);
246 let mut staged: Vec<(ContentHash, Vec<u8>)> = Vec::with_capacity(blobs.len());
247 for (hash, data) in blobs {
248 if ObjectStore::has_blob_locally(self, &hash)? {
249 continue;
250 }
251 staged.push((hash, data.clone()));
252 builder.add(hash, PackObjectType::Blob, data);
253 }
254 if staged.is_empty() {
255 return Ok(());
256 }
257 let (pack_data, index_data, _stats) = builder.build()?;
258
259 self.install_pack_files(&pack_data, &index_data)?;
271 if let Ok(mut cache) = self.recent_blobs.write() {
272 for (hash, data) in staged {
273 cache.insert(hash, crate::object::Blob::from_slice(&data));
274 }
275 }
276 Ok(())
277 }
278
279 pub(super) fn pack_objects_impl(&self, aggressive: bool) -> Result<(u64, u64)> {
301 let loose_blobs = list_hashes_from_dir(&blobs_dir(&self.root))?;
302 let loose_trees = list_hashes_from_dir(&trees_dir(&self.root))?;
303
304 let (existing_ids, old_pack_files, commit_artifact_ids) = {
307 let manager = self.pack_manager().read().map_err(|_| {
308 HeddleError::Config("Failed to acquire pack manager lock".to_string())
309 })?;
310 let ids = manager.list_all_ids()?;
311 let commit_artifact_ids = manager
312 .snapshot_commit_descriptors()?
313 .into_iter()
314 .map(|descriptor| descriptor.artifact.id())
315 .collect::<Vec<_>>();
316 let files: Vec<(std::path::PathBuf, std::path::PathBuf)> = manager
317 .pack_file_paths()
318 .into_iter()
319 .map(|(pack, index)| (pack.to_path_buf(), index.to_path_buf()))
320 .collect();
321 (ids, files, commit_artifact_ids)
322 };
323
324 if loose_blobs.is_empty() && loose_trees.is_empty() && old_pack_files.len() <= 1 {
327 return Ok((0, 0));
328 }
329
330 let mut compression = self.compression;
341 if !aggressive {
342 compression.max_delta_size = 0;
343 }
344 let mut builder = PackBuilder::new(compression);
345 let loose_tree_set: std::collections::HashSet<ContentHash> =
346 loose_trees.iter().copied().collect();
347 let mut seen: std::collections::HashSet<crate::store::pack::PackObjectId> =
348 std::collections::HashSet::new();
349
350 for id in existing_ids {
355 if !seen.insert(id) {
356 continue;
357 }
358 let obj_type = {
359 let manager = self.pack_manager().read().map_err(|_| {
360 HeddleError::Config("Failed to acquire pack manager lock".to_string())
361 })?;
362 manager.get_object(&id)?
363 };
364 if let Some((obj_type, mut data)) = obj_type {
365 if let crate::store::pack::PackObjectId::Hash(hash) = id
366 && obj_type == PackObjectType::Tree
367 && loose_tree_set.contains(&hash)
368 && let Some(loose_data) = ObjectStore::get_tree_serialized(self, &hash)?
369 {
370 data = loose_data;
371 }
372 builder.add_id(id, obj_type, data);
373 }
374 }
375
376 for hash in &loose_blobs {
379 let id = crate::store::pack::PackObjectId::Hash(*hash);
380 if seen.contains(&id) {
381 continue;
382 }
383 if let Some(blob) = ObjectStore::get_blob(self, hash)? {
384 seen.insert(id);
385 builder.add(*hash, PackObjectType::Blob, blob.content().to_vec());
386 }
387 }
388 for hash in &loose_trees {
389 let id = crate::store::pack::PackObjectId::Hash(*hash);
390 if seen.contains(&id) {
391 continue;
392 }
393 if let Some(tree) = ObjectStore::get_tree(self, hash)? {
394 let data = rmp_serde::to_vec(&tree)?;
395 seen.insert(id);
396 builder.add(*hash, PackObjectType::Tree, data);
397 }
398 }
399
400 if seen.is_empty() {
401 return Ok((0, 0));
402 }
403
404 let (pack_data, index_data, stats) = builder.build()?;
405 let new_pack_name = blake3::hash(&pack_data).to_hex();
406 if commit_artifact_ids.is_empty() {
407 self.install_pack_files(&pack_data, &index_data)?;
408 } else {
409 super::pack_install_journal::install_snapshot_pack_bytes_with_commit_markers(
410 &packs_dir(&self.root),
411 pack_data,
412 index_data,
413 &commit_artifact_ids,
414 )?;
415 self.reload_packs()?;
416 }
417 self.clear_recent_object_caches();
425
426 for (pack_path, index_path) in &old_pack_files {
433 let is_new_pack = pack_path
434 .file_stem()
435 .and_then(|stem| stem.to_str())
436 .map(|stem| stem == new_pack_name.as_str())
437 .unwrap_or(false);
438 if is_new_pack {
439 continue;
440 }
441 remove_file_ignore_missing(pack_path)?;
442 remove_file_ignore_missing(index_path)?;
443 for artifact_id in &commit_artifact_ids {
444 remove_file_ignore_missing(&snapshot_commit_marker_path(pack_path, artifact_id))?;
445 }
446 }
447 self.reload_packs()?;
450 self.clear_recent_object_caches();
451
452 let saved = stats.total_uncompressed - stats.total_compressed;
453 Ok((stats.object_count, saved))
454 }
455
456 pub(super) fn install_pack_files(&self, pack_data: &[u8], index_data: &[u8]) -> Result<()> {
457 let packs = packs_dir(&self.root);
458 let _pack_name = super::pack_install_journal::install_pack_bytes_journaled(
462 &packs, pack_data, index_data,
463 )?;
464 self.reload_packs()?;
471 Ok(())
472 }
473
474 pub(super) fn install_pack_files_streaming(
485 &self,
486 src_pack_path: &std::path::Path,
487 src_index_path: &std::path::Path,
488 ) -> Result<()> {
489 use std::io::Read;
490
491 let packs = packs_dir(&self.root);
492 crate::fs_atomic::create_dir_all_durable(&packs)?;
493
494 let mut hasher = blake3::Hasher::new();
497 let mut file = fs::File::open(src_pack_path)?;
498 let mut buf = vec![0u8; 64 * 1024];
499 loop {
500 let n = file.read(&mut buf)?;
501 if n == 0 {
502 break;
503 }
504 hasher.update(&buf[..n]);
505 }
506 drop(file);
507 let pack_hash = hasher.finalize();
510 let pack_name = pack_hash.to_hex().to_string();
511
512 super::pack_install_journal::install_pack_files_journaled(
516 &packs,
517 src_pack_path,
518 src_index_path,
519 &pack_name,
520 )?;
521
522 self.clear_recent_object_caches();
523 self.reload_packs()?;
524 Ok(())
525 }
526
527 pub fn prune_unpaired_packs(&self) -> Result<(u64, u64)> {
529 let packs = packs_dir(&self.root);
530 Ok(prune_unpaired_pack_files(&packs)?)
531 }
532
533 pub(super) fn prune_loose_objects_impl(&self) -> Result<(u64, u64)> {
534 let mut removed = 0u64;
535 let mut bytes_freed = 0u64;
536
537 let blobs = list_hashes_from_dir(&blobs_dir(&self.root))?;
538 let trees = list_hashes_from_dir(&trees_dir(&self.root))?;
539
540 let pack_manager = self
541 .pack_manager()
542 .read()
543 .map_err(|_| HeddleError::Config("Failed to acquire pack manager lock".to_string()))?;
544
545 for hash in &blobs {
546 if pack_manager.get_hashed_object(hash)?.is_some() {
547 let path = hash_path(&blobs_dir(&self.root), hash);
548 match fs::metadata(&path) {
549 Ok(metadata) => match fs::remove_file(&path) {
550 Ok(()) => {
551 bytes_freed += metadata.len();
552 removed += 1;
553 }
554 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
555 Err(e) => return Err(HeddleError::from(e)),
556 },
557 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
558 Err(e) => return Err(HeddleError::from(e)),
559 }
560 }
561 }
562
563 for hash in &trees {
564 let Some((obj_type, packed_data)) = pack_manager.get_hashed_object(hash)? else {
565 continue;
566 };
567 if obj_type != PackObjectType::Tree {
568 continue;
569 }
570 let path = hash_path(&trees_dir(&self.root), hash);
571 let Some(loose_data) = read_file_bytes(&path)? else {
572 continue;
573 };
574 let loose_data = codec::decode_tree_body(loose_data.as_slice())?;
575 if packed_data == loose_data {
576 match fs::metadata(&path) {
577 Ok(metadata) => match fs::remove_file(&path) {
578 Ok(()) => {
579 bytes_freed += metadata.len();
580 removed += 1;
581 }
582 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
583 Err(e) => return Err(HeddleError::from(e)),
584 },
585 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
586 Err(e) => return Err(HeddleError::from(e)),
587 }
588 }
589 }
590
591 Ok((removed, bytes_freed))
592 }
593}
594
595#[cfg(test)]
596mod unpaired_pack_tests {
597 use std::fs;
598
599 use super::{list_unpaired_pack_files, prune_unpaired_pack_files};
600
601 #[test]
602 fn list_and_prune_unpaired_packs() {
603 let dir = tempfile::tempdir().unwrap();
604 let packs = dir.path();
605 fs::write(packs.join("aaa.pack"), b"pack-only").unwrap();
606 fs::write(packs.join("bbb.pack"), b"paired-pack").unwrap();
607 fs::write(packs.join("bbb.idx"), b"paired-idx").unwrap();
608 fs::write(packs.join("ccc.idx"), b"index-only").unwrap();
609
610 let listed = list_unpaired_pack_files(packs).unwrap();
611 assert_eq!(listed.len(), 1);
612 assert!(listed[0].ends_with("aaa.pack"));
613
614 let (removed, bytes) = prune_unpaired_pack_files(packs).unwrap();
615 assert_eq!(removed, 1);
616 assert_eq!(bytes, b"pack-only".len() as u64);
617 assert!(!packs.join("aaa.pack").exists());
618 assert!(packs.join("bbb.pack").exists());
619 assert!(packs.join("bbb.idx").exists());
620 assert!(packs.join("ccc.idx").exists());
621 assert!(list_unpaired_pack_files(packs).unwrap().is_empty());
622 }
623
624 #[test]
625 fn missing_packs_dir_is_empty() {
626 let dir = tempfile::tempdir().unwrap();
627 let missing = dir.path().join("nope");
628 assert!(list_unpaired_pack_files(&missing).unwrap().is_empty());
629 assert_eq!(prune_unpaired_pack_files(&missing).unwrap(), (0, 0));
630 }
631}