gc repacks everything into one pack, compressed harder da652d6392d8
The Linux tip's store was 390 MB after oxy gc against Git's 311 MB. gc packed loose objects but left the first commit's pack as written (packs merged only above 16), all at zstd level 3. It now prunes, then repacks every surviving object into one pack at level 9, as git gc repacks, and the pack writer compresses in batches on every core. Automatic packing stays incremental and fast. The Linux tip's store is 357 MB after gc (52 s). Most of the rest is the CBOR framing of text blobs. refs #40
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-30 02:24 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
da652d6392d8d2b8959516144f771bab744a13ec5395bf06d5535b0c37cb1c25- Depends on
- 135212de5370 1d360c91a289 2b872d074ddb 387335ff7f4d 75167672ebcd 8f10a07a1dbe a87c7b9d563a b493d993a9df ba160f60219e cdb5beba7f05 d02e545dc265 ee06a6c557b7 fce44ae9ad29 ff0af1ee299d
- Depended on by
- 767ac6cd0545 a67914bced23 648189388f1a 9ae4d15a0e4b a17fc622ca43 60622d23b9e4 d77e1702d770 36695c5e02ef 0f93f49ce059
- 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
- 24 atoms, 0 tree operations, 7 files
Browse the tree at this change
modified crates/oxy-core/src/repo/gc.rs +7 −6
| 1 | - | //! Garbage collection (spec §21): pack loose objects, then delete objects |
|
| 1 | + | //! Garbage collection (spec §21): delete objects |
|
| 2 | 2 | //! nothing reachable refers to. |
|
| 3 | 3 | //! |
|
| 4 | 4 | //! Roots: the current view, the views of every operation newer than |
|
| … | 12 unchanged lines |
||
| 17 | 17 | pub packed: PackReport, |
|
| 18 | 18 | pub pruned: PruneReport, |
|
| 19 | 19 | pub reachable: usize, |
|
| 20 | - | /// Packs folded into one because there were more than `MAX_PACKS`. |
|
| 20 | + | /// Packs folded into the one a full repack writes. |
|
| 21 | 21 | pub merged_packs: usize, |
|
| 22 | 22 | } |
|
| 23 | 23 | ||
| 24 | 24 | impl Repo { |
|
| 25 | - | /// Pack loose objects and prune unreachable ones older than the grace |
|
| 26 | - | /// period (`now` ignores the grace period). |
|
| 25 | + | /// Prune unreachable objects older than the grace period (`now` |
|
| 26 | + | /// ignores it), then repack everything into one pack (spec §13.4). |
|
| 27 | 27 | pub fn gc(&self, now: bool) -> Result<GcReport> { |
|
| 28 | - | let packed = self.store.pack_loose()?; |
|
| 29 | 28 | let keep = self.reachable()?; |
|
| 30 | 29 | let grace = if now { |
|
| 31 | 30 | std::time::Duration::ZERO |
|
| … | 1 unchanged lines |
||
| 33 | 32 | std::time::Duration::from_secs(self.config.gc_grace_days * 86_400) |
|
| 34 | 33 | }; |
|
| 35 | 34 | let pruned = self.store.prune(&keep, grace)?; |
|
| 36 | - | let merged_packs = self.store.merge_packs()?; |
|
| 35 | + | // Then everything left in one pack, compressed harder than a |
|
| 36 | + | // commit's (spec §13.4), as `git gc` repacks. |
|
| 37 | + | let (packed, merged_packs) = self.store.repack_all(crate::store::REPACK_ZSTD_LEVEL)?; |
|
| 37 | 38 | Ok(GcReport { |
|
| 38 | 39 | packed, |
|
| 39 | 40 | pruned, |
|
| … | 132 unchanged lines |
modified crates/oxy-core/src/store.rs +63 −13
| … | 23 unchanged lines |
||
| 24 | 24 | const IDX_ENTRY: usize = 32 + 8 + 4 + 4 + 1 + 1; |
|
| 25 | 25 | const FLAG_ZSTD: u8 = 1; |
|
| 26 | 26 | const ZSTD_LEVEL: i32 = 3; |
|
| 27 | + | /// The level of a full repack (`oxy gc`): slower, once, for a smaller store. |
|
| 28 | + | pub const REPACK_ZSTD_LEVEL: i32 = 9; |
|
| 27 | 29 | /// Largest object a pack entry may claim; anything bigger is corruption, |
|
| 28 | 30 | /// and refusing it keeps a bad index from driving huge allocations. |
|
| 29 | 31 | const MAX_OBJECT_LEN: u32 = 1 << 30; |
|
| … | 322 unchanged lines |
||
| 352 | 354 | &loose.iter().map(|(r, _)| *r).collect::<Vec<_>>(), |
|
| 353 | 355 | &loose.iter().map(|(_, p)| p.clone()).collect::<Vec<_>>(), |
|
| 354 | 356 | &[], |
|
| 357 | + | ZSTD_LEVEL, |
|
| 355 | 358 | ) |
|
| 359 | + | } |
|
| 360 | + | ||
| 361 | + | /// Fold every pack and loose object into one pack compressed at |
|
| 362 | + | /// `level` (spec §13.4): what an explicit `oxy gc` does, as `git gc` |
|
| 363 | + | /// repacks. Returns the report and how many packs were folded in. |
|
| 364 | + | pub fn repack_all(&self, level: i32) -> Result<(PackReport, usize)> { |
|
| 365 | + | let loose = self.loose_objects()?; |
|
| 366 | + | let (mut refs, paths): (Vec<ObjectRef>, Vec<PathBuf>) = { |
|
| 367 | + | let packs = self.packs.borrow(); |
|
| 368 | + | ( |
|
| 369 | + | packs.iter().flat_map(|p| p.refs.iter().copied()).collect(), |
|
| 370 | + | packs.iter().map(|p| p.path.clone()).collect(), |
|
| 371 | + | ) |
|
| 372 | + | }; |
|
| 373 | + | if loose.is_empty() && paths.len() <= 1 && level == ZSTD_LEVEL { |
|
| 374 | + | return Ok((PackReport::default(), 0)); |
|
| 375 | + | } |
|
| 376 | + | refs.extend(loose.iter().map(|(r, _)| *r)); |
|
| 377 | + | let loose_paths: Vec<PathBuf> = loose.into_iter().map(|(_, p)| p).collect(); |
|
| 378 | + | let n = paths.len(); |
|
| 379 | + | let report = self.write_pack(&refs, &loose_paths, &paths, level)?; |
|
| 380 | + | Ok((report, n)) |
|
| 356 | 381 | } |
|
| 357 | 382 | ||
| 358 | 383 | /// Fold every pack into one when there are more than `MAX_PACKS` |
|
| … | 10 unchanged lines |
||
| 369 | 394 | ) |
|
| 370 | 395 | }; |
|
| 371 | 396 | let n = paths.len(); |
|
| 372 | - | self.write_pack(&refs, &[], &paths)?; |
|
| 397 | + | self.write_pack(&refs, &[], &paths, ZSTD_LEVEL)?; |
|
| 373 | 398 | Ok(n) |
|
| 374 | 399 | } |
|
| 375 | 400 | ||
| … | 65 unchanged lines |
||
| 441 | 466 | .filter(|r| !keep.contains(r)) |
|
| 442 | 467 | .collect() |
|
| 443 | 468 | }; |
|
| 444 | - | self.write_pack(&keep_refs, &[], &old_paths)?; |
|
| 469 | + | self.write_pack(&keep_refs, &[], &old_paths, ZSTD_LEVEL)?; |
|
| 445 | 470 | for r in dropped { |
|
| 446 | 471 | self.remove_sidecars(&r)?; |
|
| 447 | 472 | } |
|
| … | 56 unchanged lines |
||
| 504 | 529 | refs: &[ObjectRef], |
|
| 505 | 530 | loose_paths: &[PathBuf], |
|
| 506 | 531 | old_packs: &[PathBuf], |
|
| 532 | + | level: i32, |
|
| 507 | 533 | ) -> Result<PackReport> { |
|
| 508 | 534 | if refs.is_empty() { |
|
| 509 | 535 | for p in old_packs { |
|
| … | 16 unchanged lines |
||
| 526 | 552 | let mut f = io::BufWriter::new(fs::File::create(&tmp_pack)?); |
|
| 527 | 553 | f.write_all(PACK_MAGIC)?; |
|
| 528 | 554 | let mut offset = PACK_MAGIC.len() as u64; |
|
| 529 | - | for r in &refs { |
|
| 530 | - | let (ty, body) = self.get(r)?; |
|
| 531 | - | let (flags, stored) = if self.is_nocompress(r) { |
|
| 532 | - | (0, body.clone()) |
|
| 533 | - | } else { |
|
| 534 | - | let compressed = zstd::bulk::compress(&body, ZSTD_LEVEL) |
|
| 535 | - | .map_err(|e| StoreError::Corrupt(*r, format!("zstd: {e}")))?; |
|
| 536 | - | if compressed.len() < body.len() { |
|
| 537 | - | (FLAG_ZSTD, compressed) |
|
| 538 | - | } else { |
|
| 539 | - | (0, body.clone()) |
|
| 555 | + | // Read a batch (the store is not shared between threads), |
|
| 556 | + | // compress it on every core, write it in order. |
|
| 557 | + | let mut batches = refs.chunks(1024); |
|
| 558 | + | let mut compressed_batch = Vec::new(); |
|
| 559 | + | loop { |
|
| 560 | + | if compressed_batch.is_empty() { |
|
| 561 | + | let Some(batch) = batches.next() else { break }; |
|
| 562 | + | let mut read: Vec<(ObjectRef, ObjectType, Vec<u8>, bool)> = |
|
| 563 | + | Vec::with_capacity(batch.len()); |
|
| 564 | + | for r in batch { |
|
| 565 | + | let (ty, body) = self.get(r)?; |
|
| 566 | + | read.push((*r, ty, body, self.is_nocompress(r))); |
|
| 540 | 567 | } |
|
| 568 | + | use rayon::prelude::*; |
|
| 569 | + | compressed_batch = read |
|
| 570 | + | .into_par_iter() |
|
| 571 | + | .map(|(r, ty, body, raw)| { |
|
| 572 | + | let packed = if raw { |
|
| 573 | + | None |
|
| 574 | + | } else { |
|
| 575 | + | zstd::bulk::compress(&body, level) |
|
| 576 | + | .ok() |
|
| 577 | + | .filter(|c| c.len() < body.len()) |
|
| 578 | + | }; |
|
| 579 | + | (r, ty, body, packed) |
|
| 580 | + | }) |
|
| 581 | + | .collect::<Vec<_>>(); |
|
| 582 | + | compressed_batch.reverse(); |
|
| 583 | + | } |
|
| 584 | + | let Some((r, ty, body, packed)) = compressed_batch.pop() else { |
|
| 585 | + | continue; |
|
| 586 | + | }; |
|
| 587 | + | let r = &r; |
|
| 588 | + | let (flags, stored) = match packed { |
|
| 589 | + | Some(c) => (FLAG_ZSTD, c), |
|
| 590 | + | None => (0, body.clone()), |
|
| 541 | 591 | }; |
|
| 542 | 592 | let header_len = 1 + 1 + 4 + 4 + 32; |
|
| 543 | 593 | f.write_all(&[type_index(ty), flags])?; |
|
| … | 1297 unchanged lines |
modified docs/benchmarks.md +16 −0
| … | 470 unchanged lines |
||
| 471 | 471 | Verify still loads each commit's history from scratch, which grows with |
|
| 472 | 472 | the length of the history. |
|
| 473 | 473 | ||
| 474 | + | ### Store size (2026-09-30, backlog P1) |
|
| 475 | + | ||
| 476 | + | The Linux tip's store was 448 MB, and 390 MB after `oxy gc`, against |
|
| 477 | + | 311 MB for Git after `git gc`. The file contents cut into the store's |
|
| 478 | + | chunks compress, at zstd level 3, to 312 MB, level with Git; at level 9 |
|
| 479 | + | to 284 MB (12.9 s on four cores), at level 19 to 270 MB (314 s). The |
|
| 480 | + | rest was two things: |
|
| 481 | + | - `oxy gc` left the first pack as written: packs merge only above 16. |
|
| 482 | + | - A text blob holds its lines as a CBOR array, so every line has a |
|
| 483 | + | length header: 62 MB more raw, 35 MB more compressed for the tree's |
|
| 484 | + | 46 308 files of 4 KiB or more. |
|
| 485 | + | ||
| 486 | + | `oxy gc` now repacks everything into one pack at zstd level 9, compressed |
|
| 487 | + | on every core: 357 MB (349 MB pack) in 52 s. The text blob framing is the |
|
| 488 | + | next step (a change format version 3 with raw text blobs). |
|
| 489 | + |
modified docs/guide.md +1 −1
| … | 455 unchanged lines |
||
| 456 | 456 | ## 17. Housekeeping |
|
| 457 | 457 | ||
| 458 | 458 | ``` |
|
| 459 | - | oxy gc # pack loose objects, prune unreachable ones past the grace period |
|
| 459 | + | oxy gc # prune unreachable objects past the grace period, repack into one pack |
|
| 460 | 460 | oxy gc --now # ignore the grace period (only when nothing else is writing) |
|
| 461 | 461 | oxy fsck # check every object, the op log, references, signatures, checkpoints |
|
| 462 | 462 | oxy doctor # check identity, config and remotes, with what to do about each problem |
|
| … | 38 unchanged lines |
modified docs/spec.md +2 −2
| … | 545 unchanged lines |
||
| 546 | 546 | ||
| 547 | 547 | ### 13.4 Packing |
|
| 548 | 548 | ||
| 549 | - | `oxy gc` (and an automatic trigger when loose object count exceeds 4096) writes loose objects into a new pack and merges packs when there are more than 16. Writers SHOULD batch: a snapshot, a fetched bundle, a Git import, or a checkpoint is one write batch, and a batch that grows beyond a small threshold (256 objects or 8 MiB) is written as a single pack with one `fsync` rather than as loose objects, each with its own; smaller batches stay loose so ordinary commits do not multiply packs. Objects of an open batch MUST be readable by the writer before the batch completes. Pack indexes MUST support lookup by ObjectRef prefix and MUST be memory-mappable. A repository with two million objects MUST be openable with under 64 MiB of resident index memory on the Full profile. |
|
| 549 | + | An automatic trigger when the loose object count exceeds 4096 writes loose objects into a new pack and merges packs when there are more than 16; `oxy gc` repacks every surviving object into one pack, compressed harder (zstd level 9, against level 3 for everyday writes) and on every core, as `git gc` repacks. Writers SHOULD batch: a snapshot, a fetched bundle, a Git import, or a checkpoint is one write batch, and a batch that grows beyond a small threshold (256 objects or 8 MiB) is written as a single pack with one `fsync` rather than as loose objects, each with its own; smaller batches stay loose so ordinary commits do not multiply packs. Objects of an open batch MUST be readable by the writer before the batch completes. Pack indexes MUST support lookup by ObjectRef prefix and MUST be memory-mappable. A repository with two million objects MUST be openable with under 64 MiB of resident index memory on the Full profile. |
|
| 550 | 550 | ||
| 551 | 551 | The pack and index formats are fixed so that tools can read them without the implementation: |
|
| 552 | 552 | ||
| … | 424 unchanged lines |
||
| 977 | 977 | ||
| 978 | 978 | --- |
|
| 979 | 979 | ||
| 980 | - | *Changelog.* Draft 0.8r (2026-09-29): an import brings a deleted directory back rather than making a second node for its path, and retires a kept node's other placements (§23); graph keys at commit, lazy text in history-only loads, and a clean `status` from the manifest (§12.4); a parent checkout never moves a subrepo's bookmark backwards, and the pin is read from the nested working copy (§7.8); `bookmark create --upto/--only` (§10.1); the working-copy lock covers every command that writes the working copy; a workspace changed by two concurrent operations is an error (§12.1). Draft 0.8q (2026-09-29): change format version 2 stores large inserts as text blobs, so a change does not grow with the files it adds (§6.1, §7.4); no object may exceed 1 GiB (§13.2). Draft 0.8p (2026-09-29): Git import derives new NodeIds from the commit, making imports deterministic (§23). Draft 0.8o (2026-09-29): execution receipts, the `receipt` object and attestation (§14.6). Draft 0.8n (2026-09-29): line history, `oxy log -L` (§27). Draft 0.8m (2026-09-29): resolving replace conflicts (§9.3). Draft 0.8l (2026-09-28): deleting a reference on a remote; first serials from the clock (§16.4). Draft 0.8k (2026-09-28): the manifest records graph keys, so unchanged files are neither read nor rendered (§12.4). Draft 0.8j (2026-09-28): checkpoint payload version 2, file graphs by reference (§11). |
|
| 980 | + | *Changelog.* Draft 0.8r (2026-09-29): `oxy gc` repacks into one pack at a higher compression level (§13.4); an import brings a deleted directory back rather than making a second node for its path, and retires a kept node's other placements (§23); graph keys at commit, lazy text in history-only loads, and a clean `status` from the manifest (§12.4); a parent checkout never moves a subrepo's bookmark backwards, and the pin is read from the nested working copy (§7.8); `bookmark create --upto/--only` (§10.1); the working-copy lock covers every command that writes the working copy; a workspace changed by two concurrent operations is an error (§12.1). Draft 0.8q (2026-09-29): change format version 2 stores large inserts as text blobs, so a change does not grow with the files it adds (§6.1, §7.4); no object may exceed 1 GiB (§13.2). Draft 0.8p (2026-09-29): Git import derives new NodeIds from the commit, making imports deterministic (§23). Draft 0.8o (2026-09-29): execution receipts, the `receipt` object and attestation (§14.6). Draft 0.8n (2026-09-29): line history, `oxy log -L` (§27). Draft 0.8m (2026-09-29): resolving replace conflicts (§9.3). Draft 0.8l (2026-09-28): deleting a reference on a remote; first serials from the clock (§16.4). Draft 0.8k (2026-09-28): the manifest records graph keys, so unchanged files are neither read nor rendered (§12.4). Draft 0.8j (2026-09-28): checkpoint payload version 2, file graphs by reference (§11). |
|
| 981 | 981 | ||
| 982 | 982 | Draft 0.8i (2026-09-28): portability rules for exec bits and path names (§7.7); found by verifying a real Git import commit by commit: deletions depend on the inserters of the lines right after them, and replacements on the change that set their old blob (§8.1); replace records are superseded causally, so a reverted binary keeps its blob (§9.3); merges resolve opaque conflicts to the commit's blob, and `oxy git verify` (§23). Draft 0.8h (2026-09-28): `oxy fsck` (§21.1); durability rules and the operation head as commit point (§13.6); the operation head moves by compare-and-swap and concurrent operations are merged (§12.1); `oxy doctor` (§21.2). Draft 0.8g (2026-09-27): delegates of a rule's identities may sign reference attestations (§14.5); local-path pushes bind unbound repositories like HTTP ones. Draft 0.8f (2026-09-27): challenge signatures (§14.3) for hosts. Draft 0.8e (2026-09-27): anonymous changes (zero author) are authenticated by a seal from any key, so imported and pre-identity history can be pushed under a policy (§14.4). Draft 0.8d (2026-09-27): unbound hosted repositories adopt the first pusher's id (§16.1). Draft 0.8c (2026-09-27): bare repositories (`oxy init --bare`, §12.2: no workspace, no working copy; hosting) and the reference server as an embeddable library (§15.1). Draft 0.8b (2026-09-27): the machine interface for editors (§24): `root`, `cat`, `version`, `hunks`, `discard`, and `--json` on the reporting commands. |
|
| 983 | 983 | ||
| … | 61 unchanged lines |
modified src/cli/mod.rs +1 −1
| … | 286 unchanged lines |
||
| 287 | 287 | #[arg(long)] |
|
| 288 | 288 | json: bool, |
|
| 289 | 289 | }, |
|
| 290 | - | /// Pack loose objects and prune unreachable ones older than the grace period |
|
| 290 | + | /// Prune unreachable objects older than the grace period and repack everything into one pack |
|
| 291 | 291 | Gc { |
|
| 292 | 292 | /// Ignore the grace period (core.gc-grace) and prune immediately |
|
| 293 | 293 | #[arg(long)] |
|
| … | 927 unchanged lines |
modified src/cli/workspace.rs +2 −2
| … | 1040 unchanged lines |
||
| 1041 | 1041 | sp.clear(); |
|
| 1042 | 1042 | if r.packed.objects > 0 { |
|
| 1043 | 1043 | println!( |
|
| 1044 | - | "Packed {}: {} bytes -> {} bytes ({:.0}%).", |
|
| 1045 | - | plural(r.packed.objects, "loose object"), |
|
| 1044 | + | "Packed {} into one pack: {} bytes -> {} bytes ({:.0}%).", |
|
| 1045 | + | plural(r.packed.objects, "object"), |
|
| 1046 | 1046 | r.packed.raw_bytes, |
|
| 1047 | 1047 | r.packed.packed_bytes, |
|
| 1048 | 1048 | 100.0 * r.packed.packed_bytes as f64 / r.packed.raw_bytes.max(1) as f64 |
|
| … | 828 unchanged lines |