Files
amethyst/quartz/plans/2026-07-04-follow-feed-read-tradeoff.md
Claude cece5b6e04 docs: sync comments/plans with the audit fixes
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
2026-07-05 12:20:11 +00:00

98 lines
5.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).