Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/storage-count-cap-cost-docs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
---

docs(storage): state the count cap's cost as a decode per key past it, not per repeated key (#1081)

ADR 0011 and the comments in `lmdb/events.rs` said the cap on a tag count's id set costs
"a body decode per repeated key past it". `counted_earlier` reads the row's body for every
key the set does not answer past the cap, unless the range is the family's first exact
criterion, so the cost is not confined to repeated keys: a `{"#t":["zz","a"]}` filter,
one key a row and no repeats, costs 5.7× its uncapped walk at 20,000 rows. The record
and its copies now say so, and qualify "below the cap the walk reads no body" to a walk
over a single tag family, since a second family decodes there too.

Comment and prose only: no code changes and no behaviour change.
18 changes: 11 additions & 7 deletions crates/nostream-storage/src/lmdb/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -755,7 +755,8 @@ struct Plan<'a> {
/// `tag_ranges` builds one range per criterion that fits a key, carrying that criterion. A
/// key in these ranges has already proved that family, so the row's body is decoded only for
/// the filter's other tag families, and a `tags` count holds one id per row its matcher
/// accepts until the set is full, then reads a repeated key's body instead.
/// accepts until the set is full, then reads the body of every key the set does not answer
/// outside the family's first exact criterion.
tag: Option<(&'a str, &'a [String])>,
}

Expand Down Expand Up @@ -935,9 +936,11 @@ fn count_matches_bounded(
// A single exact criterion names one key per row, so only a second criterion or a `#g`
// prefix can walk one row twice, and only then is the id set paid for.
//
// shortcut: the set holds at most `MAX_PAGE_KEYS` ids; a repeated key past that reads the
// row's body to ask whether an earlier key of this walk already answered for it, against a
// slot per repeated row. Upgrade when a count past the cap must decode nothing, which needs
// shortcut: the set holds at most `MAX_PAGE_KEYS` ids; every key past that the set does
// not answer, outside the family's first exact criterion, reads the row's body to ask whether
// an earlier key of this walk already answered for it, not only a repeated one, against a slot
// per repeated row.
// Upgrade when a count past the cap must decode nothing, which needs
// an index order that visits a row once.
let mut counted_ids = HashSet::new();
let dedupe = plan.tag.is_some_and(|(name, criteria)| {
Expand Down Expand Up @@ -3229,9 +3232,10 @@ mod tests {
);
}

/// A count's total does not depend on how many ids its walk may hold: past the cap a
/// repeated key reads the row's body to tell an earlier key from a first one, and this runs
/// that walk and the one the set alone answers over one corpus.
/// A count's total does not depend on how many ids its walk may hold: past the cap a key
/// the set does not answer, outside the family's first exact criterion, reads the row's body
/// to tell an earlier key from a first one, and this runs that walk and the one the set alone
/// answers over one corpus.
#[test]
fn a_count_past_its_id_cap_totals_what_the_unbounded_walk_totals() {
let _serial = counting();
Expand Down
35 changes: 21 additions & 14 deletions docs/rewrite/decisions/0011-lmdb-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,15 +197,20 @@ as the capture's does.

One read keeps a second thing resident, and it is a count's rather than a page's: a `tags` count
whose criteria can select one row twice holds an id per row its matcher accepts, and that set is
bounded by `MAX_PAGE_KEYS` — the same 5 000. Below the cap the walk reads no body; at the cap it
stops inserting and decides each further key with a bounded-memory check instead: a repeated key
reads the row's body and asks whether an earlier key of the walk already answered for it, an
earlier criterion that matches one of its tags or, within one `#g` prefix range, a smaller value
the prefix matches. The total is therefore the same at every corpus size, and the set is a
constant 270 352 B once full (measured live: 5 000 ids at a `HashSet` capacity of 7 168 slots,
against 1 081 360 B for the same 20 000-row corpus with the set unbounded). The price of the
cap is a body decode per repeated key past it; an index order that visits each row once is what
would remove that too.
bounded by `MAX_PAGE_KEYS` — the same 5 000. Below the cap a walk over a single tag family reads
no body (a second family decodes there too, as below); at the cap it stops inserting and decides
each further key with a bounded-memory check instead: a key the set does not answer, outside the
family's first exact criterion, reads the row's body and asks whether an earlier key of the walk
already answered for it, an earlier criterion that matches one of its tags or, within one `#g`
prefix range, a smaller value the prefix matches. The total is therefore the same at every corpus
size, and the set is a constant 270 352 B once full (measured live: 5 000 ids at a `HashSet`
capacity of 7 168 slots, against 1 081 360 B for the same 20 000-row corpus with the set
unbounded). Past the cap every key the set does not answer, outside the family's first exact
criterion, reads the row's body — a later criterion's key to ask whether an earlier criterion
matched the row, a `#g` prefix's key to ask whether a smaller value did — not only a repeated one,
while the first criterion's own keys decode nothing. The walk costs 5.3× to 5.7× its uncapped time
at 20,000 rows for that reason. An index order that visits each row once is what would remove that
cost.

The index is chosen from the filter, and which ranges can stop early follows from the key:

Expand Down Expand Up @@ -254,9 +259,10 @@ Two consequences of the same bound:
row two criteria — or two values of a `#g` prefix — both select is counted at the first key
the walk reaches and skipped at the rest without a body decode, while the set has room: the
ranges run in the criteria's order, so that key is not necessarily the row's smallest matching
one. Once the set is full the walk reads the body of a repeated key instead and asks whether
an earlier key of the walk already answered for it — an earlier criterion, or a smaller value
of the same prefix — so the count stays exact and its resident set stays a constant. Only those
one. Once the set is full the walk reads the body of every key the set does not answer,
outside the family's first exact criterion, instead and asks whether an earlier key of the walk
already answered for it — an earlier criterion, or a smaller value of the same prefix — so the
count stays exact and its resident set stays a constant. Only those
two shapes hold the set, whose population is one id per row its matcher accepts: a single exact
criterion names one key per row. The tags index has no order that visits each row once, which
is why the cap trades a slot past it for a body decode.
Expand Down Expand Up @@ -357,8 +363,9 @@ its op is one of the five above — so the list cannot grow without the diff sho
- A page is bounded by `MAX_PAGE_KEYS` and a read holds a page rather than a range; a count
materialises no result set, and the ids it holds for a tag walk that can select a row twice
are bounded by the same `MAX_PAGE_KEYS` — 270 352 B of resident set once full, however large
the corpus, with a body decode per repeated key past the cap. So a client cannot choose how
much a request materialises.
the corpus, with a body decode per key past the cap that the set does not answer and
that lies outside the family's first exact criterion, not only per repeated one. So a client
cannot choose how much a request materialises.
The price is that a `#g`-prefix page walks its whole (time-bounded) range, an author page
without kinds reads the created-at index, and so does an author × kind filter whose
composition passes `MAX_PAGE_KEYS` ranges; an `ids` read whose criteria are exact is bounded
Expand Down
Loading