Garbage collection: operation-log retention, pruning with grace period, pack rewriting; oxy gc --now ba160f60219e
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-26 20:26 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
ba160f60219ec658e83908930d89808a751fc9fc44a39c5afe381bbafae167b3- Depends on
- 1983686fb7a7 2b5ee371060e 614541c3cebd 83e0677b07fe 982864068a59 d02e545dc265 fce44ae9ad29 ff0af1ee299d
- Depended on by
- c397a8c36a9f 1ec6fdbc6bcb ef7d4ea6f33b 767ac6cd0545 f53cbe7a204e 3a4b3e018326 b689779e9e74 9d580f2132f3 d6e0d5b8b339 42ccf504aa50 648189388f1a 9ae4d15a0e4b d8d9367878a6 b30a880fa409 ff1f62fee654 a17fc622ca43 da652d6392d8 6f8aeb996558 fc1a466a1e66 afaba5f1306d 62794ecc7f74 387335ff7f4d 75167672ebcd b3c5a59a875a 8f10a07a1dbe 21b2aec279ff 6d3eefbce2f0 132b884b9a8d 2863456a0f6d 135212de5370 252e8cb23fbc c3069537d7c5 79ab8528ff62
- In bookmarks
- agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
- In tags
- v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
- Edits
- 33 atoms, 1 tree operation, 7 files
Browse the tree at this change
added crates/oxy-core/src/repo/gc.rs +109 −0
| 1 | + | //! Garbage collection (spec §21): pack loose objects, then delete objects |
|
| 2 | + | //! nothing reachable refers to. |
|
| 3 | + | //! |
|
| 4 | + | //! Roots: the current view, the views of every operation newer than |
|
| 5 | + | //! `core.op-retention`, reference attestations, and the policy. Operation |
|
| 6 | + | //! objects are always kept so the log stays walkable, but an operation older |
|
| 7 | + | //! than the retention window no longer keeps its states alive, so undoing |
|
| 8 | + | //! that far back may fail once its objects are gone. |
|
| 9 | + | ||
| 10 | + | use super::*; |
|
| 11 | + | use crate::store::{PackReport, PruneReport}; |
|
| 12 | + | use std::collections::HashSet; |
|
| 13 | + | ||
| 14 | + | #[derive(Debug, Clone, Default, PartialEq, Eq)] |
|
| 15 | + | pub struct GcReport { |
|
| 16 | + | pub packed: PackReport, |
|
| 17 | + | pub pruned: PruneReport, |
|
| 18 | + | pub reachable: usize, |
|
| 19 | + | } |
|
| 20 | + | ||
| 21 | + | impl Repo { |
|
| 22 | + | /// Pack loose objects and prune unreachable ones older than the grace |
|
| 23 | + | /// period (`now` ignores the grace period). |
|
| 24 | + | pub fn gc(&self, now: bool) -> Result<GcReport> { |
|
| 25 | + | let packed = self.store.pack_loose()?; |
|
| 26 | + | let keep = self.reachable()?; |
|
| 27 | + | let grace = if now { std::time::Duration::ZERO } else { std::time::Duration::from_secs(self.config.gc_grace_days * 86_400) }; |
|
| 28 | + | let pruned = self.store.prune(&keep, grace)?; |
|
| 29 | + | Ok(GcReport { packed, pruned, reachable: keep.len() }) |
|
| 30 | + | } |
|
| 31 | + | ||
| 32 | + | /// Every object that must survive garbage collection. |
|
| 33 | + | pub fn reachable(&self) -> Result<HashSet<ObjectRef>> { |
|
| 34 | + | let mut keep: HashSet<ObjectRef> = HashSet::new(); |
|
| 35 | + | let mut states: Vec<ObjectRef> = Vec::new(); |
|
| 36 | + | let mut changes: Vec<ChangeHash> = Vec::new(); |
|
| 37 | + | let mut attests: Vec<ObjectRef> = Vec::new(); |
|
| 38 | + | let mut policies: Vec<ObjectRef> = Vec::new(); |
|
| 39 | + | ||
| 40 | + | // Operations: always kept; views within retention are roots. |
|
| 41 | + | let cutoff = Hlc::now().wall_ms.saturating_sub(self.config.op_retention_days * 86_400_000); |
|
| 42 | + | for (i, (r, op)) in self.op_log()?.into_iter().enumerate() { |
|
| 43 | + | keep.insert(r); |
|
| 44 | + | if i == 0 || op.hlc.wall_ms >= cutoff { |
|
| 45 | + | states.extend(op.view.bookmarks.values().copied()); |
|
| 46 | + | states.extend(op.view.remotes.values().flat_map(|b| b.values().copied())); |
|
| 47 | + | changes.extend(op.view.workspaces.values().copied()); |
|
| 48 | + | policies.extend(op.view.policy); |
|
| 49 | + | } |
|
| 50 | + | } |
|
| 51 | + | // Reference attestations. |
|
| 52 | + | for name in self.view()?.bookmarks.keys() { |
|
| 53 | + | if let Some((r, _)) = self.latest_ref_attest(name)? { |
|
| 54 | + | attests.push(r); |
|
| 55 | + | } |
|
| 56 | + | } |
|
| 57 | + | for st in states { |
|
| 58 | + | if keep.insert(st) { |
|
| 59 | + | changes.extend(self.store.get_state(&st)?.heads); |
|
| 60 | + | } |
|
| 61 | + | } |
|
| 62 | + | for p in policies { |
|
| 63 | + | if keep.insert(p) { |
|
| 64 | + | let policy = crate::policy::Policy::decode(&self.store.get_typed(&p, ObjectType::Policy)?)?; |
|
| 65 | + | for id in &policy.identities { |
|
| 66 | + | keep.insert(id.hash()); |
|
| 67 | + | } |
|
| 68 | + | for sig in self.store.signatures(&p)? { |
|
| 69 | + | keep.insert(sig.key); |
|
| 70 | + | } |
|
| 71 | + | } |
|
| 72 | + | } |
|
| 73 | + | // Changes: their closure, identities, blobs, chunks, seals. |
|
| 74 | + | let mut stack = changes; |
|
| 75 | + | while let Some(h) = stack.pop() { |
|
| 76 | + | if !keep.insert(h) { |
|
| 77 | + | continue; |
|
| 78 | + | } |
|
| 79 | + | let c = self.store.get_change(&h)?; |
|
| 80 | + | stack.extend(c.deps.iter().copied()); |
|
| 81 | + | if !c.author.is_zero() { |
|
| 82 | + | keep.insert(c.author); |
|
| 83 | + | } |
|
| 84 | + | for sig in self.store.signatures(&h)? { |
|
| 85 | + | keep.insert(sig.key); |
|
| 86 | + | } |
|
| 87 | + | for e in &c.edits { |
|
| 88 | + | if let Edit::Replace { old, new, .. } = e { |
|
| 89 | + | for b in [old, new].into_iter().flatten() { |
|
| 90 | + | if keep.insert(*b) { |
|
| 91 | + | if let Ok(bytes) = self.store.get_typed(b, ObjectType::Blob) { |
|
| 92 | + | keep.extend(Blob::decode(&bytes)?.chunks); |
|
| 93 | + | } |
|
| 94 | + | } |
|
| 95 | + | } |
|
| 96 | + | } |
|
| 97 | + | } |
|
| 98 | + | attests.extend(self.store.seals_for(&h)?); |
|
| 99 | + | } |
|
| 100 | + | for a in attests { |
|
| 101 | + | if keep.insert(a) { |
|
| 102 | + | for sig in self.store.signatures(&a)? { |
|
| 103 | + | keep.insert(sig.key); |
|
| 104 | + | } |
|
| 105 | + | } |
|
| 106 | + | } |
|
| 107 | + | Ok(keep) |
|
| 108 | + | } |
|
| 109 | + | } |
modified crates/oxy-core/src/repo/config.rs +13 −0
| … | 22 unchanged lines |
||
| 23 | 23 | pub remote_tokens: BTreeMap<String, String>, |
|
| 24 | 24 | /// `remote.<name>.ca`: PEM file of extra roots to trust for that remote. |
|
| 25 | 25 | pub remote_cas: BTreeMap<String, String>, |
|
| 26 | + | /// `core.op-retention`: days for which operations keep their states reachable (spec §12.1, §21). |
|
| 27 | + | pub op_retention_days: u64, |
|
| 28 | + | /// `core.gc-grace`: days an unreachable object survives, protecting concurrent writers (spec §21). |
|
| 29 | + | pub gc_grace_days: u64, |
|
| 26 | 30 | } |
|
| 27 | 31 | ||
| 28 | 32 | impl Config { |
|
| … | 9 unchanged lines |
||
| 38 | 42 | remotes: BTreeMap::new(), |
|
| 39 | 43 | remote_tokens: BTreeMap::new(), |
|
| 40 | 44 | remote_cas: BTreeMap::new(), |
|
| 45 | + | op_retention_days: 90, |
|
| 46 | + | gc_grace_days: 14, |
|
| 41 | 47 | } |
|
| 42 | 48 | } |
|
| 43 | 49 | ||
| … | 43 unchanged lines |
||
| 87 | 93 | } |
|
| 88 | 94 | } |
|
| 89 | 95 | "workspace.bookmark" => self.bookmark = value.into(), |
|
| 96 | + | "core.op-retention" => self.op_retention_days = parse_days(value)?, |
|
| 97 | + | "core.gc-grace" => self.gc_grace_days = parse_days(value)?, |
|
| 90 | 98 | k if k.starts_with("remote.") => { |
|
| 91 | 99 | let rest = &k["remote.".len()..]; |
|
| 92 | 100 | let Some((name, field)) = rest.rsplit_once('.') else { |
|
| … | 49 unchanged lines |
||
| 142 | 150 | } |
|
| 143 | 151 | } |
|
| 144 | 152 | ||
| 153 | + | /// A count of days, optionally suffixed `d`. |
|
| 154 | + | fn parse_days(v: &str) -> Result<u64> { |
|
| 155 | + | v.trim_end_matches('d').parse().map_err(|_| RepoError::Msg(format!("expected a number of days, got {v:?}"))) |
|
| 156 | + | } |
|
| 157 | + | ||
| 145 | 158 | /// Read `key = "value"` lines; a missing file is empty. |
|
| 146 | 159 | pub fn read_config_file(path: &Path) -> Result<BTreeMap<String, String>> { |
|
| 147 | 160 | let text = match fs::read_to_string(path) { |
|
| … | 21 unchanged lines |
modified crates/oxy-core/src/repo/mod.rs +2 −7
| … | 52 unchanged lines |
||
| 53 | 53 | ||
| 54 | 54 | mod checkout; |
|
| 55 | 55 | mod config; |
|
| 56 | + | mod gc; |
|
| 56 | 57 | mod history; |
|
| 57 | 58 | mod identity; |
|
| 58 | 59 | mod policy; |
|
| … | 4 unchanged lines |
||
| 63 | 64 | ||
| 64 | 65 | pub use checkout::CheckoutReport; |
|
| 65 | 66 | pub use config::{read_config_file, Config}; |
|
| 67 | + | pub use gc::GcReport; |
|
| 66 | 68 | pub use identity::SignatureStatus; |
|
| 67 | 69 | pub use snapshot::{added_node_paths, has_conflict_markers, is_binary, referenced_changes, render_change, ChangeStatus, PathStatus, SnapshotResult}; |
|
| 68 | 70 | pub use sync::{FetchReport, PullReport}; |
|
| … | 151 unchanged lines |
||
| 220 | 222 | write_atomic(&self.oxy.join("ops").join("head"), new_parent.unwrap().to_hex().as_bytes())?; |
|
| 221 | 223 | write_atomic(&self.oxy.join("format"), FORMAT_LINE.as_bytes())?; |
|
| 222 | 224 | Ok(()) |
|
| 223 | - | } |
|
| 224 | - | ||
| 225 | - | /// Pack every loose object (spec §13.4). Pruning unreachable objects |
|
| 226 | - | /// waits for operation-log retention, since every operation keeps its |
|
| 227 | - | /// states reachable for now. |
|
| 228 | - | pub fn gc(&self) -> Result<crate::store::PackReport> { |
|
| 229 | - | Ok(self.store.pack_loose()?) |
|
| 230 | 225 | } |
|
| 231 | 226 | ||
| 232 | 227 | /// Pack automatically once many loose objects have accumulated. |
|
| … | 345 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +33 −7
| … | 762 unchanged lines |
||
| 763 | 763 | write(root, "a.txt", b"two\n"); |
|
| 764 | 764 | write(root, "big.bin", &vec![0u8; 100_000]); |
|
| 765 | 765 | let h2 = repo.commit("two", &[]).unwrap(); |
|
| 766 | + | // Several snapshots leave superseded working-copy versions behind. |
|
| 767 | + | for i in 0..5 { |
|
| 768 | + | write(root, "a.txt", format!("two\n{i}\n").as_bytes()); |
|
| 769 | + | repo.snapshot("status").unwrap(); |
|
| 770 | + | } |
|
| 771 | + | write(root, "a.txt", b"two\n"); |
|
| 772 | + | repo.snapshot("status").unwrap(); |
|
| 766 | 773 | let before = repo.store().list().unwrap(); |
|
| 767 | 774 | assert!(repo.store().loose_count().unwrap() > 0); |
|
| 768 | - | let r = repo.gc().unwrap(); |
|
| 769 | - | assert_eq!(r.objects, before.len()); |
|
| 775 | + | ||
| 776 | + | // With the default retention every operation's view is a root: nothing prunes. |
|
| 777 | + | let r = repo.gc(true).unwrap(); |
|
| 778 | + | assert_eq!(r.packed.objects, before.len()); |
|
| 779 | + | assert_eq!(r.pruned.objects, 0); |
|
| 770 | 780 | assert_eq!(repo.store().loose_count().unwrap(), 0); |
|
| 771 | 781 | assert_eq!(repo.store().list().unwrap(), before); |
|
| 772 | - | // Reopen: reads come from the pack; signatures, states, ops all resolve. |
|
| 782 | + | ||
| 783 | + | // With zero retention only the current view is a root: superseded |
|
| 784 | + | // working copies go, everything reachable stays. |
|
| 785 | + | repo.config.set("core.op-retention", "0").unwrap(); |
|
| 786 | + | repo.save_config().unwrap(); |
|
| 773 | 787 | let repo = Repo::open(root).unwrap().with_home(root.join("home")); |
|
| 788 | + | let r = repo.gc(true).unwrap(); |
|
| 789 | + | assert!(r.pruned.objects >= 5, "{r:?}"); |
|
| 790 | + | let after = repo.store().list().unwrap(); |
|
| 791 | + | assert!(after.len() < before.len()); |
|
| 792 | + | assert!(after.contains(&h1) && after.contains(&h2)); |
|
| 793 | + | for (_, op) in repo.op_log().unwrap() { |
|
| 794 | + | assert!(!op.command.is_empty(), "operation objects are all kept"); |
|
| 795 | + | } |
|
| 796 | + | // Everything still works from the pruned, packed store. |
|
| 774 | 797 | assert_eq!(repo.bookmark_heads().unwrap(), vec![h2]); |
|
| 775 | 798 | assert!(matches!(repo.verify_change(&h2).unwrap(), SignatureStatus::Valid { .. })); |
|
| 776 | 799 | assert_eq!(repo.log().unwrap().len(), 3); |
|
| … | 2 unchanged lines |
||
| 779 | 802 | repo.checkout(&[h2], false, "checkout").unwrap(); |
|
| 780 | 803 | assert_eq!(fs::read(root.join("big.bin")).unwrap().len(), 100_000); |
|
| 781 | 804 | assert!(!repo.snapshot("status").unwrap().changed); |
|
| 782 | - | // New work after packing goes loose and commits fine. |
|
| 805 | + | // Grace period defers pruning of young garbage. |
|
| 783 | 806 | write(root, "a.txt", b"three\n"); |
|
| 784 | 807 | repo.commit("three", &[]).unwrap(); |
|
| 785 | - | assert!(repo.store().loose_count().unwrap() > 0); |
|
| 786 | - | assert_eq!(repo.gc().unwrap().objects, repo.store().list().unwrap().len() - before.len()); |
|
| 787 | - | assert_eq!(repo.store().pack_count(), 2); |
|
| 808 | + | let r = repo.gc(false).unwrap(); |
|
| 809 | + | assert_eq!(r.pruned.objects, 0); |
|
| 810 | + | assert!(r.pruned.deferred > 0); |
|
| 811 | + | let r = repo.gc(true).unwrap(); |
|
| 812 | + | assert!(r.pruned.objects > 0); |
|
| 813 | + | assert_eq!(repo.log().unwrap().len(), 4); |
|
| 788 | 814 | } |
|
| 789 | 815 | ||
| 790 | 816 | #[test] |
|
| … | 57 unchanged lines |
modified crates/oxy-core/src/store.rs +136 −4
| … | 106 unchanged lines |
||
| 107 | 107 | pub packed_bytes: u64, |
|
| 108 | 108 | } |
|
| 109 | 109 | ||
| 110 | + | /// What `prune` did. |
|
| 111 | + | #[derive(Debug, Clone, Default, PartialEq, Eq)] |
|
| 112 | + | pub struct PruneReport { |
|
| 113 | + | pub objects: usize, |
|
| 114 | + | pub bytes: u64, |
|
| 115 | + | /// Unreachable objects left alone because they are younger than the grace period. |
|
| 116 | + | pub deferred: usize, |
|
| 117 | + | } |
|
| 118 | + | ||
| 110 | 119 | #[derive(Debug, thiserror::Error)] |
|
| 111 | 120 | pub enum StoreError { |
|
| 112 | 121 | #[error("io: {0}")] |
|
| … | 80 unchanged lines |
||
| 193 | 202 | /// deleted only after the pack and its index are durable. |
|
| 194 | 203 | pub fn pack_loose(&self) -> Result<PackReport> { |
|
| 195 | 204 | let loose = self.loose_objects()?; |
|
| 196 | - | if loose.is_empty() { |
|
| 205 | + | self.write_pack(&loose.iter().map(|(r, _)| *r).collect::<Vec<_>>(), &loose.iter().map(|(_, p)| p.clone()).collect::<Vec<_>>(), &[]) |
|
| 206 | + | } |
|
| 207 | + | ||
| 208 | + | /// Delete every object not in `keep` (spec §21). Loose objects and packs |
|
| 209 | + | /// younger than `grace` are left alone, since a concurrent writer may not |
|
| 210 | + | /// have recorded its references yet. Packs holding unreachable objects |
|
| 211 | + | /// are rewritten without them. Signature sidecars and seal-index entries |
|
| 212 | + | /// of deleted objects go too. |
|
| 213 | + | pub fn prune(&self, keep: &std::collections::HashSet<ObjectRef>, grace: std::time::Duration) -> Result<PruneReport> { |
|
| 214 | + | let now = std::time::SystemTime::now(); |
|
| 215 | + | let old_enough = |m: &fs::Metadata| m.modified().map(|t| now.duration_since(t).unwrap_or_default() >= grace).unwrap_or(true); |
|
| 216 | + | let mut report = PruneReport::default(); |
|
| 217 | + | // Loose objects. |
|
| 218 | + | for (r, path) in self.loose_objects()? { |
|
| 219 | + | if keep.contains(&r) { |
|
| 220 | + | continue; |
|
| 221 | + | } |
|
| 222 | + | let meta = fs::metadata(&path)?; |
|
| 223 | + | if !old_enough(&meta) { |
|
| 224 | + | report.deferred += 1; |
|
| 225 | + | continue; |
|
| 226 | + | } |
|
| 227 | + | report.bytes += meta.len(); |
|
| 228 | + | report.objects += 1; |
|
| 229 | + | fs::remove_file(&path)?; |
|
| 230 | + | let _ = fs::remove_dir(path.parent().unwrap()); |
|
| 231 | + | self.remove_sidecars(&r)?; |
|
| 232 | + | } |
|
| 233 | + | // Packs: rewrite those that hold unreachable objects. |
|
| 234 | + | let mut keep_refs: Vec<ObjectRef> = Vec::new(); |
|
| 235 | + | let mut old_paths: Vec<PathBuf> = Vec::new(); |
|
| 236 | + | let mut rewrite = false; |
|
| 237 | + | for p in self.packs.borrow().iter() { |
|
| 238 | + | let meta = fs::metadata(&p.path)?; |
|
| 239 | + | let unreachable = p.refs.iter().filter(|r| !keep.contains(r)).count(); |
|
| 240 | + | if unreachable == 0 { |
|
| 241 | + | continue; |
|
| 242 | + | } |
|
| 243 | + | if !old_enough(&meta) { |
|
| 244 | + | report.deferred += unreachable; |
|
| 245 | + | continue; |
|
| 246 | + | } |
|
| 247 | + | rewrite = true; |
|
| 248 | + | old_paths.push(p.path.clone()); |
|
| 249 | + | for (r, e) in p.refs.iter().zip(p.entries.iter()) { |
|
| 250 | + | if keep.contains(r) { |
|
| 251 | + | keep_refs.push(*r); |
|
| 252 | + | } else { |
|
| 253 | + | report.objects += 1; |
|
| 254 | + | report.bytes += e.stored_len as u64; |
|
| 255 | + | } |
|
| 256 | + | } |
|
| 257 | + | } |
|
| 258 | + | if rewrite { |
|
| 259 | + | let dropped: Vec<ObjectRef> = { |
|
| 260 | + | let packs = self.packs.borrow(); |
|
| 261 | + | packs.iter().filter(|p| old_paths.contains(&p.path)).flat_map(|p| p.refs.iter().copied()).filter(|r| !keep.contains(r)).collect() |
|
| 262 | + | }; |
|
| 263 | + | self.write_pack(&keep_refs, &[], &old_paths)?; |
|
| 264 | + | for r in dropped { |
|
| 265 | + | self.remove_sidecars(&r)?; |
|
| 266 | + | } |
|
| 267 | + | } |
|
| 268 | + | Ok(report) |
|
| 269 | + | } |
|
| 270 | + | ||
| 271 | + | fn remove_sidecars(&self, r: &ObjectRef) -> Result<()> { |
|
| 272 | + | for path in [self.sig_path(r), self.seal_index_path(r)] { |
|
| 273 | + | match fs::remove_file(&path) { |
|
| 274 | + | Ok(()) => { |
|
| 275 | + | let _ = fs::remove_dir(path.parent().unwrap()); |
|
| 276 | + | } |
|
| 277 | + | Err(e) if e.kind() == io::ErrorKind::NotFound => {} |
|
| 278 | + | Err(e) => return Err(e.into()), |
|
| 279 | + | } |
|
| 280 | + | } |
|
| 281 | + | Ok(()) |
|
| 282 | + | } |
|
| 283 | + | ||
| 284 | + | /// Write `refs` into a new pack, then delete `loose_paths` and `old_packs`. |
|
| 285 | + | fn write_pack(&self, refs: &[ObjectRef], loose_paths: &[PathBuf], old_packs: &[PathBuf]) -> Result<PackReport> { |
|
| 286 | + | if refs.is_empty() { |
|
| 287 | + | for p in old_packs { |
|
| 288 | + | fs::remove_file(p)?; |
|
| 289 | + | fs::remove_file(p.with_extension("idx"))?; |
|
| 290 | + | } |
|
| 291 | + | self.reload_packs()?; |
|
| 197 | 292 | return Ok(PackReport::default()); |
|
| 198 | 293 | } |
|
| 294 | + | let mut refs: Vec<ObjectRef> = refs.to_vec(); |
|
| 295 | + | refs.sort(); |
|
| 296 | + | refs.dedup(); |
|
| 199 | 297 | let mut nonce = [0u8; 8]; |
|
| 200 | 298 | rand::rngs::OsRng.fill_bytes(&mut nonce); |
|
| 201 | 299 | let id = hex::encode(nonce); |
|
| … | 2 unchanged lines |
||
| 204 | 302 | let tmp_pack = self.dir.join(format!("tmp-{id}.pack")); |
|
| 205 | 303 | let tmp_idx = self.dir.join(format!("tmp-{id}.idx")); |
|
| 206 | 304 | let mut report = PackReport::default(); |
|
| 207 | - | let mut index: Vec<(ObjectRef, PackEntry)> = Vec::with_capacity(loose.len()); |
|
| 305 | + | let mut index: Vec<(ObjectRef, PackEntry)> = Vec::with_capacity(refs.len()); |
|
| 208 | 306 | { |
|
| 209 | 307 | let mut f = io::BufWriter::new(fs::File::create(&tmp_pack)?); |
|
| 210 | 308 | f.write_all(PACK_MAGIC)?; |
|
| 211 | 309 | let mut offset = PACK_MAGIC.len() as u64; |
|
| 212 | - | for (r, _) in &loose { |
|
| 310 | + | for r in &refs { |
|
| 213 | 311 | let (ty, body) = self.get(r)?; |
|
| 214 | 312 | let compressed = zstd::bulk::compress(&body, ZSTD_LEVEL).map_err(|e| StoreError::Corrupt(*r, format!("zstd: {e}")))?; |
|
| 215 | 313 | let (flags, stored) = if compressed.len() < body.len() { (FLAG_ZSTD, compressed) } else { (0, body.clone()) }; |
|
| … | 28 unchanged lines |
||
| 244 | 342 | } |
|
| 245 | 343 | fs::rename(&tmp_pack, &pack_path)?; |
|
| 246 | 344 | fs::rename(&tmp_idx, &idx_path)?; |
|
| 345 | + | for p in old_packs { |
|
| 346 | + | fs::remove_file(p)?; |
|
| 347 | + | fs::remove_file(p.with_extension("idx"))?; |
|
| 348 | + | } |
|
| 247 | 349 | self.reload_packs()?; |
|
| 248 | - | for (_, path) in &loose { |
|
| 350 | + | for path in loose_paths { |
|
| 249 | 351 | fs::remove_file(path)?; |
|
| 250 | 352 | if let Some(dir) = path.parent() { |
|
| 251 | 353 | let _ = fs::remove_dir(dir); |
|
| … | 265 unchanged lines |
||
| 517 | 619 | } |
|
| 518 | 620 | ||
| 519 | 621 | #[test] |
|
| 622 | + | fn prune_removes_unreachable_loose_and_packed_objects() { |
|
| 623 | + | use std::collections::HashSet; |
|
| 624 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 625 | + | let s = Store::open(dir.path()).unwrap(); |
|
| 626 | + | let keep_a = s.put(ObjectType::Chunk, b"keep a").unwrap(); |
|
| 627 | + | let drop_a = s.put(ObjectType::Chunk, b"drop a").unwrap(); |
|
| 628 | + | s.pack_loose().unwrap(); |
|
| 629 | + | let keep_b = s.put(ObjectType::Chunk, b"keep b").unwrap(); |
|
| 630 | + | let drop_b = s.put(ObjectType::Chunk, b"drop b").unwrap(); |
|
| 631 | + | let sk = crate::identity::SecretKey { ed_seed: [1; 32], ml_seed: [2; 32] }; |
|
| 632 | + | let id = sk.identity("a", "b", 0); |
|
| 633 | + | let key = s.put_identity(&id).unwrap(); |
|
| 634 | + | s.put_signature(&drop_b, &sk.sign(crate::identity::Domain::Change, &key, &drop_b)).unwrap(); |
|
| 635 | + | s.index_seal(&drop_a, &ObjectRef([3; 32])).unwrap(); |
|
| 636 | + | let keep: HashSet<ObjectRef> = [keep_a, keep_b, key].into_iter().collect(); |
|
| 637 | + | // Everything is too young under a grace period. |
|
| 638 | + | let r = s.prune(&keep, std::time::Duration::from_secs(3600)).unwrap(); |
|
| 639 | + | assert_eq!((r.objects, r.deferred), (0, 2)); |
|
| 640 | + | let r = s.prune(&keep, std::time::Duration::ZERO).unwrap(); |
|
| 641 | + | assert_eq!(r.objects, 2, "{r:?}"); |
|
| 642 | + | assert!(s.has(&keep_a) && s.has(&keep_b) && s.has(&key)); |
|
| 643 | + | assert!(!s.has(&drop_a) && !s.has(&drop_b)); |
|
| 644 | + | assert!(s.signatures(&drop_b).unwrap().is_empty()); |
|
| 645 | + | assert!(s.seals_for(&drop_a).unwrap().is_empty()); |
|
| 646 | + | assert_eq!(s.pack_count(), 1); |
|
| 647 | + | assert_eq!(s.get(&keep_a).unwrap().1, b"keep a"); |
|
| 648 | + | assert_eq!(s.list().unwrap().len(), 3); |
|
| 649 | + | } |
|
| 650 | + | ||
| 651 | + | #[test] |
|
| 520 | 652 | fn signatures_are_stored_beside_objects() { |
|
| 521 | 653 | use crate::identity::{Domain, SecretKey}; |
|
| 522 | 654 | let dir = tempfile::tempdir().unwrap(); |
|
| … | 25 unchanged lines |
modified src/cli/mod.rs +8 −4
| … | 62 unchanged lines |
||
| 63 | 63 | Resolve, |
|
| 64 | 64 | /// Undo the last operation |
|
| 65 | 65 | Undo, |
|
| 66 | - | /// Pack loose objects into a compressed pack |
|
| 67 | - | Gc, |
|
| 66 | + | /// Pack loose objects and prune unreachable ones older than the grace period |
|
| 67 | + | Gc { |
|
| 68 | + | /// Ignore the grace period (core.gc-grace) and prune immediately |
|
| 69 | + | #[arg(long)] |
|
| 70 | + | now: bool, |
|
| 71 | + | }, |
|
| 68 | 72 | /// List operations |
|
| 69 | 73 | #[command(name = "op")] |
|
| 70 | 74 | Op { |
|
| … | 45 unchanged lines |
||
| 116 | 120 | #[command(subcommand)] |
|
| 117 | 121 | cmd: PolicyCmd, |
|
| 118 | 122 | }, |
|
| 119 | - | /// Set a configuration key (author.*, core.sign, workspace.bookmark, remote.<name>.url|token|ca) |
|
| 123 | + | /// Set a configuration key (author.*, core.sign, core.op-retention, core.gc-grace, workspace.bookmark, remote.<name>.url|token|ca) |
|
| 120 | 124 | Config { |
|
| 121 | 125 | key: String, |
|
| 122 | 126 | value: String, |
|
| … | 71 unchanged lines |
||
| 194 | 198 | Cmd::Checkout { changes, discard } => workspace::checkout(&changes, discard), |
|
| 195 | 199 | Cmd::Resolve => workspace::resolve(), |
|
| 196 | 200 | Cmd::Undo => workspace::undo(), |
|
| 197 | - | Cmd::Gc => workspace::gc(), |
|
| 201 | + | Cmd::Gc { now } => workspace::gc(now), |
|
| 198 | 202 | Cmd::Op { cmd: OpCmd::Log } => workspace::op_log(), |
|
| 199 | 203 | Cmd::Clone { url, dest } => sync::clone(&url, dest), |
|
| 200 | 204 | Cmd::Remote { cmd } => match cmd { |
|
| … | 23 unchanged lines |
modified src/cli/workspace.rs +15 −11
| … | 183 unchanged lines |
||
| 184 | 184 | Ok(0) |
|
| 185 | 185 | } |
|
| 186 | 186 | ||
| 187 | - | pub fn gc() -> Result<i32> { |
|
| 187 | + | pub fn gc(now: bool) -> Result<i32> { |
|
| 188 | 188 | let repo = open()?; |
|
| 189 | - | let r = repo.gc()?; |
|
| 190 | - | if r.objects == 0 { |
|
| 191 | - | println!("Nothing to pack; {} pack(s) present.", repo.store().pack_count()); |
|
| 192 | - | } else { |
|
| 189 | + | let r = repo.gc(now)?; |
|
| 190 | + | if r.packed.objects > 0 { |
|
| 193 | 191 | println!( |
|
| 194 | - | "Packed {} into one pack: {} bytes -> {} bytes ({:.0}%); {} pack(s) present.", |
|
| 195 | - | plural(r.objects, "object"), |
|
| 196 | - | r.raw_bytes, |
|
| 197 | - | r.packed_bytes, |
|
| 198 | - | 100.0 * r.packed_bytes as f64 / r.raw_bytes.max(1) as f64, |
|
| 199 | - | repo.store().pack_count() |
|
| 192 | + | "Packed {}: {} bytes -> {} bytes ({:.0}%).", |
|
| 193 | + | plural(r.packed.objects, "loose object"), |
|
| 194 | + | r.packed.raw_bytes, |
|
| 195 | + | r.packed.packed_bytes, |
|
| 196 | + | 100.0 * r.packed.packed_bytes as f64 / r.packed.raw_bytes.max(1) as f64 |
|
| 200 | 197 | ); |
|
| 201 | 198 | } |
|
| 199 | + | println!("{} reachable; pruned {} ({} bytes){}; {} present.", |
|
| 200 | + | plural(r.reachable, "object"), |
|
| 201 | + | plural(r.pruned.objects, "unreachable object"), |
|
| 202 | + | r.pruned.bytes, |
|
| 203 | + | if r.pruned.deferred > 0 { format!("; {} deferred by the grace period (use --now)", plural(r.pruned.deferred, "object")) } else { String::new() }, |
|
| 204 | + | plural(repo.store().pack_count(), "pack") |
|
| 205 | + | ); |
|
| 202 | 206 | Ok(0) |
|
| 203 | 207 | } |
|
| 204 | 208 | ||
| … | 38 unchanged lines |