Skip to content

perf(stats): answer the Statistics page in three index scans, not six - #269

Merged
henry40408 merged 3 commits into
mainfrom
perf/stats-page-misses
Sep 12, 2026
Merged

henry40408 merged 3 commits into
mainfrom
perf/stats-page-misses

Conversation

@henry40408

@henry40408 henry40408 commented Sep 12, 2026

Copy link
Copy Markdown
Owner

Rendering a 7-day window of the Statistics page cost 29 274 page misses — 114 MiB read off the disk for one screen — on a 370 k-row, 147 MiB database. It now costs 13 287 (52 MiB).

The unit is deliberate. These queries were previously tuned against wall-clock readings taken on an SSD, where the OS page cache hides how much of the file a query actually touches. The appliance runs off an SD card, where it does not. A page count is the same number on both machines; a duration is not.

What was expensive

The outcome breakdown was scanning the whole log table — 12 173 misses, 41% of the page. Classifying a query needs result tested for emptiness, and no index carried that, so the query walked idx_query_logs_timestamp and then did a rowid lookup per matching row: the entire 10 784-page table.

The emptiness test now rides idx_query_logs_ts_metrics as a VIRTUAL generated column (has_result), which occupies no table space and added 65 pages (3%) to the index. Same answer, 2 157 misses.

Version 11's migration comment ruled this out — "indexing the emptiness expression measured slower than the plain table lookup". That was true in milliseconds on an SSD and wrong about the cost. Two details from re-doing it: an index on the bare expression is not treated as covering by the planner, but a named generated column is; and the query needs INDEXED BY, because with idx_query_logs_timestamp also matching the range the planner picks the smaller index and pays the lookups anyway.

The other readings re-walked indexes each other had just finished with. The timeline, both breakdowns and the latency histogram are four foldings of one index over one window; the top-domain list and the unique-domain count are two foldings of another. Asked as six separate statements — and round-robined by the read pool onto connections holding 2 MiB of page cache each — nothing was ever warm for the next one.

