OXYGENHUB

Signatures and index entries from a journal and entry lists, not a LIST per lookup ef7d4ea6f33b

On a store that is not this disk's directory, every signature and index
entry key a writer writes is also added to the pack-entries journal
(compare-and-swap, retried like pack-retired; a batch adds its keys in
one update when flushed). oxy gc folds the journal and a listing of every
entry prefix into a complete list, pack-<id>.entries, retiring the lists
it replaces and marking the journal lines folded (kept an hour); past
1000 unfolded lines a writer folds them into a partial list. A store
with a live complete list is folded: a handle reads its lists and the
journal once, on its first lookup, and answers from them, with no LIST.
Unfolded stores list as before. A collection takes deleted entries out
of the journal. Cloud stores count journal reads as read_journal.

40 lookups on the test store: 40 LISTs before, one journal read after.

Refs #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:50 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
ef7d4ea6f33bb293f6624b63952d17400c9a42e9c4def93de17a676b7b65c45e
Depends on
192fad1e46a2 1983686fb7a7 1e0fbb47ab90 30644f1ecb7b 3a4b3e018326 4dcb8c0916b7 648189388f1a 75167672ebcd 767ac6cd0545 8d80356371c5 8f10a07a1dbe 8fd78eb52ad2 9ae4d15a0e4b ba160f60219e ce14f0d68b15 e4595038502a fc1a466a1e66 ff0af1ee299d
Depended on by
2b95bc24d7d6 771661aa50f4 8156967b882c
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
55 atoms, 0 tree operations, 4 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
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.
6 +
5 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.
6 8
7 9
- **`oxy tlog verify` audits a hub's whole log (oxygenhub#238).** It accepts the hub's checkpoint by the same rules as fetch, downloads every entry and requires them to make the root the checkpoint signs, then lists this repository's entries, a key's (`--key`) or all of them (`--all`); `--json` too. It also shows which witnesses cosigned and when.
…
242 unchanged lines
modified crates/oxy-core/src/repo/gc.rs +5 −1
…
30 unchanged lines
31 31
    /// than the grace period (`now` ignores it, but not the retired
32 32
    /// files' hour), then repack everything into one pack (spec §13.4).
33 33
    /// The packs and loose objects this repack replaces are retired in
34 -
    /// turn, for the next collection.
34 +
    /// turn, for the next collection. A store that is not this disk's
35 +
    /// directory then folds its signatures and index entries (§13.7).
35 36
    pub fn gc(&self, now: bool) -> Result<GcReport> {
36 37
        self.gc_with(now, &mut |_| {})
37 38
    }
…
42 unchanged lines
80 81
        let (packed, merged_packs) = self
81 82
            .store
82 83
            .repack_all_with(crate::store::REPACK_ZSTD_LEVEL, progress)?;
84 +
        // Last, on a store that is not this disk's directory, every
85 +
        // signature and index entry into one list (spec §13.7, #116).
86 +
        self.store.fold_entries(progress)?;
83 87
        Ok(GcReport {
84 88
            packed,
85 89
            pruned,
…
158 unchanged lines
modified crates/oxy-core/src/store.rs +916 −34
…
25 unchanged lines
26 26
//! object (`sigs/`, `seals/`, `receipts/`), which are still read.
27 27
//! `pack-retired` records the packs and loose objects a repack replaced,
28 28
//! which stay for readers until a collection an hour later (oxygenhub#249).
29 +
//!
30 +
//! A store that is not this disk's directory, where finding an object's
31 +
//! entries is a LIST request, also keeps every entry key in the
32 +
//! `pack-entries` journal and, once a collection folds them, in entry
33 +
//! lists (`pack-<id>.entries`), read once per open (spec §13.7, #116).
29 34
30 35
use crate::attest::Attest;
31 36
use crate::hash::{hash_object, header, ObjectRef, ObjectType};
…
54 unchanged lines
86 91
/// thousands of objects it is about to store would otherwise list the
87 92
/// packs for each.
88 93
const HAS_RELIST_AFTER: std::time::Duration = std::time::Duration::from_secs(1);
94 +
95 +
/// The journal of signatures and index entries (spec §13.7): every entry
96 +
/// key a writer on a store that is not this disk's directory wrote since
97 +
/// the last fold, one line each, `<folded at, wall ms; 0 until folded>
98 +
/// <key>`, as the retired record's lines are. A reference record updated
99 +
/// by compare-and-swap, so concurrent writers never lose each other's
100 +
/// lines; read once per open, instead of a listing per lookup.
101 +
pub const JOURNAL_KEY: &str = "pack-entries";
102 +
/// A complete entry list (spec §13.7): every entry a fold found.
103 +
const LIST_SUFFIX: &str = ".entries";
104 +
/// A partial entry list: journal lines a writer folded out.
105 +
const PART_SUFFIX: &str = ".entries-part";
106 +
/// The first line of an entry list.
107 +
const LIST_MAGIC: &str = "oxy-entries 1";
108 +
/// Written once a collection has copied the older one-list keys into
109 +
/// per-object entries: readers stop looking for them.
110 +
const LEGACY_FOLDED_KEY: &str = "pack-legacy-folded";
111 +
/// Unfolded journal lines a writer lets the journal hold before it folds
112 +
/// them into a partial list.
113 +
const JOURNAL_FOLD_AT: usize = 1000;
114 +
/// The per-object entry indexes, each with the one-list name older
115 +
/// stores kept it under.
116 +
const ENTRY_INDEXES: [(&str, &str); 6] = [
117 +
    ("sig", "sigs"),
118 +
    ("seal", "seals"),
119 +
    ("receipt", "receipts"),
120 +
    ("approval", "approvals"),
121 +
    ("cert", "certs"),
122 +
    ("accepted", "accepted-lists"),
123 +
];
89 124
90 125
/// A pack listing as compared: each key with its size and time.
91 126
type Listed = Vec<(String, u64, Option<u64>)>;
…
92 unchanged lines
184 219
fn fanout_prefix(index: &str, r: &ObjectRef) -> String {
185 220
    let h = r.to_hex();
186 221
    format!("{index}/{}/{}/", &h[..2], &h[2..])
222 +
}
223 +
224 +
/// Whether a key names an entry list (spec §13.7), complete or partial.
225 +
fn is_entry_list(key: &str) -> bool {
226 +
    key.starts_with("pack-") && (key.ends_with(LIST_SUFFIX) || key.ends_with(PART_SUFFIX))
227 +
}
228 +
229 +
/// An entry list's bytes: zstd-compressed text, [`LIST_MAGIC`] and then
230 +
/// one key per line, sorted.
231 +
fn encode_entry_list<'a>(keys: impl Iterator<Item = &'a String>) -> Result<Vec<u8>> {
232 +
    let mut text = String::from(LIST_MAGIC);
233 +
    text.push('\n');
234 +
    for k in keys {
235 +
        text.push_str(k);
236 +
        text.push('\n');
237 +
    }
238 +
    Ok(zstd::bulk::compress(text.as_bytes(), ZSTD_LEVEL)?)
239 +
}
240 +
241 +
/// The keys an entry list holds.
242 +
fn decode_entry_list(key: &str, bytes: &[u8]) -> Result<Vec<String>> {
243 +
    let bad = |why: &str| StoreError::Io(io::Error::other(format!("{key}: {why}")));
244 +
    let text = zstd::stream::decode_all(bytes).map_err(|_| bad("not zstd"))?;
245 +
    let text = String::from_utf8(text).map_err(|_| bad("not UTF-8"))?;
246 +
    let mut lines = text.lines();
247 +
    if lines.next() != Some(LIST_MAGIC) {
248 +
        return Err(bad("not an entry list"));
249 +
    }
250 +
    Ok(lines
251 +
        .filter(|l| !l.is_empty())
252 +
        .map(str::to_string)
253 +
        .collect())
187 254
}
188 255
256 +
/// Drop journal lines folded at least [`RETIRED_FOR`] before `now`:
257 +
/// every reader that could need them has a list holding them by then.
258 +
fn expire_folded(m: &mut std::collections::BTreeMap<String, u64>, now: u64) {
259 +
    let hour = RETIRED_FOR.as_millis() as u64;
260 +
    m.retain(|_, t| *t == 0 || now.saturating_sub(*t) < hour);
261 +
}
262 +
189 263
/// `<index>/<hh>/<rest>`: the one-file-per-object layout of older stores.
190 264
fn legacy_key(index: &str, r: &ObjectRef) -> String {
191 265
    let h = r.to_hex();
…
214 unchanged lines
406 480
    batch_depth: std::cell::Cell<u32>,
407 481
    /// Change metadata already read, from the `metas` cache file.
408 482
    metas: RefCell<Option<crate::fasthash::FastMap<ObjectRef, crate::state::ChangeMeta>>>,
483 +
    /// The live entry lists (spec §13.7) the pack listing showed: listed
484 +
    /// and not retired.
485 +
    entry_lists: RefCell<Vec<String>>,
486 +
    /// Whether the store is folded: a live entry list is complete.
487 +
    folded: std::cell::Cell<bool>,
488 +
    /// Whether the pack listing showed `pack-legacy-folded`.
489 +
    legacy_folded: std::cell::Cell<bool>,
490 +
    /// The entry keys of a folded store, read from its lists and journal
491 +
    /// on the first lookup after the packs were loaded.
492 +
    entries: RefCell<Option<std::collections::BTreeSet<String>>>,
493 +
    /// Entry keys written in an open batch, added to the journal when the
494 +
    /// batch is flushed.
495 +
    journal_queue: RefCell<Vec<String>>,
409 496
}
410 497
411 498
impl std::fmt::Debug for Store {
…
161 unchanged lines
573 660
            pending: RefCell::new(None),
574 661
            batch_depth: std::cell::Cell::new(0),
575 662
            metas: RefCell::new(None),
663 +
            entry_lists: RefCell::new(Vec::new()),
664 +
            folded: std::cell::Cell::new(false),
665 +
            legacy_folded: std::cell::Cell::new(false),
666 +
            entries: RefCell::new(None),
667 +
            journal_queue: RefCell::new(Vec::new()),
576 668
        };
577 669
        store
578 670
            .reload_packs()
…
16 unchanged lines
595 687
    /// writers packed since becomes readable, and packs since retired or
596 688
    /// deleted are let go. Packs already loaded are kept, not read again.
597 689
    pub fn refresh(&self) -> Result<()> {
690 +
        *self.entries.borrow_mut() = None;
598 691
        self.reload_packs()
599 692
    }
600 693
…
59 unchanged lines
660 753
                (listed, sig) = self.list_packs()?;
661 754
                continue;
662 755
            }
