Analysis: bound the freshness query, let window sums skip chunks, share the Overview's catalog
ci / build-test (push) Successful in 2m34s

Three things the 1,000-meter x 10-year measurement found, each proved by
EXPLAIN or a statement count before and after. No tally moves.

Freshness had two jobs in one unbounded query. The mark -- when a meter
last delivered -- is now stored on meter_rollup_state and maintained by
every recompute, with a one-pass backfill in the migration, so an
import-only meter keeps its years-old last activity without reading a
single raw row. The rhythm that decides stale versus live is sampled
inside a 90-day window and only for meters that actually have a live
source; a source silent for longer than that is re-read unbounded, so it
is still called stale by its own rhythm rather than by a default. The
portfolio query went from 13.8 ms planning plus 36.1 ms execution across
all 123 reading chunks to 0.58 plus 0.44 ms across four.

Window sums took their time bounds only from the unnest join, so the
planner could not exclude chunks: a 1,960-window case scanned 1.39 M rows
in parallel and spilled a 45 MB sort. Repeating the overall min and max as
constants makes it five chunks and nested-loop index scans, 121.5 ms to
8.7 ms.

The Overview read the catalog three times, once for the quantities and
once for each of its two bills. One context and one catalog snapshot now
feed all three: 31 statements per load to 23.

The final timings on an idle machine are in docs/ANALYSIS_REPORT.md: the
brief's target request (100 meters, ten years, monthly) is 286 ms against
two seconds, and a startup rebuild of 1,000 meters is 279 s.
This commit is contained in:
Florian Schmidt
2026-09-20 11:16:48 +02:00
parent ec51419f64
commit 5a6f34a467
21 changed files with 1857 additions and 87 deletions
+30
View File
@@ -8,6 +8,7 @@ The note was kept current through Phase 5:
- §11: amendments from the Phase 1 module review.
- §12: amendments from the acceptance review.
- §13: decisions recorded with the final documentation.
- §14: amendments from the performance measurement.
- §9 and §10: extended with what the implementation measured and changed.
The outcome is in [ANALYSIS_REPORT.md](ANALYSIS_REPORT.md), the user-facing changes in
@@ -708,3 +709,32 @@ figure.
keep the existing detour and its draft.
- The theme defaults to dark when no `mv-theme` cookie is set.
- The Calculation tab words a calculation problem as the attention list does, one wording per `VirtualProblemKind`.
## 14. Amendments from the performance measurement
The measurement of brief §9.10 / D-56 on a synthetic 1,000-meter × 10-year instance named three costs that grow with
history rather than with what a page asks for. They are fixed here. No golden bill, reconciliation figure or displayed
value changes; only what a request reads does.
- **A-40 What a request reads (D-15, D-18, D-56).**
- **The freshness mark is stored, not searched.** `meter_rollup_state` gains `last_reading_at`: the stamp of the
meter's latest raw reading, written by the recompute that every write path already runs. D-18 is unchanged — the
last reading or event time is still the mark — but a request no longer queries `reading` to find it, so an
import-only meter's mark stays exactly as old as its data without planning across a decade of raw chunks. The
migration backfills the column in one pass, so nothing waits for a rebuild.
- **Only a live source's rhythm is sampled, and only from the recent past.** The median of D-18 is consulted for a
meter with a live source, so the reading times are read for those meters alone, bounded by
`FreshnessRules.RecentWindow` (90 days) — the bound is what lets PostgreSQL exclude the older chunks at plan time.
A live meter that delivered nothing inside the window has no rhythm there; those few meters are read again over
their whole history, so a long-silent source is still called stale by its own rhythm. An instance without any live
source reads `reading` not at all.
- **The window-sum statement carries its overall bounds.** Its windows arrive through an `unnest` join, so their
bounds are columns and exclude no chunk. The minimum start and maximum end of the window set are repeated as
constants in the `WHERE` clause. No row outside them can match any window, so no tally changes; with enough
windows it is the difference between an indexed probe and a parallel scan of the whole hypertable with a sort
spilling to disk.
- **One Overview load holds one catalog.** The page's quantities, its bill and its comparison's bill are three
figures of one period. `DashboardService.GetOverviewAsync` loads the meters, tanks, links and rollup states once
and passes that snapshot to all three (`CostReader.ReadAsync(db, catalog, …)`), so every figure answers from the
same snapshot and the load is not repeated. The cards, the change table and the composition are derived from those
results and are never priced again. `OverviewReadBudgetTests` asserts the statement count of one load.