range_metrics_since groups at (bucket, blocked, cached, has_result) and (query_type, response_ms); domain_stats_since returns the top list and the distinct count from one materialized CTE. The single-purpose functions /api/stats/* calls are now folds over the same statements, so there is one SQL spelling per fact.

Measured

Same harness, same database, 7-day window:

Reading Before After
highlights (unique domains + latency) 6 057
breakdowns (query type + outcome) 14 231
top domains 3 973
range_stats (all of the above) 8 271
top clients 3 615 3 618
db health 1 398 1 398
first response 29 274 (114.4 MiB) 13 287 (51.9 MiB)

Tests

  • tests/stats_page_miss_bench.rs — reports the figure against a real database (BENCH_DB=… cargo nextest run --release --no-capture --run-ignored only stats_page_miss_bench).
  • tests/stats_page_miss_test.rs — three guards. the_outcome_breakdown_never_reads_the_log_table was observed failing with the INDEXED BY removed (read 1470 of the database's 1892 pages) and passing with it restored.
  • stats_db_test.rs gains five cases pinning the folded results to what the separate queries returned, including empty-result classification and outcome precedence.
  • migration_v12_puts_the_outcome_flag_in_the_metrics_index covers both arrival paths — fresh database and one migrated from v11.

cargo nextest run 665/665, cargo fmt --check, cargo clippy --all-targets -- -D warnings and cargo deny check all clean.

Incidental fix

add_column_if_missing probed pragma_table_info, which omits generated columns. A fresh database — whose CREATE TABLE already declares has_result — would have been told the column was missing and failed the migration on duplicate column name. It uses pragma_table_xinfo now.

A latent dismissal bug this exposed

e2e failed on The next-step banner can be dismissed and stays dismissed, reproducibly, and bisecting it across four throwaway branches put the blame on the domain CTE — which on an empty database produces byte-identical responses to the query it replaced, and measures the same to a tenth of a millisecond. It was not the cause; it was the perturbation.

NextStepBanner takes the notice out of the DOM the moment the form is submitted and posts the dismissal in the background, so the operator never waits on it. Without keepalive that post is an ordinary fetch tied to the document: navigate in the same breath — click a nav link, reload — and the browser cancels it. The setting is never written and the notice is back on the page they land on. main wins that race often enough to stay green; this branch's timing lost it every run.

Confirmed by experiment rather than inference: the same tree with keepalive: true added and nothing else changed goes green.

The dismissal is also a real form post without JavaScript, and that path was never affected — verified against the running binary.

Notes

  • Migration 12 rebuilds the metrics index and runs ANALYZE: 0.3 s on an SSD, a one-time cost of perhaps ten seconds on an SD card at first boot after upgrading.
  • e2e was not runnable locally (no Chrome/Chromium on this machine); it is green in CI.
  • Screenshots not regenerated — this changes where the numbers come from, not the markup or the numbers.
  • Not addressed, as a possible follow-up: top clients (3 618) and domains (3 977) each scan their whole index, because (domain, timestamp) cannot be restricted by a timestamp range. A (timestamp, domain) index would only read the window whenever retention exceeds the range on screen — at the cost of another index on the insert path.

🤖 Generated with Claude Code

henry40408 and others added 2 commits September 12, 2026 20:06
The page took 29 340 page misses to render a 7-day window on a 370 k-row
database — 115 MiB read off the disk for one screen. Two causes, both
invisible in wall-clock terms on an SSD and both expensive on the SD card
an appliance actually runs from:

`outcome_breakdown_since` classified a query by testing `result` for
emptiness, which no index carried, so it walked the timestamp index and
then looked up every matching row: the entire 10 784-page table, 12 173
misses on its own. It now rides a VIRTUAL generated column in
`idx_query_logs_ts_metrics`, which costs no table space and 65 pages of
index, and answers from the index alone at 2 157. Version 11's comment
called this approach slower; it was measured in milliseconds on an SSD,
where the page cache hides the reads.

The other four readings re-walked indexes each other had just finished
with. The timeline, both breakdowns and the latency histogram are four
foldings of one index over one window, and the top-domain list and the
unique-domain count are two foldings of another; the read pool spread
them over connections with 2 MiB of cache each, so nothing stayed warm.
`range_metrics_since` and `domain_stats_since` group at a grain fine
enough to derive each set, and the single-purpose functions the `/api`
endpoints call are now folds over the same two statements.

First response: 29 340 -> 13 291 page misses, 115 MiB -> 52 MiB.

`add_column_if_missing` had to move to `pragma_table_xinfo`: the plain
`table_info` omits generated columns, so a fresh database would have been
told `has_result` was missing and failed the migration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d in

Version 11 left the outcome breakdown scanning the whole log table on the
strength of a millisecond reading taken on an SSD. ARCHITECTURE.md now
says why that reading was the wrong one, names the instrumentation, and
documents the shared-scan shape the page reads through; CLAUDE.md points
at the benchmark that produces the figure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Sep 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.60274% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.16%. Comparing base (849a84d) to head (9edca7f).

Files with missing lines Patch % Lines
src/db.rs 97.70% 6 Missing ⚠️
src/admin/stats.rs 92.85% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #269      +/-   ##
==========================================
+ Coverage   91.03%   91.16%   +0.12%     
==========================================
  Files          31       31              
  Lines       10986    11168     +182     
==========================================
+ Hits        10001    10181     +180     
- Misses        985      987       +2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…ked on

`NextStepBanner` takes the notice out of the DOM the moment the form is
submitted and posts the dismissal in the background, so the operator never
waits on it. Without `keepalive` that post is an ordinary fetch tied to the
document: navigate in the same breath — click a nav link, reload — and the
browser cancels it. The setting is never written and the notice is back on
the page they land on.

Latent on main, which wins the race often enough for the e2e scenario to
pass; the query restructuring in this branch shifted the timing enough to
lose it every run. `The next-step banner can be dismissed and stays
dismissed` failed on the same tree without this flag and passes with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@henry40408
henry40408 merged commit c5d388a into main Sep 12, 2026
11 of 12 checks passed
@henry40408
henry40408 deleted the perf/stats-page-misses branch September 12, 2026 15:30
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