756 +
            let lists: Vec<String> = listed
757 +
                .iter()
758 +
                .map(|e| e.key.as_str())
759 +
                .filter(|k| is_entry_list(k) && !retired.contains_key(*k))
760 +
                .map(str::to_string)
761 +
                .collect();
762 +
            self.folded
763 +
                .set(lists.iter().any(|k| k.ends_with(LIST_SUFFIX)));
764 +
            self.legacy_folded
765 +
                .set(listed.iter().any(|e| e.key == LEGACY_FOLDED_KEY));
766 +
            *self.entry_lists.borrow_mut() = lists;
767 +
            if *self.packs_listed.borrow() != sig {
768 +
                *self.entries.borrow_mut() = None;
769 +
            }
663 770
            *self.packs.borrow_mut() = packs;
664 771
            *self.packs_listed.borrow_mut() = sig;
665 772
            return Ok(());
…
28 unchanged lines
694 801
695 802
    /// The retired record and its version (`None`: never written).
696 803
    fn read_retired(
804 +
        &self,
805 +
    ) -> Result<(
806 +
        std::collections::BTreeMap<String, u64>,
807 +
        Option<strata::Version>,
808 +
    )> {
809 +
        self.read_record(RETIRED_KEY)
810 +
    }
811 +
812 +
    /// A record of `<wall ms> <key>` lines (the retired record, the
813 +
    /// journal) and its version (`None`: never written).
