mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-30 15:48:36 +00:00
Follow-up to the audit fixes so nothing describes the pre-fix behavior: - CorpusServerMain: drop the leftover "reuses an already loaded DB … skips the reload" comment above `val dbFile` — the sentinel-gated reuse it described is now spelled out in the block just below it. - sync-throughput-1m plan: the up-catch-up now streams `negentropyReconcile` (publishing each onHaveIds batch) instead of materializing the full diff via negentropyReconcileIds; note the O(batch) memory win at 1M. - follow-feed plan: the k-way merge dedups repeated authors/kinds, and its id-ASC tie-break is byte-exact vs the single-SQL path only when the store indexes id (useAndIndexIdOnOrderBy) — otherwise ties fall in rowid order. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
98 lines
5.4 KiB
Markdown
98 lines
5.4 KiB
Markdown
# follow-feed: measured read/write/size tradeoff — shipped the k-way merge
|
||
|
||
**Status: shipped.** The 1M run showed geode's `follow-feed` (`kinds=[1,6] AND
|
||
authors=[150] ORDER BY created_at DESC LIMIT 500`) at 97.7 ms vs strfry 17.6 ms.
|
||
This measured whether any change is worth it, deliberately across **read**,
|
||
**write**, and **size** so a read win doesn't quietly cost elsewhere — and the
|
||
one universally-better option (the app-level k-way merge) was then built.
|
||
`MergeQueryExecutor` now serves this shape; a fresh 1M relayBench run has geode
|
||
at **18.8 ms vs strfry 17.7 ms** (parity, down from 97.7 ms) and **46,258 ev/s
|
||
vs strfry 15,365 @8conn** (3×), with both relays returning the same 500 events.
|
||
|
||
## What the current plan actually does
|
||
|
||
Composite seek `query_by_kind_pubkey_created (kind, pubkey, created_at)` for
|
||
the 300 `(kind, pubkey)` combos, feeding every matching row into a
|
||
LIMIT-bounded top-500 sorter (`USE TEMP B-TREE FOR ORDER BY`). So it reads
|
||
**O(followed authors' matching history)** rows — cheap in memory, but on a
|
||
cold on-disk 1M DB, following prolific accounts means reading hundreds of
|
||
thousands of index rows: that's the 97.7 ms (and it's a worst case — the
|
||
relayBench follow set is the 150 *most prolific* authors).
|
||
|
||
## Read — measured (`FollowFeedReadBenchmark`, in-memory)
|
||
|
||
Three strategies, all on **existing** indexes, across two opposite follow
|
||
profiles, at two corpus sizes:
|
||
|
||
| | prolific-recent | sparse-old |
|
||
|---|---:|---:|
|
||
| **current** (composite + bounded sort) | 5.7 ms | 1.9 ms |
|
||
| **scan** (`created_at` index, early-LIMIT — strfry's shape) | **1.0 ms** | **1601.9 ms** |
|
||
| **union** (per-(author,kind) LIMIT 500, merged) | 316.9 ms | 20.0 ms |
|
||
|
||
*(scale 5 ≈ 1.05M events; scale 1 numbers: scan sparse-old 234 ms, so it
|
||
grows 234 → 1601 ms as the corpus grows.)*
|
||
|
||
- **scan** is the strfry approach and wins big for active follows (1.0 ms),
|
||
but is **catastrophic for sparse/inactive follows** — it walks newest-first
|
||
through the whole corpus to reach their old events, so it's slow **and
|
||
scales with total corpus size** (234 → 1601 ms). Following people who rarely
|
||
post is the common case; this would be a severe regression.
|
||
- **union** (300 branches) is dominated by per-branch overhead — non-viable.
|
||
- **current** is the only *robust* option: never catastrophic, flat across
|
||
scale, bounded by the followed set (not the corpus).
|
||
|
||
**No safe SQL-level swap exists.** Each alternative trades geode's worst case
|
||
for a worse one on a common workload. The only universally-better plan is
|
||
strfry's algorithm — an **app-level k-way merge**: open a `(kind, pubkey,
|
||
created_at DESC)` cursor per `(author, kind)`, heap-merge them, stop at the
|
||
LIMIT. That reads **O(LIMIT + streams)** regardless of follow activity or
|
||
corpus size. It's a real new query-execution path (not a SQL tweak), worth
|
||
building only if prolific-follow feeds become a measured production priority.
|
||
|
||
## Write & size — neutral for every candidate
|
||
|
||
All four strategies (current, scan, union, k-way merge) run on indexes that
|
||
already exist:
|
||
|
||
| strategy | index | new index? |
|
||
|---|---|---|
|
||
| current / union / k-way | `query_by_kind_pubkey_created` (unconditional) | no |
|
||
| scan | `query_by_created_at_id` (`indexEventsByCreatedAtAlone`, on for geode) | no |
|
||
|
||
So **no read-strategy choice here changes the index set** — write throughput
|
||
(geode's 1.3× ingest lead) and on-disk footprint (geode's storage win) are
|
||
untouched no matter which we pick. Adding a *new* index to help this one
|
||
shape was considered and rejected: it would tax every insert and grow the DB
|
||
for a single query pattern, and (as the profiles investigation already
|
||
showed) SQLite reverts to the scan under `ANALYZE` anyway.
|
||
|
||
## Decision — built the k-way merge
|
||
|
||
The SQL alternatives were all rejected (each breaks a common workload and gets
|
||
worse at scale). The app-level **k-way merge** was the only approach that
|
||
improves this shape universally while staying write/size-neutral, so it was
|
||
built as `MergeQueryExecutor` and wired into both `query` and the zero-decode
|
||
`rawQuery` relay path.
|
||
|
||
It opens one lazy newest-first cursor per `(kind, pubkey)` stream off the
|
||
existing `query_by_kind_pubkey_created` index (or `query_by_pubkey_created` for
|
||
authors-only), merges their heads newest-first (`created_at DESC`, tie-broken by
|
||
`id ASC`), and stops at the limit — reading **O(limit + streams)** rows
|
||
regardless of follow activity or corpus size, which is exactly strfry's
|
||
algorithm. Eligibility is narrow (2..2048 streams, simple filter, explicit
|
||
limit, no ids/d-tags); everything else falls through to the single-SQL plan, so
|
||
no other query shape changes. Duplicate authors/kinds are collapsed before
|
||
opening cursors (a repeat would double-emit), and the `id ASC` tie-break is
|
||
byte-exact against the single-SQL path only when the store indexes id
|
||
(`useAndIndexIdOnOrderBy`) — otherwise same-second ties fall in rowid order, a
|
||
valid newest-N either way.
|
||
|
||
Correctness is pinned by `MergeQueryCorrectnessTest` (merge == single-SQL top-N
|
||
vs both an independent reference and the SQL path, across ties/windows/raw).
|
||
`FollowFeedReadBenchmark` gained a `merge` variant showing it flat ~10-12 ms
|
||
in-memory across both profiles — the in-memory number understates the win
|
||
because the merge trades *disk* reads for CPU; the real gain is the cold-disk
|
||
1M relayBench result above (97.7 → 18.8 ms). The `scan` guard stays as evidence
|
||
of why the tempting shortcut is catastrophic on sparse follows (1995 ms at 1M).
|