Files
amethyst/quartz/plans/2026-07-04-profiles-query-plan.md
Claude 87192e7793 fix(store): pin (kind,pubkey) index for multi-author no-limit REQs
The profiles scenario — Filter(kinds=[0], authors=[50]), no limit — was
geode's ~100x loss to strfry on the 1M corpus (99.5ms vs 0.94ms).

Root cause: the REQ path always appends ORDER BY created_at DESC. For a
multi-author pubkey IN(...) filter, query_by_kind_created (kind,
created_at) satisfies that order for free by scanning an ENTIRE kind, so
SQLite prefers it over the selective query_by_kind_pubkey_created (which
would need a sort). The scan is O(all kind-0 profiles) — cheap at 2k, the
99.5ms at 1M. ANALYZE does not fix it (verified: even a reopened store
reading fresh sqlite_stat1 keeps the scan, since the ORDER BY genuinely
lets the scan skip a sort). A single author is costed right and already
seeks; only the IN-list of >1 is mis-costed.

Fix: pin INDEXED BY query_by_kind_pubkey_created for exactly that shape —
multi-author + kinds, no ids, no d-tags, no limit — keeping the ORDER BY.
SQLite seeks the authors and sorts the small result: identical rows,
identical newest-first order (zero behavior change), ~8x at 2k profiles,
growing to ~100x at 1M. Limited feeds (home/global) keep the created_at
scan + early LIMIT; single-author and d-tag queries are untouched. The
index is created unconditionally so the hint never dangles.

(Considered dropping the ORDER BY for no-limit author queries — faster and
hint-free, but it changes on-the-wire result ordering, which broke
FsParityTest's ordered-parity assertions, so it's client-visible. Rejected
in favor of the order-preserving pin.)

Adds ProfilesQueryPlanBenchmark (regression guard: asserts the live REQ
plan seeks the composite index, not the kind scan) and
plans/2026-07-04-profiles-query-plan.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 11:38:31 +00:00

102 lines
5.2 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.
# The `profiles` query: why kind-0 + authors scans, and how to fix it
**Status: shipped — Fix B applied** (the order-preserving one, chosen over
Fix A because dropping the ORDER BY changed observable result ordering — it
broke `FsParityTest`'s ordered-parity assertions, i.e. it's client-visible).
`QueryBuilder.makeSimpleQuery` now pins `INDEXED BY query_by_kind_pubkey_created`
for the exact broken shape — multi-author (`pubkey IN (…)`) + `kinds`, no
`limit`, no d-tags — keeping the newest-first ORDER BY. The REQ plan for
`profiles` seeks the composite index instead of scanning, ~8× at 42k profiles
and growing with profile count (the ~100× at 1M), with **zero behavior
change** (identical rows, identical order). Guarded by
`ProfilesQueryPlanBenchmark`. Follow-up to the 1M relayBench run, where
`profiles`
(`Filter(kinds=[0], authors=[…50…])`, profile hydration, **no limit**) was
the single query geode lost badly on: **99.5 ms p50 vs strfry 0.94 ms
(~100×)**, while geode won or tied the other nine scenarios.
## Root cause
The REQ query path (`QueryBuilder.makeSimpleQuery`, `project = true`)
appends `ORDER BY created_at DESC` **unconditionally** — even when the
filter has no `limit`. For `kind = 0 AND pubkey IN (…50…) ORDER BY
created_at DESC`, SQLite has two options:
- `query_by_kind_created (kind, created_at)` — seek `kind = 0`, then walk it
in `created_at` order. Satisfies the ORDER BY **for free**, but walks
*every kind-0 row* (every profile on the relay) and filters 50 authors out
of them.
- `query_by_kind_pubkey_created (kind, pubkey, created_at)` — 50 seeks
(one per author), but the results arrive grouped by author, so the ORDER
BY needs a **sort**.
SQLite picks the first: avoiding the sort looks cheaper than 50 seeks in its
cost model. That's fine when there are 2k profiles; at 1M events with tens
of thousands of profiles the scan *is* the 99.5 ms. strfry seeks its
`(pubkey, kind)` index and merges — the 0.94 ms.
`EXPLAIN QUERY PLAN` at 2k profiles (see `ProfilesQueryPlanBenchmark`):
```
current (ORDER BY, no limit): SEARCH … USING INDEX query_by_kind_created (kind=?) 1.24 ms
```
## `ANALYZE` does not fix it
Verified decisively: running `ANALYZE`, and even **reopening the store** so
fresh connections read `sqlite_stat1`, leaves the plan unchanged — still the
kind scan. With the ORDER BY present, the scan genuinely avoids a sort, so
accurate stats don't change SQLite's mind. This is not a stale-stats
problem; it's the ORDER BY constraining the plan.
## Two fixes (both measured, both correct — identical result sets)
At 2k profiles / 42k events (the gap widens with profile count):
| | plan | time | ordering |
|---|---|---|---|
| current | scan `query_by_kind_created` | 1.24 ms | newest-first |
| **Fix A** — drop `ORDER BY` when `limit == null` | planner picks `query_by_kind_pubkey_created` itself | **0.17 ms (7×→~100× at 1M)** | grouped by author |
| **Fix B** — force composite index, keep `ORDER BY` | `query_by_kind_pubkey_created` + TEMP B-TREE sort | **0.19 ms** | newest-first (unchanged) |
**Fix A (rejected):** emit `ORDER BY` only when `limit != null || authors ==
null`. Fastest (0.15 ms) and no hint, but dropping the order for multi-author
no-limit queries is **client-visible** — it broke `FsParityTest`'s
ordered-parity assertions, i.e. it changes what a REQ returns on the wire.
Not worth a semantic change for this.
**Fix B (applied):** in `makeSimpleQuery`, pin `INDEXED BY
query_by_kind_pubkey_created` when the filter is multi-author (`authors.size >
1`) with `kinds`, no `ids`, no d-tags, and **no `limit`** — keeping the ORDER
BY. SQLite seeks the authors and sorts the (small) result: same rows, same
newest-first order, ~8× here / ~100× at 1M. The scoping is exact:
- **single author** (`pubkey = ?`) is already costed right and seeks — no pin;
- **with a limit** (the 150-author home feed) the `created_at` scan + early
`LIMIT` is the better plan — no pin;
- **d-tag / addressable** filters have their own index — no pin;
- **authors-only, no kinds** keeps the planner's choice (needs the
flag-gated `query_by_pubkey_created`; it was only a ~3× minor loss, not the
100× regression) — left for later.
`query_by_kind_pubkey_created` is created unconditionally, so the hint never
dangles. No existing test changed — the only multi-author cases in
`QueryAssemblerTest` carry a `limit` or a `search`, both excluded.
**Fix B** (zero behavior change): when the filter has `kinds` + `authors`
and **no limit**, add `INDEXED BY query_by_kind_pubkey_created` and keep the
ORDER BY — SQLite seeks the authors then sorts the (already-materialized)
result. Preserves newest-first exactly. Costs a hard index hint (safe: the
index is created unconditionally) that must be scoped to no-limit filters —
forcing it on limited queries (e.g. the 150-author home feed) would regress
them, since there the `created_at` scan + early `LIMIT` is the better plan.
Either way the scoping is the same: **`kinds` + `authors`, no `limit`.**
Limited feeds (home, global) are untouched and already fast.
## Artifact
`quartz …prodbench.ProfilesQueryPlanBenchmark` — seeds a kind-0-heavy store,
prints the three plans + timings, asserts all shapes return identical rows.
Runs in CI (~seconds at 42k).