814 +
    fn read_record(
697 815
        &self,
816 +
        key: &str,
698 817
    ) -> Result<(
699 818
        std::collections::BTreeMap<String, u64>,
700 819
        Option<strata::Version>,
701 820
    )> {
702 -
        match self.backend.read_ref(RETIRED_KEY).map_err(backend_err)? {
821 +
        match self.backend.read_ref(key).map_err(backend_err)? {
703 822
            Some((bytes, v)) => Ok((parse_retired(&bytes), Some(v))),
704 823
            None => Ok((Default::default(), None)),
705 824
        }
…
3 unchanged lines
709 828
    /// whenever another writer changed it first. `edit` must give the
710 829
    /// same result however often it runs.
711 830
    fn update_retired(
831 +
        &self,
832 +
        edit: impl FnMut(&mut std::collections::BTreeMap<String, u64>),
833 +
    ) -> Result<()> {
834 +
        self.update_record(RETIRED_KEY, "the record of retired packs", edit)
835 +
    }
836 +
837 +
    /// Change a record of `<wall ms> <key>` lines by compare-and-swap, as
838 +
    /// [`Store::update_retired`] does; `what` names it in the error when
839 +
    /// other writers kept winning.
840 +
    fn update_record(
712 841
        &self,
842 +
        key: &str,
843 +
        what: &str,
713 844
        mut edit: impl FnMut(&mut std::collections::BTreeMap<String, u64>),
714 845
    ) -> Result<()> {
715 846
        for _ in 0..RETIRED_TRIES {
716 -
            let (mut m, version) = self.read_retired()?;
847 +
            let (mut m, version) = self.read_record(key)?;
717 848
            let before = m.clone();
718 849
            edit(&mut m);
719 -
            if m == before && version.is_some() {
850 +
            // Unchanged; a record never written stays unwritten.
851 +
            if m == before && (version.is_some() || m.is_empty()) {
720 852
                return Ok(());
721 853
            }
722 854
            match self
723 855
                .backend
724 -
                .write_ref(RETIRED_KEY, &encode_retired(&m), version.as_ref())
856 +
                .write_ref(key, &encode_retired(&m), version.as_ref())
725 857
            {
726 858
                Ok(_) => return Ok(()),
727 859
                Err(BackendError::Conflict(_)) => continue,
728 860
                Err(e) => return Err(backend_err(e)),
729 861
            }
730 862
        }
731 -
        Err(StoreError::Io(io::Error::other(
732 -
            "other writers kept changing the record of retired packs; run the command again",
733 -
        )))
863 +
        Err(StoreError::Io(io::Error::other(format!(
864 +
            "other writers kept changing {what}; run the command again"
865 +
        ))))
734 866
    }
735 867
736 868
    /// Record `keys` (packs, by their `.pack` key, and loose objects) as
…
46 unchanged lines
783 915
            if key.ends_with(".pack") {
784 916
                self.remove_pack(key)?;
785 917
                report.packs += 1;
918 +
            } else if is_entry_list(key) {
919 +
                // An entry list a fold replaced (spec §13.7).
920 +
                delete(self.backend.as_ref(), key)?;
786 921
            } else if let Some(r) = loose_ref(key) {
787 922
                if self.find_packed(&r).is_some() {
788 923
                    delete(self.backend.as_ref(), key)?;
…
152 unchanged lines
941 1076
        let old_enough =
942 1077
            |modified: Option<u64>| modified.is_none_or(|t| now.saturating_sub(t) >= grace_ms);
943 1078
        let mut report = PruneReport::default();
1079 +
        let mut gone: Vec<String> = Vec::new();
944 1080
        // Loose objects. A retired one (its object packed) whose object is
945 1081
        // unreachable goes with it, uncounted: the pack's copy is counted.
946 1082
        let retired = self.retired_loose()?;
…
16 unchanged lines
963 1099
            report.bytes += entry.size;
964 1100
            report.objects += 1;
965 1101
            delete(self.backend.as_ref(), &entry.key)?;
966 -
            self.remove_sidecars(&r)?;
1102 +
            self.remove_sidecars(&r, &mut gone)?;
967 1103
        }
968 1104
        if !unretired.is_empty() {
969 1105
            self.update_retired(|m| {
…
44 unchanged lines
1014 1150
            };
1015 1151
            self.write_pack(&keep_refs, &[], &old_keys, ZSTD_LEVEL, progress)?;
1016 1152
            for r in dropped {
1017 -
                self.remove_sidecars(&r)?;
1153 +
                self.remove_sidecars(&r, &mut gone)?;
1018 1154
            }
1019 1155
        }
1156 +
        self.journal_forget(&gone)?;
1020 1157
        Ok(report)
1021 1158
    }
1022 1159
1023 1160
    /// Everything kept beside an object: its signatures and the seal and
1024 1161
    /// receipt entries under it, in both layouts.
1025 -
    fn remove_sidecars(&self, r: &ObjectRef) -> Result<()> {
1162 +
    /// The entry keys deleted are added to `gone`, for the journal.
1163 +
    fn remove_sidecars(&self, r: &ObjectRef, gone: &mut Vec<String>) -> Result<()> {
1026 1164
        for index in ["sigs", "seals", "receipts"] {
1027 1165
            delete(self.backend.as_ref(), &legacy_key(index, r))?;
1028 1166
        }
…
4 unchanged lines
1033 1171
                .map_err(backend_err)?
1034 1172
            {
1035 1173
                delete(self.backend.as_ref(), &e.key)?;
1174 +
                gone.push(e.key);
1036 1175
            }
1037 1176
        }
1038 1177
        Ok(())
1039 1178
    }
1040 1179
1180 +
    /// Take entry keys a collection deleted out of the journal, so the
1181 +
    /// next complete fold leaves them out (spec §13.7).
1182 +
    fn journal_forget(&self, gone: &[String]) -> Result<()> {
1183 +
        if self.local || gone.is_empty() {
1184 +
            return Ok(());
1185 +
        }
1186 +
        if let Some(set) = self.entries.borrow_mut().as_mut() {
1187 +
            for k in gone {
1188 +
                set.remove(k);
1189 +
            }
1190 +
        }
1191 +
        self.update_record(JOURNAL_KEY, "the journal of entries", |m| {
1192 +
            for k in gone {
1193 +
                m.remove(k);
1194 +
            }
1195 +
        })
1196 +
    }
1197 +
1041 1198
    /// Put a complete, synced temporary pack file and its index into the
1042 1199
    /// store; `index` must be sorted by ObjectRef. The pack goes first: an
1043 1200
    /// index never names a pack that is not there.
…
293 unchanged lines
1337 1494
            return Ok(());
1338 1495
        }
1339 1496
        let Some(pending) = self.pending.borrow_mut().take() else {
1340 -
            return Ok(());
1497 +
            return self.flush_journal();
1341 1498
        };
1342 -
        self.write_pending(pending)
1499 +
        self.write_pending(pending)?;
1500 +
        self.flush_journal()
1343 1501
    }
1344 1502
1345 1503
    /// Make every object written so far durable, even inside an open
…
1 unchanged lines
1347 1505
    /// is moved (spec §13.6): the operation head, a bookmark pointer.
1348 1506
    pub fn flush(&self) -> Result<()> {
1349 1507
        let Some(pending) = self.pending.borrow_mut().take() else {
1350 -
            return Ok(());
1508 +
            return self.flush_journal();
1351 1509
        };
1352 1510
        self.write_pending(pending)?;
1511 +
        self.flush_journal()?;
1353 1512
        *self.pending.borrow_mut() = Some(Pending {
1354 1513
            buffered: vec![],
1355 1514
            buffered_bytes: 0,
…
629 unchanged lines
1985 2144
    /// Record `value` under `key` in `index`: one write-once entry, so two
1986 2145
    /// writers of a shared store never lose each other's.
1987 2146
    fn index_add(&self, index: &str, key: &ObjectRef, value: &ObjectRef) -> Result<()> {
2147 +
        debug_assert!(ENTRY_INDEXES.iter().any(|(i, _)| *i == index));
1988 2148
        let entry = format!("{}{}", fanout_prefix(index, key), value.to_hex());
1989 -
        put_new_or_existing(self.backend.as_ref(), &entry, b"")
2149 +
        put_new_or_existing(self.backend.as_ref(), &entry, b"")?;
2150 +
        self.journal_add(entry)
1990 2151
    }
1991 2152
1992 2153
    /// The values recorded under `key` in `index`, with those an older
1993 2154
    /// store kept as one list in `legacy`, sorted.
1994 2155
    fn index_get(&self, index: &str, legacy: &str, key: &ObjectRef) -> Result<Vec<ObjectRef>> {
1995 2156
        let mut values: Vec<ObjectRef> = self
1996 -
            .backend
1997 -
            .list(&fanout_prefix(index, key))
1998 -
            .map_err(backend_err)?
2157 +
            .entry_keys(&fanout_prefix(index, key))?
1999 2158
            .into_iter()
2000 -
            .filter_map(|e| e.key.rsplit('/').next().and_then(ObjectRef::from_hex))
2159 +
            .filter_map(|k| k.rsplit('/').next().and_then(ObjectRef::from_hex))
2001 2160
            .collect();
2002 -
        if let Some(bytes) = get_optional(self.backend.as_ref(), &legacy_key(legacy, key))? {
2161 +
        if !self.reads_legacy() {
2162 +
            // Older lists folded into entries (spec §13.7).
2163 +
        } else if let Some(bytes) = get_optional(self.backend.as_ref(), &legacy_key(legacy, key))? {
2003 2164
            values.extend(
2004 2165
                String::from_utf8_lossy(&bytes)
2005 2166
                    .lines()
…
93 unchanged lines
2099 2260
    /// packed `many/<hh>/<rest>` lists) holds attestations for.
2100 2261
    fn indexed_states(&self, one: &str, many: &str) -> Result<Vec<ObjectRef>> {
2101 2262
        let mut out: Vec<ObjectRef> = Vec::new();
2102 -
        for e in self.backend.list(&format!("{one}/")).map_err(backend_err)? {
2263 +
        for key in self.entry_keys(&format!("{one}/"))? {
2103 2264
            // receipt/<hh>/<rest>/<attest>
2104 -
            let mut parts = e.key.splitn(4, '/');
2265 +
            let mut parts = key.splitn(4, '/');
2105 2266
            let (Some(_), Some(hh), Some(rest), Some(_)) =
2106 2267
                (parts.next(), parts.next(), parts.next(), parts.next())
2107 2268
            else {
…
1 unchanged lines
2109 2270
            };
2110 2271
            out.extend(ObjectRef::from_hex(&format!("{hh}{rest}")));
2111 2272
        }
2112 -
        for e in self
2113 -
            .backend
2273 +
        let legacy = if self.reads_legacy() {
2274 +
            self.backend
2114 2275
                .list(&format!("{many}/"))
2115 2276
                .map_err(backend_err)?
2116 -
        {
2277 +
        } else {
2278 +
            Vec::new()
2279 +
        };
2280 +
        for e in legacy {
2117 2281
            let mut parts = e.key.splitn(3, '/');
2118 2282
            let (Some(_), Some(hh), Some(rest)) = (parts.next(), parts.next(), parts.next()) else {
2119 2283
                continue;
…
17 unchanged lines
2137 2301
    /// key, sorted by key. Empty if none.
2138 2302
    pub fn signatures(&self, r: &ObjectRef) -> Result<Vec<Signature>> {
2139 2303
        let mut sigs: Vec<Signature> = Vec::new();
2140 -
        for e in self
2141 -
            .backend
2142 -
            .list(&fanout_prefix("sig", r))
2143 -
            .map_err(backend_err)?
2144 -
        {
2145 -
            let bytes = self.backend.get(&e.key).map_err(backend_err)?;
2304 +
        for key in self.entry_keys(&fanout_prefix("sig", r))? {
2305 +
            // Gone since it was listed or journalled: a collection deleted
2306 +
            // it with its object, or a new signature by the same signer is
2307 +
            // replacing it.
2308 +
            let Some(bytes) = get_optional(self.backend.as_ref(), &key)? else {
2309 +
                continue;
2310 +
            };
2146 2311
            sigs.extend(Signature::decode_list(&bytes)?);
2147 2312
        }
2148 2313
        // An older store's one list per object; a signer kept there and
2149 2314
        // here counts once, as written here.
2150 -
        if let Some(bytes) = get_optional(self.backend.as_ref(), &legacy_key("sigs", r))? {
2315 +
        let legacy = if self.reads_legacy() {
2316 +
            get_optional(self.backend.as_ref(), &legacy_key("sigs", r))?
2317 +
        } else {
2318 +
            None
2319 +
        };
2320 +
        if let Some(bytes) = legacy {
2151 2321
            for sig in Signature::decode_list(&bytes)? {
2152 2322
                if !sigs.iter().any(|s| s.key == sig.key) {
2153 2323
                    sigs.push(sig);
…
11 unchanged lines
2165 2335
    pub fn put_signature(&self, r: &ObjectRef, sig: &Signature) -> Result<()> {
2166 2336
        let key = format!("{}{}", fanout_prefix("sig", r), sig.key.to_hex());
2167 2337
        let bytes = Signature::encode_list(std::slice::from_ref(sig));
2168 -
        if get_optional(self.backend.as_ref(), &key)?.as_deref() == Some(bytes.as_slice()) {
2338 +
        if get_optional(self.backend.as_ref(), &key)?.as_deref() != Some(bytes.as_slice()) {
2339 +
            delete(self.backend.as_ref(), &key)?;
2340 +
            put_new_or_existing(self.backend.as_ref(), &key, &bytes)?;
2341 +
        }
2342 +
        // Journalled even when the key was there: an older writer's
2343 +
        // entry is not in the journal or any list.
2344 +
        self.journal_add(key)
2345 +
    }
2346 +
2347 +
    // ---- the journal and entry lists (spec §13.7, #116)
2348 +
2349 +
    /// Whether lookups still read the one-list keys of older stores.
2350 +
    fn reads_legacy(&self) -> bool {
2351 +
        true
2352 +
    }
2353 +
2354 +
    /// The entry keys under `prefix` (an index's `<index>/` or one
2355 +
    /// object's `<index>/<hh>/<rest>/`), sorted: on a folded store from
2356 +
    /// its lists and journal, with no request; otherwise by listing.
2357 +
    fn entry_keys(&self, prefix: &str) -> Result<Vec<String>> {
2358 +
        if let Some(keys) = self.with_entries(|set| {
2359 +
            set.range(prefix.to_string()..)
2360 +
                .take_while(|k| k.starts_with(prefix))
2361 +
                .cloned()
2362 +
                .collect::<Vec<String>>()
2363 +
        })? {
2364 +
            return Ok(keys);
2365 +
        }
2366 +
        Ok(self
2367 +
            .backend
2368 +
            .list(prefix)
2369 +
            .map_err(backend_err)?
2370 +
            .into_iter()
2371 +
            .map(|e| e.key)
2372 +
            .collect())
2373 +
    }
2374 +
2375 +
    /// `f` over a folded store's entry keys, read on first use; `None`
2376 +
    /// when the store is not folded (or is this disk's directory), and
2377 +
    /// lookups list.
2378 +
    fn with_entries<T>(
2379 +
        &self,
2380 +
        f: impl FnOnce(&std::collections::BTreeSet<String>) -> T,
2381 +
    ) -> Result<Option<T>> {
2382 +
        if self.local || !self.folded.get() {
2383 +
            return Ok(None);
2384 +
        }
2385 +
        if self.entries.borrow().is_none() {
2386 +
            let Some(set) = self.read_entries()? else {
2387 +
                return Ok(None);
2388 +
            };
2389 +
            *self.entries.borrow_mut() = Some(set);
2390 +
        }
2391 +
        Ok(self.entries.borrow().as_ref().map(f))
2392 +
    }
2393 +
2394 +
    /// Every live entry list's keys and every journal line's, folded or
2395 +
    /// not, with what this handle has queued. A list deleted since the
2396 +
    /// packs were listed (retired over an hour ago) has the packs listed
2397 +
    /// again; `None` when one is still missing then, or the store is no
2398 +
    /// longer folded: lookups list.
2399 +
    fn read_entries(&self) -> Result<Option<std::collections::BTreeSet<String>>> {
2400 +
        'attempt: for attempt in 0..2 {
2401 +
            if !self.folded.get() {
2402 +
                return Ok(None);
2403 +
            }
2404 +
            let mut set = std::collections::BTreeSet::new();
2405 +
            let lists = self.entry_lists.borrow().clone();
2406 +
            for key in &lists {
2407 +
                match get_optional(self.backend.as_ref(), key)? {
2408 +
                    Some(bytes) => set.extend(decode_entry_list(key, &bytes)?),
2409 +
                    None if attempt == 0 => {
2410 +
                        self.reload_packs()?;
2411 +
                        continue 'attempt;
2412 +
                    }
2413 +
                    None => return Ok(None),
2414 +
                }
2415 +
            }
2416 +
            set.extend(self.read_record(JOURNAL_KEY)?.0.into_keys());
2417 +
            set.extend(self.journal_queue.borrow().iter().cloned());
2418 +
            return Ok(Some(set));
2419 +
        }
2420 +
        Ok(None)
2421 +
    }
2422 +
2423 +
    /// Add an entry key this handle wrote to the journal: at once, or
2424 +
    /// when the open batch is flushed. One the handle already knows from
2425 +
    /// the lists or the journal is not added again. Nothing on this
2426 +
    /// disk's directory.
2427 +
    fn journal_add(&self, key: String) -> Result<()> {
2428 +
        if self.local {
2429 +
            return Ok(());
2430 +
        }
2431 +
        if let Some(set) = self.entries.borrow_mut().as_mut() {
2432 +
            if !set.insert(key.clone()) {
2433 +
                return Ok(());
2434 +
            }
2435 +
        }
2436 +
        if self.batch_depth.get() > 0 {
2437 +
            self.journal_queue.borrow_mut().push(key);
2438 +
            return Ok(());
2439 +
        }
2440 +
        self.journal_append(std::slice::from_ref(&key))
2441 +
    }
2442 +
2443 +
    /// Add what the open batch queued to the journal.
2444 +
    fn flush_journal(&self) -> Result<()> {
2445 +
        let queued = std::mem::take(&mut *self.journal_queue.borrow_mut());
2446 +
        self.journal_append(&queued)
2447 +
    }
2448 +
2449 +
    /// Add `keys` to the journal, unfolded, in one compare-and-swap
2450 +
    /// update, dropping lines folded over an hour ago; past
2451 +
    /// [`JOURNAL_FOLD_AT`] unfolded lines, fold them into a partial list.
2452 +
    fn journal_append(&self, keys: &[String]) -> Result<()> {
2453 +
        if keys.is_empty() {
2169 2454
            return Ok(());
2455 +
        }
2456 +
        let now = now_ms();
2457 +
        let mut unfolded = 0;
2458 +
        self.update_record(JOURNAL_KEY, "the journal of entries", |m| {
2459 +
            expire_folded(m, now);
2460 +
            for k in keys {
2461 +
                m.entry(k.clone()).or_insert(0);
2462 +
            }
2463 +
            unfolded = m.values().filter(|t| **t == 0).count();
2464 +
        })?;
2465 +
        if unfolded > JOURNAL_FOLD_AT {
2466 +
            self.fold_journal()?;
2467 +
        }
2468 +
        Ok(())
2469 +
    }
2470 +
2471 +
    /// Fold the journal's unfolded lines into a partial entry list, and
2472 +
    /// mark them folded. Lines added meanwhile stay unfolded.
2473 +
    fn fold_journal(&self) -> Result<()> {
2474 +
        let (journal, _) = self.read_record(JOURNAL_KEY)?;
2475 +
        let keys: Vec<String> = journal
2476 +
            .into_iter()
2477 +
            .filter(|(_, t)| *t == 0)
2478 +
            .map(|(k, _)| k)
2479 +
            .collect();
2480 +
        if keys.is_empty() {
2481 +
            return Ok(());
2482 +
        }
2483 +
        self.put_entry_list(PART_SUFFIX, keys.iter())?;
2484 +
        self.mark_folded(&keys)
2485 +
    }
2486 +
2487 +
    /// Mark these journal lines folded now, if they are not yet; drop
2488 +
    /// those folded over an hour ago.
2489 +
    fn mark_folded(&self, keys: &[String]) -> Result<()> {
2490 +
        let now = now_ms();
2491 +
        self.update_record(JOURNAL_KEY, "the journal of entries", |m| {
2492 +
            expire_folded(m, now);
2493 +
            for k in keys {
2494 +
                if let Some(t) = m.get_mut(k) {
2495 +
                    if *t == 0 {
2496 +
                        *t = now;
2497 +
                    }
2498 +
                }
2499 +
            }
2500 +
        })
2501 +
    }
2502 +
2503 +
    /// Write a new entry list of `keys` (sorted), complete or partial by
2504 +
    /// `suffix`; its key.
2505 +
    fn put_entry_list<'a>(
2506 +
        &self,
2507 +
        suffix: &str,
2508 +
        keys: impl Iterator<Item = &'a String>,
2509 +
    ) -> Result<String> {
2510 +
        let key = format!("pack-{}{suffix}", fresh_id());
2511 +
        let bytes = encode_entry_list(keys)?;
2512 +
        self.backend.put_new(&key, &bytes).map_err(backend_err)?;
2513 +
        Ok(key)
2514 +
    }
2515 +
2516 +
    /// 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
2518 +
    /// a listing of every entry prefix, written as a new list, every list
2519 +
    /// the packs' listing showed retired, and the journal lines read
2520 +
    /// marked folded. Nothing when the store is this disk's directory, or
2521 +
    /// when the one live list already holds exactly these entries and the
2522 +
    /// journal has nothing unfolded. Whether a list was written.
2523 +
    pub fn fold_entries(&self, progress: Progress) -> Result<bool> {
2524 +
        if self.local {
2525 +
            return Ok(false);
2170 2526
        }
2171 -
        delete(self.backend.as_ref(), &key)?;
2172 -
        put_new_or_existing(self.backend.as_ref(), &key, &bytes)
2527 +
        self.reload_packs()?;
2528 +
        let (journal, _) = self.read_record(JOURNAL_KEY)?;
2529 +
        progress("folding signatures and index entries");
2530 +
        let mut all: std::collections::BTreeSet<String> = journal.keys().cloned().collect();
2531 +
        for (index, _) in ENTRY_INDEXES {
2532 +
            for e in self
2533 +
                .backend
2534 +
                .list(&format!("{index}/"))
2535 +
                .map_err(backend_err)?
2536 +
            {
2537 +
                all.insert(e.key);
2538 +
            }
2539 +
        }
2540 +
        let lists = self.entry_lists.borrow().clone();
2541 +
        let unfolded: Vec<String> = journal
2542 +
            .iter()
2543 +
            .filter(|(_, t)| **t == 0)
2544 +
            .map(|(k, _)| k.clone())
2545 +
            .collect();
2546 +
        if unfolded.is_empty() && lists.len() == 1 && lists[0].ends_with(LIST_SUFFIX) {
2547 +
            if let Some(bytes) = get_optional(self.backend.as_ref(), &lists[0])? {
2548 +
                let held: std::collections::BTreeSet<String> =
2549 +
                    decode_entry_list(&lists[0], &bytes)?.into_iter().collect();
2550 +
                if held == all {
2551 +
                    return Ok(false);
2552 +
                }
2553 +
            }
2554 +
        }
2555 +
        self.put_entry_list(LIST_SUFFIX, all.iter())?;
2556 +
        self.retire(&lists)?;
2557 +
        if !unfolded.is_empty() {
2558 +
            self.mark_folded(&unfolded)?;
2559 +
        }
2560 +
        *self.entries.borrow_mut() = None;
2561 +
        self.reload_packs()?;
2562 +
        Ok(true)
2173 2563
    }
2174 2564
2175 2565
    /// Finish packs a writer left unfinished: a batch writes its objects to
…
1375 unchanged lines
3551 3941
        let again = Store::open(dir.path()).unwrap();
3552 3942
        for (r, b) in refs.iter().zip(&objects) {
3553 3943
            assert_eq!(again.get(r).unwrap().1, b.1);
3944 +
        }
3945 +
    }
3946 +
}
3947 +
3948 +
/// Signatures and index entries on a store that is not this disk's
3949 +
/// directory: the journal and the entry lists (spec §13.7, #116).
3950 +
#[cfg(test)]
3951 +
mod entry_tests {
3952 +
    use super::*;
3953 +
    use crate::identity::{Domain, SecretKey};
3954 +
    use crate::strata::Store as _;
3955 +
    use std::collections::HashMap;
3956 +
    use std::sync::atomic::{AtomicBool, Ordering};
3957 +
    use std::sync::Mutex;
3958 +
3959 +
    /// A store in memory that counts its requests by kind, as a bucket
3960 +
    /// store's /health counters do; it can also slip a competing journal
3961 +
    /// line in just before a write of the journal, as another process
3962 +
    /// would.
3963 +
    #[derive(Default)]
3964 +
    struct Counted {
3965 +
        inner: MemoryStore,
3966 +
        counts: Mutex<HashMap<&'static str, usize>>,
3967 +
        interfere: AtomicBool,
3968 +
    }
3969 +
3970 +
    impl Counted {
3971 +
        fn count(&self, kind: &'static str) {
3972 +
            *self.counts.lock().unwrap().entry(kind).or_default() += 1;
3973 +
        }
3974 +
        fn n(&self, kind: &str) -> usize {
3975 +
            self.counts.lock().unwrap().get(kind).copied().unwrap_or(0)
3554 3976
        }
3977 +
        fn reset(&self) {
3978 +
            self.counts.lock().unwrap().clear();
3979 +
        }
3980 +
        fn total(&self) -> usize {
3981 +
            self.counts.lock().unwrap().values().sum()
3982 +
        }
3983 +
        fn summary(&self) -> String {
3984 +
            let c = self.counts.lock().unwrap();
3985 +
            let mut v: Vec<String> = c.iter().map(|(k, n)| format!("{k} {n}")).collect();
3986 +
            v.sort();
3987 +
            v.join(", ")
3988 +
        }
3989 +
    }
3990 +
3991 +
    impl crate::strata::Store for Counted {
3992 +
        fn describe(&self) -> String {
3993 +
            "counted".into()
3994 +
        }
3995 +
        fn has(&self, k: &str) -> crate::strata::Result<bool> {
3996 +
            self.count("has");
3997 +
            self.inner.has(k)
3998 +
        }
3999 +
        fn get(&self, k: &str) -> crate::strata::Result<Vec<u8>> {
4000 +
            self.count(if is_legacy(k) { "get_legacy" } else { "get" });
4001 +
            self.inner.get(k)
4002 +
        }
4003 +
        fn get_range(&self, k: &str, r: std::ops::Range<u64>) -> crate::strata::Result<Vec<u8>> {
4004 +
            self.count("range");
4005 +
            self.inner.get_range(k, r)
4006 +
        }
4007 +
        fn put_new(&self, k: &str, b: &[u8]) -> crate::strata::Result<()> {
4008 +
            self.count("put");
4009 +
            self.inner.put_new(k, b)
4010 +
        }
4011 +
        fn list(&self, p: &str) -> crate::strata::Result<Vec<crate::strata::Entry>> {
4012 +
            self.count(if p == "pack-" {
4013 +
                "list_packs"
4014 +
            } else if is_legacy(p) {
4015 +
                "list_legacy"
4016 +
            } else {
4017 +
                "list"
4018 +
            });
4019 +
            self.inner.list(p)
4020 +
        }
4021 +
        fn delete(&self, k: &str) -> crate::strata::Result<()> {
4022 +
            self.count("delete");
4023 +
            self.inner.delete(k)
4024 +
        }
4025 +
        fn read_ref(
4026 +
            &self,
4027 +
            k: &str,
4028 +
        ) -> crate::strata::Result<Option<(Vec<u8>, crate::strata::Version)>> {
4029 +
            self.count(if k == JOURNAL_KEY {
4030 +
                "read_journal"
4031 +
            } else {
4032 +
                "read_ref"
4033 +
            });
4034 +
            self.inner.read_ref(k)
4035 +
        }
4036 +
        fn write_ref(
4037 +
            &self,
4038 +
            k: &str,
4039 +
            b: &[u8],
4040 +
            e: Option<&crate::strata::Version>,
4041 +
        ) -> crate::strata::Result<crate::strata::Version> {
4042 +
            self.count("write_ref");
4043 +
            if k == JOURNAL_KEY && self.interfere.swap(false, Ordering::SeqCst) {
4044 +
                let (mut m, v) = match self.inner.read_ref(k)? {
4045 +
                    Some((bytes, v)) => (parse_retired(&bytes), Some(v)),
4046 +
                    None => (Default::default(), None),
4047 +
                };
4048 +
                m.insert(
4049 +
                    format!("seal/{}", "c".repeat(64)) + "/" + &"d".repeat(64),
4050 +
                    0,
4051 +
                );
4052 +
                self.inner.write_ref(k, &encode_retired(&m), v.as_ref())?;
4053 +
            }
4054 +
            self.inner.write_ref(k, b, e)
4055 +
        }
4056 +
    }
4057 +
4058 +
    /// Whether a key or prefix is under an older one-list layout.
4059 +
    fn is_legacy(k: &str) -> bool {
4060 +
        ENTRY_INDEXES
4061 +
            .iter()
4062 +
            .any(|(_, legacy)| k.starts_with(&format!("{legacy}/")))
4063 +
    }
4064 +
4065 +
    struct Fixture {
4066 +
        backend: Arc<Counted>,
4067 +
        dir: tempfile::TempDir,
4068 +
        handles: std::cell::Cell<usize>,
4069 +
    }
4070 +
4071 +
    impl Fixture {
4072 +
        fn new() -> Fixture {
4073 +
            Fixture {
4074 +
                backend: Arc::new(Counted::default()),
4075 +
                dir: tempfile::tempdir().unwrap(),
4076 +
                handles: Default::default(),
4077 +
            }
4078 +
        }
4079 +
        /// Another handle on the store, as another process opens it.
4080 +
        fn open(&self) -> Store {
4081 +
            let n = self.handles.get() + 1;
4082 +
            self.handles.set(n);
4083 +
            Store::open_on(self.backend.clone(), self.dir.path().join(format!("s{n}"))).unwrap()
4084 +
        }
4085 +
    }
4086 +
4087 +
    fn signer(n: u8) -> SecretKey {
4088 +
        SecretKey {
4089 +
            ed_seed: [n; 32],
4090 +
            ml_seed: [n + 100; 32],
4091 +
        }
4092 +
    }
4093 +
4094 +
    fn sign(s: &Store, r: &ObjectRef, n: u8) -> Signature {
4095 +
        let sk = signer(n);
4096 +
        let key = s.put_identity(&sk.identity("a", "b", 0)).unwrap();
4097 +
        let sig = sk.sign(Domain::Change, &key, r);
4098 +
        s.put_signature(r, &sig).unwrap();
4099 +
        sig
4100 +
    }
4101 +
4102 +
    fn journal(s: &Store) -> std::collections::BTreeMap<String, u64> {
4103 +
        s.read_record(JOURNAL_KEY).unwrap().0
4104 +
    }
4105 +
4106 +
    fn fold(s: &Store) -> bool {
4107 +
        s.fold_entries(&mut silent()).unwrap()
4108 +
    }
4109 +
4110 +
    /// What 0.4.6 writes for a signature: its own key, and nothing else.
4111 +
    fn old_writer_signs(f: &Fixture, r: &ObjectRef, n: u8) -> Signature {
4112 +
        let sk = signer(n);
4113 +
        let sig = sk.sign(Domain::Change, &ObjectRef([n; 32]), r);
4114 +
        f.backend
4115 +
            .put_new(
4116 +
                &format!("{}{}", fanout_prefix("sig", r), sig.key.to_hex()),
4117 +
                &Signature::encode_list(std::slice::from_ref(&sig)),
4118 +
            )
4119 +
            .unwrap();
4120 +
        sig
4121 +
    }
4122 +
4123 +
    /// After a fold, a signature or seal another handle writes is found
4124 +
    /// by a store opened later through the journal, with no listing; a
4125 +
    /// handle that already read the entries sees it once refreshed.
4126 +
    #[test]
4127 +
    fn an_entry_written_after_a_fold_is_found_through_the_journal() {
4128 +
        let f = Fixture::new();
4129 +
        let w = f.open();
4130 +
        let r1 = w.put(ObjectType::Chunk, b"one").unwrap();
4131 +
        let s1 = sign(&w, &r1, 1);
4132 +
        assert!(fold(&w));
4133 +
        assert!(!fold(&w), "nothing new: no second list");
4134 +
4135 +
        let early = f.open();
4136 +
        assert_eq!(early.signatures(&r1).unwrap(), vec![s1.clone()]);
4137 +
4138 +
        let w2 = f.open();
4139 +
        let r2 = w2.put(ObjectType::Chunk, b"two").unwrap();
4140 +
        let s2 = sign(&w2, &r2, 2);
4141 +
        w2.index_seal(&r2, &ObjectRef([9; 32])).unwrap();
4142 +
        w2.index_receipt(&r2, &ObjectRef([8; 32])).unwrap();
4143 +
        // Its own writes, at once.
4144 +
        assert_eq!(w2.signatures(&r2).unwrap(), vec![s2.clone()]);
4145 +
4146 +
        let later = f.open();
4147 +
        f.backend.reset();
4148 +
        assert_eq!(later.signatures(&r1).unwrap(), vec![s1]);
4149 +
        assert_eq!(later.signatures(&r2).unwrap(), vec![s2.clone()]);
4150 +
        assert_eq!(later.seals_for(&r2).unwrap(), vec![ObjectRef([9; 32])]);
4151 +
        assert_eq!(later.receipts_for(&r2).unwrap(), vec![ObjectRef([8; 32])]);
4152 +
        assert_eq!(later.receipt_states().unwrap(), vec![r2]);
4153 +
        assert!(later.seals_for(&r1).unwrap().is_empty());
4154 +
        assert_eq!(f.backend.n("list"), 0, "{}", f.backend.summary());
4155 +
        assert_eq!(f.backend.n("read_journal"), 1, "{}", f.backend.summary());
4156 +
4157 +
        // The handle that read the entries before keeps its view until
4158 +
        // it is refreshed, as it keeps its operation head.
4159 +
        assert!(early.signatures(&r2).unwrap().is_empty());
4160 +
        early.refresh().unwrap();
4161 +
        assert_eq!(early.signatures(&r2).unwrap(), vec![s2]);
4162 +
    }
4163 +
4164 +
    /// A fold marks the journal's lines rather than dropping them, so a
4165 +
    /// reader whose listing predates it still finds every entry; an hour
4166 +
    /// on, the old list is deleted and the lines dropped, and readers
4167 +
    /// find everything in the new list.
4168 +
    #[test]
4169 +
    fn a_fold_keeps_the_journal_for_the_hour_readers_may_need_it() {
4170 +
        let f = Fixture::new();
4171 +
        let w = f.open();
4172 +
        let r1 = w.put(ObjectType::Chunk, b"one").unwrap();
4173 +
        let s1 = sign(&w, &r1, 1);
4174 +
        assert!(fold(&w));
4175 +
        let r2 = w.put(ObjectType::Chunk, b"two").unwrap();
4176 +
        let s2 = sign(&w, &r2, 2);
4177 +
        assert!(journal(&w).values().any(|t| *t == 0));
4178 +
4179 +
        // Opened before the second fold: lists the first list only.
4180 +
        let stale = f.open();
4181 +
        let stale_after_expiry = f.open();
4182 +
        assert!(fold(&w));
4183 +
        assert!(
4184 +
            journal(&w).values().all(|t| *t != 0),
4185 +
            "every line is marked folded"
4186 +
        );
4187 +
        assert!(journal(&w).len() >= 2, "and kept");
4188 +
        assert_eq!(stale.signatures(&r1).unwrap(), vec![s1.clone()]);
4189 +
        assert_eq!(stale.signatures(&r2).unwrap(), vec![s2.clone()]);
4190 +
4191 +
        // An hour later: the first list goes with the next collection,
4192 +
        // and the next journal update drops the folded lines.
4193 +
        reader_tests::age_retired(&w, RETIRED_FOR * 2);
4194 +
        w.update_record(JOURNAL_KEY, "test", |m| {
4195 +
            for t in m.values_mut() {
4196 +
                *t = t.saturating_sub(2 * RETIRED_FOR.as_millis() as u64);
4197 +
            }
4198 +
        })
4199 +
        .unwrap();
4200 +
        w.expire_retired(RETIRED_FOR, &mut silent()).unwrap();
4201 +
        let lists: Vec<String> = f
4202 +
            .backend
4203 +
            .inner
4204 +
            .list("pack-")
4205 +
            .unwrap()
4206 +
            .into_iter()
4207 +
            .map(|e| e.key)
4208 +
            .filter(|k| is_entry_list(k))
4209 +
            .collect();
4210 +
        assert_eq!(lists.len(), 1, "the replaced list is deleted: {lists:?}");
4211 +
        let r3 = w.put(ObjectType::Chunk, b"three").unwrap();
4212 +
        let s3 = sign(&w, &r3, 3);
4213 +
        let j = journal(&w);
4214 +
        assert_eq!(j.len(), 1, "only the new line is left: {j:?}");
4215 +
        assert!(j.keys().all(|k| k.starts_with(&fanout_prefix("sig", &r3))));
4216 +
4217 +
        // A handle whose listing named the deleted list lists again.
4218 +
        assert_eq!(
4219 +
            stale_after_expiry.signatures(&r1).unwrap(),
4220 +
            vec![s1.clone()]
4221 +
        );
4222 +
        assert_eq!(
4223 +
            stale_after_expiry.signatures(&r3).unwrap(),
4224 +
            vec![s3.clone()]
4225 +
        );
4226 +
        let fresh = f.open();
4227 +
        f.backend.reset();
4228 +
        assert_eq!(fresh.signatures(&r1).unwrap(), vec![s1]);
4229 +
        assert_eq!(fresh.signatures(&r2).unwrap(), vec![s2]);
4230 +
        assert_eq!(fresh.signatures(&r3).unwrap(), vec![s3]);
4231 +
        assert_eq!(f.backend.n("list"), 0);
4232 +
    }
4233 +
4234 +
    /// A store no fold has touched (as 0.4.6 left it: per-object keys and
4235 +
    /// older one-list keys, no journal, no lists) reads by listing, and
4236 +
    /// reads the same once folded. An older writer's entry after the
4237 +
    /// fold is missed until the next fold: the trade-off of §13.7.
4238 +
    #[test]
4239 +
    fn an_old_layout_store_reads_before_and_after_its_first_fold() {
4240 +
        let f = Fixture::new();
4241 +
        let w = f.open();
4242 +
        let r1 = w.put(ObjectType::Chunk, b"one").unwrap();
4243 +
        let r2 = w.put(ObjectType::Chunk, b"two").unwrap();
4244 +
        let s1 = old_writer_signs(&f, &r1, 1);
4245 +
        let s2 = SecretKey {
4246 +
            ed_seed: [2; 32],
4247 +
            ml_seed: [3; 32],
4248 +
        }
4249 +
        .sign(Domain::Change, &ObjectRef([2; 32]), &r2);
4250 +
        f.backend
4251 +
            .put_new(
4252 +
                &legacy_key("sigs", &r2),
4253 +
                &Signature::encode_list(std::slice::from_ref(&s2)),
4254 +
            )
4255 +
            .unwrap();
4256 +
        f.backend
4257 +
            .put_new(
4258 +
                &format!("{}{}", fanout_prefix("seal", &r1), "ab".repeat(32)),
4259 +
                b"",
4260 +
            )
4261 +
            .unwrap();
4262 +
        f.backend
4263 +
            .put_new(
4264 +
                &legacy_key("receipts", &r2),
4265 +
                format!("{}\n", ObjectRef([7; 32]).to_hex()).as_bytes(),
4266 +
            )
4267 +
            .unwrap();
4268 +
        let check = |s: &Store| {
4269 +
            assert_eq!(s.signatures(&r1).unwrap(), vec![s1.clone()]);
4270 +
            assert_eq!(s.signatures(&r2).unwrap(), vec![s2.clone()]);
4271 +
            assert_eq!(s.seals_for(&r1).unwrap(), vec![ObjectRef([0xab; 32])]);
4272 +
            assert_eq!(s.receipts_for(&r2).unwrap(), vec![ObjectRef([7; 32])]);
4273 +
            assert_eq!(s.receipt_states().unwrap(), vec![r2]);
4274 +
        };
4275 +
        let reader = f.open();
4276 +
        assert!(!reader.folded.get());
4277 +
        check(&reader);
4278 +
        assert!(f.backend.inner.read_ref(JOURNAL_KEY).unwrap().is_none());
4279 +
4280 +
        assert!(fold(&w));
4281 +
        let reader = f.open();
4282 +
        assert!(reader.folded.get());
4283 +
        check(&reader);
4284 +
4285 +
        // An older writer after the fold: missed until the next one.
4286 +
        let r3 = w.put(ObjectType::Chunk, b"three").unwrap();
4287 +
        let s3 = old_writer_signs(&f, &r3, 3);
4288 +
        assert!(f.open().signatures(&r3).unwrap().is_empty());
4289 +
        assert!(fold(&w));
4290 +
        assert_eq!(f.open().signatures(&r3).unwrap(), vec![s3]);
4291 +
    }
4292 +
4293 +
    /// Writers racing on the journal never lose a line: a lost
4294 +
    /// compare-and-swap reads the journal again and retries.
4295 +
    #[test]
4296 +
    fn concurrent_journal_appends_lose_nothing() {
4297 +
        let f = Fixture::new();
4298 +
        // One race, made to happen.
4299 +
        let w = f.open();
4300 +
        f.backend.interfere.store(true, Ordering::SeqCst);
4301 +
        w.index_seal(&ObjectRef([1; 32]), &ObjectRef([2; 32]))
4302 +
            .unwrap();
4303 +
        let j = journal(&w);
4304 +
        assert_eq!(j.len(), 2, "the competitor's line and ours: {j:?}");
4305 +
        // Many, from threads, each its own handle.
4306 +
        let backend: Arc<Backend> = f.backend.clone();
4307 +
        std::thread::scope(|scope| {
4308 +
            for t in 0..8u8 {
4309 +
                let backend = backend.clone();
4310 +
                let dir = f.dir.path().join(format!("t{t}"));
4311 +
                scope.spawn(move || {
4312 +
                    let s = Store::open_on(backend, dir).unwrap();
4313 +
                    for i in 0..25u8 {
4314 +
                        s.index_seal(&ObjectRef([t + 10; 32]), &ObjectRef([i; 32]))
4315 +
                            .unwrap();
4316 +
                    }
4317 +
                });
4318 +
            }
4319 +
        });
4320 +
        assert_eq!(journal(&w).len(), 2 + 8 * 25);
4321 +
        assert!(fold(&w));
4322 +
        let r = f.open();
4323 +
        for t in 0..8u8 {
4324 +
            assert_eq!(r.seals_for(&ObjectRef([t + 10; 32])).unwrap().len(), 25);
4325 +
        }
4326 +
    }
4327 +
4328 +
    /// A batch adds its entries to the journal in one update when it is
4329 +
    /// flushed; past the limit the writer folds them into a partial list.
4330 +
    #[test]
4331 +
    fn a_long_journal_is_folded_into_a_partial_list() {
4332 +
        let f = Fixture::new();
4333 +
        let w = f.open();
4334 +
        assert!(fold(&w), "folded, with nothing yet");
4335 +
        let key = ObjectRef([5; 32]);
4336 +
        f.backend.reset();
4337 +
        let batch = w.begin_batch().unwrap();
4338 +
        for i in 0..=JOURNAL_FOLD_AT as u32 {
4339 +
            let mut v = [0u8; 32];
4340 +
            v[..4].copy_from_slice(&i.to_le_bytes());
4341 +
            w.index_seal(&key, &ObjectRef(v)).unwrap();
4342 +
        }
4343 +
        assert_eq!(f.backend.n("write_ref"), 0, "nothing until the flush");
4344 +
        batch.finish().unwrap();
4345 +
        let parts = f
4346 +
            .backend
4347 +
            .inner
4348 +
            .list("pack-")
4349 +
            .unwrap()
4350 +
            .into_iter()
4351 +
            .filter(|e| e.key.ends_with(PART_SUFFIX))
4352 +
            .count();
4353 +
        assert_eq!(parts, 1);
4354 +
        assert!(journal(&w).values().all(|t| *t != 0), "all folded");
4355 +
        let r = f.open();
4356 +
        assert_eq!(r.seals_for(&key).unwrap().len(), JOURNAL_FOLD_AT + 1);
4357 +
        // The next complete fold takes the partial list in.
4358 +
        assert!(fold(&w));
4359 +
        let lists: Vec<String> = f.open().entry_lists.borrow().clone();
4360 +
        assert_eq!(lists.len(), 1);
4361 +
        assert!(lists[0].ends_with(LIST_SUFFIX));
4362 +
        assert_eq!(f.open().seals_for(&key).unwrap().len(), JOURNAL_FOLD_AT + 1);
4363 +
    }
4364 +
4365 +
    /// A collection that deletes an object takes its entries out of the
4366 +
    /// journal too, so the next fold leaves them out.
4367 +
    #[test]
4368 +
    fn pruned_objects_leave_the_journal() {
4369 +
        let f = Fixture::new();
4370 +
        let w = f.open();
4371 +
        let gone = w.put(ObjectType::Chunk, b"gone").unwrap();
4372 +
        let kept = w.put(ObjectType::Chunk, b"kept").unwrap();
4373 +
        sign(&w, &gone, 1);
4374 +
        let s = sign(&w, &kept, 2);
4375 +
        assert!(fold(&w));
4376 +
        let keep: std::collections::HashSet<ObjectRef> = w
4377 +
            .list()
4378 +
            .unwrap()
4379 +
            .into_iter()
4380 +
            .filter(|r| *r != gone)
4381 +
            .collect();
4382 +
        w.prune(&keep, std::time::Duration::ZERO).unwrap();
4383 +
        assert!(journal(&w).keys().all(|k| !k.contains(&gone.to_hex()[2..])));
4384 +
        fold(&w);
4385 +
        let r = f.open();
4386 +
        assert!(r.signatures(&gone).unwrap().is_empty());
4387 +
        assert_eq!(r.signatures(&kept).unwrap(), vec![s]);
4388 +
        let all = r.with_entries(|e| e.clone()).unwrap().unwrap();
4389 +
        assert!(
4390 +
            all.iter().all(|k| !k.contains(&gone.to_hex()[2..])),
4391 +
            "{all:?}"
4392 +
        );
4393 +
    }
4394 +
4395 +
    /// Requests per lookup, before and after the fold (#116): twenty
4396 +
    /// objects, five of them signed, each looked up for its signatures
4397 +
    /// and its seals, as a fetch does.
4398 +
    #[test]
4399 +
    fn a_folded_store_looks_up_entries_without_listing() {
4400 +
        let f = Fixture::new();
4401 +
        let w = f.open();
4402 +
        let refs: Vec<ObjectRef> = (0..20)
4403 +
            .map(|i| {
4404 +
                w.put(ObjectType::Chunk, format!("o {i}").as_bytes())
4405 +
                    .unwrap()
4406 +
            })
4407 +
            .collect();
4408 +
        for r in &refs[..5] {
4409 +
            sign(&w, r, 1);
4410 +
        }
4411 +
        let lookups = |s: &Store| {
4412 +
            for r in &refs {
4413 +
                s.signatures(r).unwrap();
4414 +
                s.seals_for(r).unwrap();
4415 +
            }
4416 +
        };
4417 +
        let before = f.open();
4418 +
        f.backend.reset();
4419 +
        lookups(&before);
4420 +
        let (before_total, before_list) = (f.backend.total(), f.backend.n("list"));
4421 +
        eprintln!("40 lookups before the fold: {}", f.backend.summary());
4422 +
4423 +
        assert!(fold(&w));
4424 +
        let after = f.open();
4425 +
        f.backend.reset();
4426 +
        lookups(&after);
4427 +
        eprintln!("40 lookups after the fold: {}", f.backend.summary());
4428 +
        assert_eq!(before_list, 40);
4429 +
        assert_eq!(f.backend.n("list"), 0);
4430 +
        assert_eq!(f.backend.n("read_journal"), 1);
4431 +
        let legacy = |b: &Counted| b.n("get_legacy") + b.n("list_legacy");
4432 +
        assert!(
4433 +
            f.backend.total() - legacy(&f.backend) < (before_total - 40) / 4,
4434 +
            "{}",
4435 +
            f.backend.summary()
4436 +
        );
3555 4437
    }
3556 4438
}
modified crates/oxy-strata/src/lib.rs +12 −2
…
129 unchanged lines
130 130
    ListKept,
131 131
    Delete,
132 132
    ReadRef,
133 +
    /// A read of a repository's journal of signatures and index entries
134 +
    /// (`pack-entries`, spec §13.7): one per open of a folded store, in
135 +
    /// place of a listing per lookup.
136 +
    ReadJournal,
133 137
    WriteRef,
134 138
}
135 139
136 140
impl Op {
137 -
    pub const ALL: [Op; 11] = [
141 +
    pub const ALL: [Op; 12] = [
138 142
        Op::Has,
139 143
        Op::HasMissing,
140 144
        Op::Get,
…
4 unchanged lines
145 149
        Op::ListKept,
146 150
        Op::Delete,
147 151
        Op::ReadRef,
152 +
        Op::ReadJournal,
148 153
        Op::WriteRef,
149 154
    ];
150 155
…
9 unchanged lines
160 165
            Op::ListKept => "list_kept",
161 166
            Op::Delete => "delete",
162 167
            Op::ReadRef => "read_ref",
168 +
            Op::ReadJournal => "read_journal",
163 169
            Op::WriteRef => "write_ref",
164 170
        }
165 171
    }
…
692 unchanged lines
858 864
859 865
    fn read_ref(&self, key: &str) -> Result<Option<(Vec<u8>, Version)>> {
860 866
        let path = self.path(key)?;
861 -
        counted(Op::ReadRef);
867 +
        counted(if key == oxy_core::store::JOURNAL_KEY {
868 +
            Op::ReadJournal
869 +
        } else {
870 +
            Op::ReadRef
871 +
        });
862 872
        let got = self.block(async {
863 873
            let r = self.inner.get_opts(&path, GetOptions::default()).await?;
864 874
            let meta = r.meta.clone();
…
240 unchanged lines