OXYGENHUB

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