OXYGENHUB

Fold the older one-list keys on gc and stop reading them 8156967b882c

A complete fold (oxy gc on a store that is not this disk's directory)
first copies every older one-list key (sigs/, seals/, receipts/,
approvals/, certs/, accepted-lists/) into per-object entries, keeping an
entry already there, then writes pack-legacy-folded once. A reader whose
pack listing shows the marker no longer GETs those keys on each lookup
or lists them for the indexed states, folded store or not. The older
keys are left in place for older readers.

40 lookups on the test store: 85 requests before (40 LISTs, 40 missed
legacy GETs), 7 after (one journal read, the list, five signatures).

Closes #116
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 5b0841c895da
When
· 2026-10-19 20:55 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
8156967b882c3f90f0ccede4f615aedbb509ef9abe235948d7721d650a6441b2
Depends on
8f10a07a1dbe 8fd78eb52ad2 e4595038502a ef7d4ea6f33b ff0af1ee299d
Depended on by
771661aa50f4
In bookmarks
identical-sides keyless-tags main push-notes subrepo-pin-state
In tags
v0.10.0 v0.11.0 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
12 atoms, 0 tree operations, 2 files

Browse the tree at this change

modified CHANGELOG.md +1 −1
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 -
- **A repository kept in a bucket finds signatures and seals without a LIST each (#116).** Every lookup of an object's signatures, seals, receipts, approvals or certificate listed a prefix in the bucket, and most of these lookups found nothing. Writers now also add each such entry to a journal (`pack-entries`, updated by compare-and-swap), and `oxy gc` folds them all into one list. Once a store has been folded, a reader reads the lists and the journal once per open and looks everything up from them: 40 lookups went from 40 LISTs to one journal read. Older readers are unaffected, because every entry still has its own key. Entries written by oxy 0.4.6 or older after the fold are missed until the next `oxy gc`, so upgrade every client that writes to the bucket first, then run `oxy gc` once. A server's `/health` counts journal reads as `read_journal`. A repository's own directory works as before.
5 +
- **A repository kept in a bucket finds signatures and seals without a LIST each (#116).** Every lookup of an object's signatures, seals, receipts, approvals or certificate listed a prefix in the bucket, and most of these lookups found nothing. Writers now also add each such entry to a journal (`pack-entries`, updated by compare-and-swap), and `oxy gc` folds them all into one list. Once a store has been folded, a reader reads the lists and the journal once per open and looks everything up from them. The first `oxy gc` also copies the one-list keys of much older stores (`sigs/`, `seals/`, `certs/`, …) into the current layout and marks them folded, so readers stop requesting them; until now nearly every lookup made one of these requests and found nothing. On the test store, 40 lookups went from 85 requests (40 LISTs) to 7 (one journal read and no LIST). Older readers are unaffected, because every entry still has its own key. Entries written by oxy 0.4.6 or older after the fold are missed until the next `oxy gc`, so upgrade every client that writes to the bucket first, then run `oxy gc` once. A server's `/health` counts journal reads as `read_journal`. A repository's own directory works as before.
6 6
7 7
- **Fetch checks the hub's transparency log (oxygenhub#238).** A hub that keeps a log sends its latest checkpoint with every refs answer, and each bookmark's attestation with its log entry and inclusion proof. The first fetch pins the hub's log key in `<oxy home>/tlog/<hub>` with the last checkpoint seen. A later checkpoint must be signed by that key and extend the last one: a smaller log, another root at the same size or a failed consistency proof fails the fetch, saying the hub showed two histories. An attestation not logged yet, or not proved, is only a note for now, printed by `fetch` and `pull` and in `--json`. A hub that comes back with a new log key (a new key, a reused address, a rename) is a loud note, and the new key is pinned. Older hubs and clients ignore the new fields.
8 8
…
243 unchanged lines
modified crates/oxy-core/src/store.rs +155 −5
…
2345 unchanged lines
2346 2346
2347 2347
    // ---- the journal and entry lists (spec §13.7, #116)
2348 2348
2349 -
    /// Whether lookups still read the one-list keys of older stores.
2349 +
    /// Whether lookups still read the one-list keys of older stores: until
2350 +
    /// a complete fold has copied them into per-object entries and the
2351 +
    /// pack listing shows its marker (spec §13.7).
2350 2352
    fn reads_legacy(&self) -> bool {
2351 -
        true
2353 +
        !self.legacy_folded.get()
2352 2354
    }
2353 2355
2356 +
    /// Copy every older one-list key's contents into per-object entries,
2357 +
    /// keeping an entry already there, then write the marker that tells
2358 +
    /// readers to stop reading those keys. The keys themselves are left
2359 +
    /// for older readers. A list that cannot be read is passed over, and
2360 +
    /// said.
2361 +
    fn fold_legacy(&self, progress: Progress) -> Result<()> {
2362 +
        let mut copied = 0usize;
2363 +
        for (index, legacy) in ENTRY_INDEXES {
2364 +
            for e in self
2365 +
                .backend
2366 +
                .list(&format!("{legacy}/"))
2367 +
                .map_err(backend_err)?
2368 +
            {
2369 +
                // <legacy>/<hh>/<rest>
2370 +
                let mut parts = e.key.splitn(3, '/');
2371 +
                let (Some(_), Some(hh), Some(rest)) = (parts.next(), parts.next(), parts.next())
2372 +
                else {
2373 +
                    continue;
2374 +
                };
2375 +
                let Some(r) = ObjectRef::from_hex(&format!("{hh}{rest}")) else {
2376 +
                    continue;
2377 +
                };
2378 +
                let Some(bytes) = get_optional(self.backend.as_ref(), &e.key)? else {
2379 +
                    continue;
2380 +
                };
2381 +
                if index == "sig" {
2382 +
                    let Ok(sigs) = Signature::decode_list(&bytes) else {
2383 +
                        progress(&format!("{}: not a list of signatures, passed over", e.key));
2384 +
                        continue;
2385 +
                    };
2386 +
                    for sig in sigs {
2387 +
                        let key = format!("{}{}", fanout_prefix("sig", &r), sig.key.to_hex());
2388 +
                        let one = Signature::encode_list(std::slice::from_ref(&sig));
2389 +
                        put_new_or_existing(self.backend.as_ref(), &key, &one)?;
2390 +
                        copied += 1;
2391 +
                    }
2392 +
                } else {
2393 +
                    for v in String::from_utf8_lossy(&bytes)
2394 +
                        .lines()
2395 +
                        .filter_map(ObjectRef::from_hex)
2396 +
                    {
2397 +
                        let key = format!("{}{}", fanout_prefix(index, &r), v.to_hex());
2398 +
                        put_new_or_existing(self.backend.as_ref(), &key, b"")?;
2399 +
                        copied += 1;
2400 +
                    }
2401 +
                }
2402 +
            }
2403 +
        }
2404 +
        if copied > 0 {
2405 +
            progress(&format!("{copied} entries copied out of older lists"));
2406 +
        }
2407 +
        put_new_or_existing(
2408 +
            self.backend.as_ref(),
2409 +
            LEGACY_FOLDED_KEY,
2410 +
            format!("{}\n", now_ms()).as_bytes(),
2411 +
        )?;
2412 +
        self.legacy_folded.set(true);
2413 +
        Ok(())
2414 +
    }
2415 +
2354 2416
    /// The entry keys under `prefix` (an index's `<index>/` or one
2355 2417
    /// object's `<index>/<hh>/<rest>/`), sorted: on a folded store from
2356 2418
    /// its lists and journal, with no request; otherwise by listing.
…
157 unchanged lines
2514 2576
    }
2515 2577
2516 2578
    /// Fold every signature and index entry into one complete entry list
2517 -
    /// (spec §13.7), what a collection does last: the journal's lines and
2579 +
    /// (spec §13.7), what a collection does last: first the older
2580 +
    /// one-list keys into per-object entries, once; then the journal's lines and
2518 2581
    /// a listing of every entry prefix, written as a new list, every list
2519 2582
    /// the packs' listing showed retired, and the journal lines read
2520 2583
    /// marked folded. Nothing when the store is this disk's directory, or
…
4 unchanged lines
2525 2588
            return Ok(false);
2526 2589
        }
2527 2590
        self.reload_packs()?;
2591 +
        if !self.legacy_folded.get() {
2592 +
            progress("folding older lists of signatures and index entries");
2593 +
            self.fold_legacy(progress)?;
2594 +
        }
2528 2595
        let (journal, _) = self.read_record(JOURNAL_KEY)?;
2529 2596
        progress("folding signatures and index entries");
2530 2597
        let mut all: std::collections::BTreeSet<String> = journal.keys().cloned().collect();
…
1750 unchanged lines
4281 4348
        let reader = f.open();
4282 4349
        assert!(reader.folded.get());
4283 4350
        check(&reader);
4351 +
4352 +
        // The older lists' entries are per-object entries now, and the
4353 +
        // marker is there.
4354 +
        assert!(f.backend.inner.has(LEGACY_FOLDED_KEY).unwrap());
4355 +
        assert!(f
4356 +
            .backend
4357 +
            .inner
4358 +
            .has(&format!("{}{}", fanout_prefix("sig", &r2), s2.key.to_hex()))
4359 +
            .unwrap());
4360 +
        assert!(f
4361 +
            .backend
4362 +
            .inner
4363 +
            .has(&format!(
4364 +
                "{}{}",
4365 +
                fanout_prefix("receipt", &r2),
4366 +
                ObjectRef([7; 32]).to_hex()
4367 +
            ))
4368 +
            .unwrap());
4284 4369
4285 4370
        // An older writer after the fold: missed until the next one.
4286 4371
        let r3 = w.put(ObjectType::Chunk, b"three").unwrap();
…
140 unchanged lines
4427 4512
        eprintln!("40 lookups after the fold: {}", f.backend.summary());
4428 4513
        assert_eq!(before_list, 40);
4429 4514
        assert_eq!(f.backend.n("list"), 0);
4515 +
        assert_eq!(f.backend.n("get_legacy"), 0, "the older lists are folded");
4430 4516
        assert_eq!(f.backend.n("read_journal"), 1);
4431 -
        let legacy = |b: &Counted| b.n("get_legacy") + b.n("list_legacy");
4432 4517
        assert!(
4433 -
            f.backend.total() - legacy(&f.backend) < (before_total - 40) / 4,
4518 +
            f.backend.total() < before_total / 10,
4434 4519
            "{}",
4435 4520
            f.backend.summary()
4436 4521
        );
4522 +
    }
