Skip to content

perf(str): precompute the marker report instead of aggregating per request - #24

Merged
JamesKane merged 1 commit into
mainfrom
perf/str-marker-precompute
Jul 29, 2026
Merged

perf(str): precompute the marker report instead of aggregating per request#24
JamesKane merged 1 commit into
mainfrom
perf/str-marker-precompute

Conversation

@JamesKane

Copy link
Copy Markdown
Owner

Follow-up to #22. The /str-markers page is slow on production.

The measurement

It aggregated every stored profile on every request — ~1.9s over the 876-profile dev corpus, growing linearly with the corpus, so production is worse.

Profiling put the cost somewhere indexes cannot reach:

Stage Time
JSONB expansion (seq scan + jsonb_array_elements, 514k observations) 36ms
Aggregate machinery (mode() WITHIN GROUP, 3× ordered array_agg, 2× DISTINCT) ~1,840ms
The two reference joins — the only index-servable part 13ms

marker_name is already a PK and the active-rate partial index already applies, so there was nothing to add. I tried the obvious rewrites before reaching for precomputation:

  • Raise work_mem to 256MB (the plan spills 52MB to disk): 1,888ms — no change
  • Drop count(DISTINCT sid): 1,556ms (−17%)
  • Drop both DISTINCT aggregates: 1,385ms (−26%)
  • Pre-aggregate by distinct value before the ordered-set aggregates (the textbook fix): 1,449ms (−23%)

None of that is enough, and none of it stops the growth.

The change

Precompute the rollup into genomics.str_marker_stat, refreshed by du-jobs run-once str-marker-stats — the same pattern as coverage-norms, discovery-consensus and tree-samples-recompute. A plain table rather than a MATERIALIZED VIEW: this schema uses none, and the refresh belongs inside the job's transaction.

before after
DB query 1,875ms 0.94ms
Page response ~2s 13ms

And it no longer scales with the corpus.

Design notes

  • Full replace in one transaction. There is no incremental form — a single new profile can move a marker's min, max and mode at once — and readers see one whole snapshot or the other.
  • Auto-refresh after an applied ftdna-str import, since a bulk load invalidates every row.
  • Live fallback while the table is empty, covering the window between deploying migration 0071 and the first refresh. The report stays correct rather than going blank, and logs a warning naming the job instead of failing quietly.
  • The page now shows when it was last recomputed, because the figures are no longer live.
  • The aggregation SQL has one definition, shared by the refresh and the fallback. The integration test asserts both paths return identical rows, so they cannot drift.

Deploy

Run du-jobs run-once str-marker-stats after migrating. Forgetting it is not fatal — the page works at the old speed and says so in the log. Worth adding to the timer set for the federated path: fed.str_profile arrives continuously via Jetstream and nothing else triggers a refresh for it.

Verification

  • cargo clippy --workspace --locked -- -D warnings clean
  • du-db 114/114 against a fresh, empty database in parallel — the configuration that exercises migration 0071 from scratch
  • du-web 65/65
  • Served JSON byte-identical to pre-change on the spot-check markers (DYS19, DYS385, DYS390, DYS447, DYS448, CDY)

🤖 Generated with Claude Code

…quest

The /str-markers page aggregated every stored profile on every request: ~1.9s
over the 876-profile dev corpus, growing linearly, and worse in production.

Profiling put the cost somewhere indexes cannot reach. The JSONB expansion is
36ms for 514k observations, and the two reference joins — the only index-servable
part — are 13ms. The remaining ~1.84s is aggregate machinery: mode() WITHIN
GROUP, three ordered array_agg()s and two DISTINCT counts, all over every
expanded observation. Raising work_mem changed nothing despite a 52MB spill;
dropping both DISTINCT aggregates reached 1.39s; pre-aggregating by distinct
value before the ordered-set aggregates reached 1.45s. None of that is enough,
and none of it stops the growth.

So the rollup is precomputed into genomics.str_marker_stat, refreshed by
`du-jobs run-once str-marker-stats`, following the coverage-norms /
discovery-consensus / tree-samples-recompute pattern already in this crate. A
plain table rather than a MATERIALIZED VIEW: the schema uses none, and the
refresh belongs in the job's transaction. Read path is now 0.94ms and the page
serves in 13ms, independent of corpus size.

The refresh is a full replace in one transaction — a single new profile can move
a marker's min, max and mode at once, so there is no incremental form, and
readers see one whole snapshot or the other. It also runs automatically after an
applied `ftdna-str` import, since a bulk load invalidates every row.

marker_stats falls back to computing live while the table is empty, so the report
is correct in the window between deploying this migration and the first refresh —
slow, and it says so in the log rather than going quietly blank. The report now
displays when it was last recomputed, because the figures are no longer live.

The integration test asserts the live and precomputed paths return identical
rows, so the two definitions cannot drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@JamesKane
JamesKane merged commit 5cd181d into main Jul 29, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant