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 |