4523 +
4524 +
    /// Once a fold has copied the older lists into per-object entries and
4525 +
    /// written its marker, readers stop reading them, folded or not; the
4526 +
    /// answers are the same.
4527 +
    #[test]
4528 +
    fn the_legacy_marker_stops_reads_of_the_older_lists() {
4529 +
        let f = Fixture::new();
4530 +
        let w = f.open();
4531 +
        let r = w.put(ObjectType::Chunk, b"old").unwrap();
4532 +
        let sig = SecretKey {
4533 +
            ed_seed: [4; 32],
4534 +
            ml_seed: [5; 32],
4535 +
        }
4536 +
        .sign(Domain::Change, &ObjectRef([4; 32]), &r);
4537 +
        f.backend
4538 +
            .put_new(
4539 +
                &legacy_key("sigs", &r),
4540 +
                &Signature::encode_list(std::slice::from_ref(&sig)),
4541 +
            )
4542 +
            .unwrap();
4543 +
        for (legacy, v) in [("seals", 9u8), ("certs", 8), ("accepted-lists", 6)] {
4544 +
            f.backend
4545 +
                .put_new(
4546 +
                    &legacy_key(legacy, &r),
4547 +
                    format!("{}\n", ObjectRef([v; 32]).to_hex()).as_bytes(),
4548 +
                )
4549 +
                .unwrap();
4550 +
        }
4551 +
        let check = |s: &Store| {
4552 +
            assert_eq!(s.signatures(&r).unwrap(), vec![sig.clone()]);
4553 +
            assert_eq!(s.seals_for(&r).unwrap(), vec![ObjectRef([9; 32])]);
4554 +
            assert_eq!(
4555 +
                s.index_get("cert", "certs", &r).unwrap(),
4556 +
                vec![ObjectRef([8; 32])]
4557 +
            );
4558 +
            assert_eq!(
4559 +
                s.index_get_pub("accepted", &r).unwrap(),
4560 +
                vec![ObjectRef([6; 32])]
4561 +
            );
4562 +
        };
4563 +
        let before = f.open();
4564 +
        f.backend.reset();
4565 +
        check(&before);
4566 +
        assert_eq!(f.backend.n("get_legacy"), 4, "{}", f.backend.summary());
4567 +
4568 +
        assert!(fold(&w));
4569 +
        let folded = f.open();
4570 +
        f.backend.reset();
4571 +
        check(&folded);
4572 +
        assert_eq!(f.backend.n("get_legacy") + f.backend.n("list"), 0);
4573 +
4574 +
        // Not folded (its lists gone), but marked: per-object listings,
4575 +
        // still no older lists.
4576 +
        for e in f.backend.inner.list("pack-").unwrap() {
4577 +
            if is_entry_list(&e.key) {
4578 +
                f.backend.inner.delete(&e.key).unwrap();
4579 +
            }
4580 +
        }
4581 +
        let marked = f.open();
4582 +
        assert!(!marked.folded.get() && marked.legacy_folded.get());
4583 +
        f.backend.reset();
4584 +
        check(&marked);
4585 +
        assert_eq!(f.backend.n("get_legacy"), 0, "{}", f.backend.summary());
4586 +
        assert_eq!(f.backend.n("list"), 4);
4437 4587
    }
4438 4588
}