OXYGENHUB

Entries stay fresh in a long-lived handle: re-read after the pack listing's 30 s 771661aa50f4

A handle that read a folded store's entries more than 30 s ago (the time
a cloud client keeps the pack listing) lists its packs again and re-reads
the journal on its next lookup, reading only entry lists that are new
(lists are written once, so those already read are kept). Another
process's signature, seal or approval is seen within 30 s with no
refresh, as its pack is, even by a handle a server keeps open across
requests. refresh() and any pack reload still force it. Spec §13.7,
docs/strata.md and the changelog say so.

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 21:11 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
771661aa50f4de9919098ec2eb4c52b543093eebcb52dd7dc140e2daeb00b2c0
Depends on
8156967b882c 8b3c9b2ba502 8e1194860efa 8f10a07a1dbe 8fd78eb52ad2 d069701a4997 e4595038502a ef7d4ea6f33b ff0af1ee299d
Depended on by
2b95bc24d7d6
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
15 atoms, 0 tree operations, 4 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. 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.
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 when it opens it, and again after 30 seconds (as long as the pack listing is kept), and looks everything up from them. Another writer's entry is seen within 30 seconds, as its pack would be, even by a handle a server keeps open. 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 +87 −8
…
110 unchanged lines
111 111
/// Unfolded journal lines a writer lets the journal hold before it folds
112 112
/// them into a partial list.
113 113
const JOURNAL_FOLD_AT: usize = 1000;
114 +
/// How long a handle answers lookups from the entries it read before it
115 +
/// reads the pack listing and the journal again (spec §13.7): the
116 +
/// interval a cloud client keeps the pack listing for, so another
117 +
/// writer's entry is seen as soon as its pack would be.
118 +
pub const ENTRIES_FRESH: std::time::Duration = std::time::Duration::from_secs(30);
114 119
/// The per-object entry indexes, each with the one-list name older
115 120
/// stores kept it under.
116 121
const ENTRY_INDEXES: [(&str, &str); 6] = [
…
373 unchanged lines
490 495
    /// The entry keys of a folded store, read from its lists and journal
491 496
    /// on the first lookup after the packs were loaded.
492 497
    entries: RefCell<Option<std::collections::BTreeSet<String>>>,
498 +
    /// When `entries` was read; past [`ENTRIES_FRESH`] it is read again.
499 +
    entries_read: std::cell::Cell<Option<std::time::Instant>>,
500 +
    /// Entry lists already read, by key: written once, so never read
501 +
    /// twice; those no longer live are let go.
502 +
    lists_read: RefCell<std::collections::HashMap<String, Arc<Vec<String>>>>,
493 503
    /// Entry keys written in an open batch, added to the journal when the
494 504
    /// batch is flushed.
495 505
    journal_queue: RefCell<Vec<String>>,
…
168 unchanged lines
664 674
            folded: std::cell::Cell::new(false),
665 675
            legacy_folded: std::cell::Cell::new(false),
666 676
            entries: RefCell::new(None),
677 +
            entries_read: std::cell::Cell::new(None),
678 +
            lists_read: RefCell::new(Default::default()),
667 679
            journal_queue: RefCell::new(Vec::new()),
668 680
        };
669 681
        store
…
1774 unchanged lines
2444 2456
        if self.local || !self.folded.get() {
2445 2457
            return Ok(None);
2446 2458
        }
2459 +
        // Read over the interval ago: list the packs again (a cloud
2460 +
        // client keeps that listing as long), loading what changed, and
2461 +
        // read the journal again, so another writer's entry is seen as
2462 +
        // its pack would be.
2463 +
        if self
2464 +
            .entries_read
2465 +
            .get()
2466 +
            .is_some_and(|t| t.elapsed() >= ENTRIES_FRESH)
2467 +
        {
2468 +
            *self.entries.borrow_mut() = None;
2469 +
            self.reload_if_changed()?;
2470 +
            if !self.folded.get() {
2471 +
                return Ok(None);
2472 +
            }
2473 +
        }
2447 2474
        if self.entries.borrow().is_none() {
2448 2475
            let Some(set) = self.read_entries()? else {
2449 2476
                return Ok(None);
…
15 unchanged lines
2465 2492
            }
2466 2493
            let mut set = std::collections::BTreeSet::new();
2467 2494
            let lists = self.entry_lists.borrow().clone();
2495 +
            self.lists_read
2496 +
                .borrow_mut()
2497 +
                .retain(|k, _| lists.contains(k));
2468 2498
            for key in &lists {
2469 -
                match get_optional(self.backend.as_ref(), key)? {
2470 -
                    Some(bytes) => set.extend(decode_entry_list(key, &bytes)?),
2471 -
                    None if attempt == 0 => {
2472 -
                        self.reload_packs()?;
2473 -
                        continue 'attempt;
2474 -
                    }
2475 -
                    None => return Ok(None),
2476 -
                }
2499 +
                let known = self.lists_read.borrow().get(key).cloned();
2500 +
                let keys = match known {
2501 +
                    Some(keys) => keys,
2502 +
                    None => match get_optional(self.backend.as_ref(), key)? {
2503 +
                        Some(bytes) => {
2504 +
                            let keys = Arc::new(decode_entry_list(key, &bytes)?);
2505 +
                            self.lists_read
2506 +
                                .borrow_mut()
2507 +
                                .insert(key.clone(), keys.clone());
2508 +
                            keys
2509 +
                        }
2510 +
                        None if attempt == 0 => {
2511 +
                            self.reload_packs()?;
2512 +
                            continue 'attempt;
2513 +
                        }
2514 +
                        None => return Ok(None),
2515 +
                    },
2516 +
                };
2517 +
                set.extend(keys.iter().cloned());
2477 2518
            }
2478 2519
            set.extend(self.read_record(JOURNAL_KEY)?.0.into_keys());
2479 2520
            set.extend(self.journal_queue.borrow().iter().cloned());
2521 +
            self.entries_read.set(Some(std::time::Instant::now()));
2480 2522
            return Ok(Some(set));
2481 2523
        }
2482 2524
        Ok(None)
…
2101 unchanged lines
4584 4626
        check(&marked);
4585 4627
        assert_eq!(f.backend.n("get_legacy"), 0, "{}", f.backend.summary());
4586 4628
        assert_eq!(f.backend.n("list"), 4);
4629 +
    }
4630 +
4631 +
    /// A handle that has read the entries finds another process's new
4632 +
    /// signature once the pack listing's interval has passed, with no
4633 +
    /// refresh, at the cost of one pack listing and one journal read.
4634 +
    #[test]
4635 +
    fn a_long_lived_handle_sees_new_entries_within_the_interval() {
4636 +
        let f = Fixture::new();
4637 +
        let w = f.open();
4638 +
        let r1 = w.put(ObjectType::Chunk, b"one").unwrap();
4639 +
        let s1 = sign(&w, &r1, 1);
4640 +
        assert!(fold(&w));
4641 +
        let a = f.open();
4642 +
        assert_eq!(a.signatures(&r1).unwrap(), vec![s1]);
4643 +
4644 +
        let b = f.open();
4645 +
        let r2 = b.put(ObjectType::Chunk, b"two").unwrap();
4646 +
        let s2 = sign(&b, &r2, 2);
4647 +
        b.index_approval(&r2, &ObjectRef([3; 32])).unwrap();
4648 +
        // Within the interval: answered from what was read.
4649 +
        assert!(a.signatures(&r2).unwrap().is_empty());
4650 +
4651 +
        // The interval passes, as the pack listing's does.
4652 +
        let t = a.entries_read.get().unwrap();
4653 +
        a.entries_read.set(t.checked_sub(ENTRIES_FRESH));
4654 +
        f.backend.reset();
4655 +
        assert_eq!(a.signatures(&r2).unwrap(), vec![s2]);
4656 +
        assert_eq!(a.approvals_for(&r2).unwrap(), vec![ObjectRef([3; 32])]);
4657 +
        assert_eq!(f.backend.n("read_journal"), 1, "{}", f.backend.summary());
4658 +
        assert_eq!(f.backend.n("list_packs"), 1, "{}", f.backend.summary());
4659 +
        assert_eq!(f.backend.n("list"), 0, "{}", f.backend.summary());
4660 +
        assert_eq!(
4661 +
            f.backend.n("get"),
4662 +
            1,
4663 +
            "the signature; the list already read is not read again: {}",
4664 +
            f.backend.summary()
4665 +
        );
4587 4666
    }
4588 4667
}
modified docs/spec.md +1 −1
…
594 unchanged lines
595 595
- **The journal**, `pack-entries`: a reference record, updated by compare-and-swap as `pack-retired` is, of lines `<folded at, wall ms; 0 until folded> <entry key>`. After writing an entry's own key, a writer adds the key to the journal. A write batch (§13.4) adds all of its entries in one update when it is flushed, before the operation head moves. A writer that loses the race reads the record again and retries (up to 32 times), so concurrent writers never lose each other's lines.
596 596
- **Entry lists**: zstd-compressed UTF-8 text, written once: the line `oxy-entries 1`, then one entry key per line, sorted. `pack-<id>.entries` is **complete**: it holds every entry the store held when it was written. `pack-<id>.entries-part` is **partial**: it holds the journal lines a writer folded out. The lists are named under `pack-` so that the pack listing every open makes already finds them. They are not tied to a pack: a repack by an implementation that does not know them cannot hide them, and a collection with nothing to repack still folds.
597 597
- **Folding.** `oxy gc` (and a forge's collection) folds **completely**. It reads the journal, lists every entry prefix, writes a complete list of both, retires every list it read (in `pack-retired`, like a pack: a collection deletes it an hour later), and marks the journal lines it read as folded. When nothing changed since the last complete list, no list is written. A collection that deletes an object deletes its entry keys and their journal lines too, and the next complete list leaves them out. A writer whose update leaves more than 1000 unfolded lines folds them into a partial list and marks them. The next update after a folded line's hour (§13.4) drops it. Until then, a reader whose pack listing predates the fold still finds every entry, in the old lists or in the journal. Lines added while a fold runs are not marked, so they stay.
598 -
- **Reading.** A store is **folded** when its live lists (listed and not retired) include a complete one. On its first lookup, a handle on a folded store reads every live list and the journal, folded lines included, and answers every lookup from that set. It adds what it writes itself, and it never lists the per-object prefixes or reads the older lists. A signature's bytes are still read from its own key, which a cache may keep, since that key is written once. A handle reads the lists and the journal again when it loads its packs again (§13.4) or is refreshed. On a store that is not folded (no collection has folded it since writers kept a journal, or another implementation's collection retired its lists), a lookup lists the per-object prefix as before.
598 +
- **Reading.** A store is **folded** when its live lists (listed and not retired) include a complete one. On its first lookup, a handle on a folded store reads every live list and the journal, folded lines included, and answers every lookup from that set. It adds what it writes itself, and it never lists the per-object prefixes or reads the older lists. A signature's bytes are still read from its own key, which a cache may keep, since that key is written once. A handle reads them again when it loads its packs again (§13.4) or is refreshed, and on the first lookup more than 30 seconds after it last read them. At that point it lists its packs again, reads only the lists that are new, and reads the journal. 30 seconds is as long as a cloud client keeps the pack listing, so another writer's entry is seen within that interval, as another writer's pack is, even by a handle a server keeps open across requests. On a store that is not folded (no collection has folded it since writers kept a journal, or another implementation's collection retired its lists), a lookup lists the per-object prefix as before.
599 599
- **Older lists.** Before listing the entry prefixes, a complete fold copies every older one-list key's contents into per-object entries, keeping an entry already there. It then writes the marker `pack-legacy-folded` once. A reader whose pack listing shows the marker does not read the older lists. Nothing writes them any more.
600 600
601 601
**Older writers.** Writers that predate the journal (oxy 0.4.6 and before) write only the per-object keys. Readers of a folded store miss what such a writer adds until the next complete fold picks it up. A store becomes folded at its first complete fold. So the operator of a repository kept in a bucket first upgrades every writer to it (clients with `storage.url`, runners, the forge), then runs `oxy gc` there, or lets the forge's collection run. After that, a lookup costs no request apart from reading a signature's own key. A forge that collects nightly picks up an older writer's entries within a day. A repository's own directory (`.oxy/objects`) keeps no journal or lists: reading a directory costs no request, so its lookups list the per-object prefixes as before.
…
532 unchanged lines
modified docs/strata.md +8 −6
…
143 unchanged lines
144 144
- **Signatures and seals:** each one is stored under a key of its own,
145 145
  as in any repository. A bucket can only find those keys with a LIST
146 146
  request, so the store also keeps a journal of them (`pack-entries`)
147 -
  and lists of them (`pack-<id>.entries`) that are read once per open
148 -
  (spec §13.7). After every client that writes to the bucket runs this
149 -
  version of oxy, run `oxy gc` once. From then on, looking up a
150 -
  signature or a seal sends no LIST request. A client still on oxy
151 -
  0.4.6 or older writes signatures that the others miss until the next
152 -
  `oxy gc`, so upgrade every client first.
147 +
  and lists of them (`pack-<id>.entries`). These are read when the
148 +
  repository is opened and again every 30 seconds (spec §13.7). After
149 +
  every client that writes to the bucket runs this version of oxy, run
150 +
  `oxy gc` once. From then on, looking up a signature or a seal sends no
151 +
  LIST request, and another client's new signature is seen within 30
152 +
  seconds. A client still on oxy 0.4.6 or older writes signatures that
153 +
  the others miss until the next `oxy gc`, so upgrade every client
154 +
  first.
153 155
154 156
A repository kept on a store holds:
155 157
…
54 unchanged lines