perf(programs): resolve the winning runs once, and stop sorting rows nobody reads in order (ADR-087) - #610
Conversation
…nobody reads in order `/api/programs/overview` answered 503 `statement_timeout` at 30 s on a cold process, and the page rendered "Failed to load program data" over "No active measures" — a database timeout presented as an empty catalog. Measured on the live sandbox, outside the nightly window: overview 503 at 30 s cold, 3.2-3.7 s warm overview?include=detail 59.8 s, with every downstream memo already warm programs/sites 17.2 s cold, 2.5 s warm, for five strings The 59.8 s is the finding. Warm, the only work left in that request is the winners walk, paid thirteen times by one page load — the overview, then a trend and a top-drivers pass per measure, each resolving the same runs again. `programSites` isolates it: warm, the walk is all it does, and it takes 2.5 s. `listLatestPopulationRuns` is two questions. The candidate runs are one indexed statement over `runs`. Which of them holds a row per measure is an `EXISTS` probe per (run, measure) with no `(run_id, measure_id)` index behind it. The probe result is now memoized under the candidate run list itself, per store instance: the cheap statement runs every call and IS the key, and a terminal population run's rows are immutable. Two writes can move the answer without moving the key — an outcome write adding a measure's first row to a run already in the list, and a compaction deleting a non-winner's last row — so both write paths and `compactOlderThan` drop the memo, on both stores. The 503 itself came from the aggregate read. `listOutcomes` orders by `(evaluated_at, id)`, which no index serves, so `aggregateOfficialRun` re-sorted one measure's whole 20,000 rows — `evidence_json` and all — once per `LIMIT/OFFSET` page, after a one-row provenance probe had sorted them too. Eleven sorts per (run, measure), six measures, on the cold path. It is now ONE unordered statement (`order: "none"`, opt-in, and a paged read keeps its ordering whatever the caller asks) which also reports `producedOfficialEvidence`, so the rate reads the rows once. And the read models are warmed at boot, off the request path, with one retry: every memo is in-process, so a deploy guarantees the cold path, and `warmReadModels` only ever ran after a run. The largest win left is `outcomes (run_id, measure_id)`, which is owner DDL — recommended with the numbers in ADR-087 and DEPLOY.md, not written here. The overview still derives its status buckets from 120,000 rows in JavaScript where a GROUP BY would return 90; deferred, because the site/tenant/profile predicates have no SQL form. ADR-087. Backend suite 2,856 tests: 2,832 pass, 23 skip, 1 pre-existing local failure (`corpus-membership`, this host's `.official-content` sparse checkout).
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 927e78d04d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| filter.to ?? null, | ||
| filter.excludeScale === true, | ||
| filter.excludeTrendHistory === true, | ||
| runs.map((r) => r.id), |
There was a problem hiding this comment.
Include fallback state in the probe-cache key
When the probe budget cannot satisfy a measure, the cached value also includes results from the fallback query over runs outside this candidate list, but the key contains only the top candidate IDs. If a separate writer adds backdated terminal runs below that budget—for example the seed:trend-history CLI while the server is running—the candidate IDs remain unchanged and the server instance is not cleared by the CLI's store, so trend requests continue returning the old winners indefinitely. Either avoid caching fallback-derived answers or include an identity that changes with the older qualifying rows.
Useful? React with 👍 / 👎.
| const { getStores } = await import("./stores/factory.ts"); | ||
| const { warmReadModels } = await import("./program/warm-read-models.ts"); | ||
| const stores = await getStores(schedulerEnv); | ||
| await warmReadModels({ |
There was a problem hiding this comment.
Propagate warm-up failures so the boot retry runs
When a read-model query fails after getStores succeeds, warmReadModels catches the overview failure (and each per-measure failure) and returns normally. This call therefore logs that the models were warmed and exits the loop instead of taking the advertised second attempt, leaving the first /programs request to pay the cold read after exactly the transient query failure this boot logic is intended to recover from. The warmer needs to report failure to this retry layer, or return a success/failure result that is checked here.
Useful? React with 👍 / 👎.
…timeout back Own code review of this branch found eight things. Five are fixed here; three are stated in ADR-087 because they are properties of the design rather than mistakes in it. **The winners memo reached the wrong store.** `getStores` caches its bundle in a WeakMap keyed by the env OBJECT, and a live container builds two: the `schedulerEnv` literal in `server.ts` and the one the host builds for the worker. Only the pool is module-global, so there are TWO `PgOutcomeStore` instances. Per-instance, the compaction invalidation landed on the instance that does the DELETING and stayed open on the one that does the READING, and the boot warm filled a cache no request would ever hit — so the cold `?include=detail` this branch exists to fix was essentially unimproved. The cache is now keyed by the database handle: one per pool, shared by both instances, and the ceiling and floor still separate because they are separate handles. **`officialMeasureRate` did not memoize `null`, and the null now costs a full read.** It used to cost one row, so leaving the negative uncached was free. Since provenance and aggregation share one read it costs the measure's whole evidence, and `programOverview` runs that loop per request OUTSIDE its own memo — so a measure with no official evidence (an all-errored nightly, a run predating a flip, any authored measure) would have re-read 20,000 rows on every dashboard load for the life of the process. That is the 30-second cliff this branch removes, reintroduced by the branch. **The aggregate lost its memory bound, and the note justifying that was wrong twice.** It claimed the unpaged read was "smaller than the 120,000-row read the overview already makes" and that rows are "folded as they arrive". `listOutcomesWithRun` has a lean projection carrying no `evidence_json` at all, and `for (const row of await ...)` awaits the whole parsed array. The bound is back as a PROJECTION rather than a page window: `listOutcomeMembershipsForRun` returns the `official` object and the `evaluationError` marker — the few dozen bytes per subject `run-aggregate.ts` already said a sum needs — in one unordered statement. This matters beyond the dashboard: the subject cap gates the individual and bundle MeasureReport variants only, so on a 20,000-patient run the summary report and QRDA III are the reachable exports and this is what bounds them. **`producedOfficialEvidence` was decided by a row that is now arbitrary.** Unordered, "the first evaluated row" is whatever the planner returns, and the answer is memoized — so one unreadable `populationResults` arriving first would have made an official measure read as authored and dropped its rate off the dashboard, non-deterministically. Any evaluated row carrying membership now settles it. The reason given for keeping the first-row rule — that asking every row would duplicate the unreadable-evidence alerts — was false: `aggregator.add` already calls `officialMembership` on every non-error row. **The boot warm's retry could not fire and its success line was printed on failure.** `warmReadModels` swallows every error by design, including the cold-connection failure the retry was keyed on, so the retry was unreachable and `read models warmed at boot` was logged whatever happened — and DEPLOY.md points an operator at that line. It returns a result now. Three tests were missing and one was vacuous. Removing `runs.map((r) => r.id)` from the probe key left the whole suite green, and that key is the ONLY thing invalidating a new nightly on the request-serving instance. The paged-ordering guard compared three rows whose insertion order was also their sort order, so it passed either way. Compaction's invalidation had no test at all. All three are pinned and mutation-checked, and the fakes now derive their membership read from their own fixture (`test-support/memberships.ts`) so the two readers cannot drift. Backend suite 2,869 tests: 2,845 pass, 23 skip, 1 pre-existing local failure (`corpus-membership`, this host's `.official-content` sparse checkout).
…nobody reads in order (ADR-087) (#610) * perf(programs): resolve the winning runs once, and stop sorting rows nobody reads in order `/api/programs/overview` answered 503 `statement_timeout` at 30 s on a cold process, and the page rendered "Failed to load program data" over "No active measures" — a database timeout presented as an empty catalog. Measured on the live sandbox, outside the nightly window: overview 503 at 30 s cold, 3.2-3.7 s warm overview?include=detail 59.8 s, with every downstream memo already warm programs/sites 17.2 s cold, 2.5 s warm, for five strings The 59.8 s is the finding. Warm, the only work left in that request is the winners walk, paid thirteen times by one page load — the overview, then a trend and a top-drivers pass per measure, each resolving the same runs again. `programSites` isolates it: warm, the walk is all it does, and it takes 2.5 s. `listLatestPopulationRuns` is two questions. The candidate runs are one indexed statement over `runs`. Which of them holds a row per measure is an `EXISTS` probe per (run, measure) with no `(run_id, measure_id)` index behind it. The probe result is now memoized under the candidate run list itself, per store instance: the cheap statement runs every call and IS the key, and a terminal population run's rows are immutable. Two writes can move the answer without moving the key — an outcome write adding a measure's first row to a run already in the list, and a compaction deleting a non-winner's last row — so both write paths and `compactOlderThan` drop the memo, on both stores. The 503 itself came from the aggregate read. `listOutcomes` orders by `(evaluated_at, id)`, which no index serves, so `aggregateOfficialRun` re-sorted one measure's whole 20,000 rows — `evidence_json` and all — once per `LIMIT/OFFSET` page, after a one-row provenance probe had sorted them too. Eleven sorts per (run, measure), six measures, on the cold path. It is now ONE unordered statement (`order: "none"`, opt-in, and a paged read keeps its ordering whatever the caller asks) which also reports `producedOfficialEvidence`, so the rate reads the rows once. And the read models are warmed at boot, off the request path, with one retry: every memo is in-process, so a deploy guarantees the cold path, and `warmReadModels` only ever ran after a run. The largest win left is `outcomes (run_id, measure_id)`, which is owner DDL — recommended with the numbers in ADR-087 and DEPLOY.md, not written here. The overview still derives its status buckets from 120,000 rows in JavaScript where a GROUP BY would return 90; deferred, because the site/tenant/profile predicates have no SQL form. ADR-087. Backend suite 2,856 tests: 2,832 pass, 23 skip, 1 pre-existing local failure (`corpus-membership`, this host's `.official-content` sparse checkout). * fix(programs): the review's five defects, including two that put the timeout back Own code review of this branch found eight things. Five are fixed here; three are stated in ADR-087 because they are properties of the design rather than mistakes in it. **The winners memo reached the wrong store.** `getStores` caches its bundle in a WeakMap keyed by the env OBJECT, and a live container builds two: the `schedulerEnv` literal in `server.ts` and the one the host builds for the worker. Only the pool is module-global, so there are TWO `PgOutcomeStore` instances. Per-instance, the compaction invalidation landed on the instance that does the DELETING and stayed open on the one that does the READING, and the boot warm filled a cache no request would ever hit — so the cold `?include=detail` this branch exists to fix was essentially unimproved. The cache is now keyed by the database handle: one per pool, shared by both instances, and the ceiling and floor still separate because they are separate handles. **`officialMeasureRate` did not memoize `null`, and the null now costs a full read.** It used to cost one row, so leaving the negative uncached was free. Since provenance and aggregation share one read it costs the measure's whole evidence, and `programOverview` runs that loop per request OUTSIDE its own memo — so a measure with no official evidence (an all-errored nightly, a run predating a flip, any authored measure) would have re-read 20,000 rows on every dashboard load for the life of the process. That is the 30-second cliff this branch removes, reintroduced by the branch. **The aggregate lost its memory bound, and the note justifying that was wrong twice.** It claimed the unpaged read was "smaller than the 120,000-row read the overview already makes" and that rows are "folded as they arrive". `listOutcomesWithRun` has a lean projection carrying no `evidence_json` at all, and `for (const row of await ...)` awaits the whole parsed array. The bound is back as a PROJECTION rather than a page window: `listOutcomeMembershipsForRun` returns the `official` object and the `evaluationError` marker — the few dozen bytes per subject `run-aggregate.ts` already said a sum needs — in one unordered statement. This matters beyond the dashboard: the subject cap gates the individual and bundle MeasureReport variants only, so on a 20,000-patient run the summary report and QRDA III are the reachable exports and this is what bounds them. **`producedOfficialEvidence` was decided by a row that is now arbitrary.** Unordered, "the first evaluated row" is whatever the planner returns, and the answer is memoized — so one unreadable `populationResults` arriving first would have made an official measure read as authored and dropped its rate off the dashboard, non-deterministically. Any evaluated row carrying membership now settles it. The reason given for keeping the first-row rule — that asking every row would duplicate the unreadable-evidence alerts — was false: `aggregator.add` already calls `officialMembership` on every non-error row. **The boot warm's retry could not fire and its success line was printed on failure.** `warmReadModels` swallows every error by design, including the cold-connection failure the retry was keyed on, so the retry was unreachable and `read models warmed at boot` was logged whatever happened — and DEPLOY.md points an operator at that line. It returns a result now. Three tests were missing and one was vacuous. Removing `runs.map((r) => r.id)` from the probe key left the whole suite green, and that key is the ONLY thing invalidating a new nightly on the request-serving instance. The paged-ordering guard compared three rows whose insertion order was also their sort order, so it passed either way. Compaction's invalidation had no test at all. All three are pinned and mutation-checked, and the fakes now derive their membership read from their own fixture (`test-support/memberships.ts`) so the two readers cannot drift. Backend suite 2,869 tests: 2,845 pass, 23 skip, 1 pre-existing local failure (`corpus-membership`, this host's `.official-content` sparse checkout). --------- Co-authored-by: Taleef <taleef@gmail.com>
/programson the pilot was not slow, it was failing. Reproduced from the numbers below, not fromthe screenshot:
GET /api/programs/overviewanswers 503statement_timeoutat 30 s on a coldprocess, and the page renders "Failed to load program data" over "No active measures. Create and
release a measure to begin." — a database timeout shown to a quality lead as an empty catalog.
Measured first (live Maui, 20,000 patients, 17:59–18:20Z, outside the nightly window)
GET /api/programs/overviewGET /api/programs/overview?include=detail&granularity=monthGET /api/programs/sites— asked on every page loadGET /api/programs/:id/trend?granularity=monthGET /api/programs/:id/top-driversGET /api/cases?status=open&limit=25(ADR-084's fast path)GET /api/exports/outcomes?runId=<winner>The 59.8 s is the finding, and it is not the total. It was measured with every downstream memo
already warm — so the whole minute was the winners walk, paid thirteen times by one page load.
programSitesisolates it: warm, its only remaining work islatestPopulationWinners, and it takes2.5 s to return five strings.
Three costs, one page
1 · The winners walk is not memoized.
listLatestPopulationRunsis two questions in one method.WHICH runs qualify is one indexed statement over
runs(boundedLIMIT, milliseconds). WHICH of themholds a row for each measure is an
EXISTSprobe per (run, measure), andoutcomeshas no(run_id, measure_id)index — so the pair costs 2.5–4 s, and every read model resolved its own.The probe is now memoized under the candidate run list itself, per store instance. The cheap
statement runs every call and IS the key. That identity is exact rather than a TTL: a probe's answer
depends only on which runs are in the list and which rows they hold, a terminal population run's rows
are immutable — the fact every
RunKeyedMemoand the roster cell cache already rest on — and a runenters the list only once terminal.
2 ·
aggregateOfficialRunsorted one measure's whole evidence eleven times — this is the 503.listOutcomesorders by(evaluated_at, id)and no index serves it, so an ordered read of one measureof a whole-population run sorts the measure's 20,000 rows,
evidence_jsonand all. The aggregate paidthat once for a one-row provenance probe and once per
LIMIT/OFFSETpage, each page re-running thesame filter and the same sort to skip further into it. Six measures, on the cold path.
Now ONE unordered statement.
listOutcomesgainedorder: "none"— opt-in, for a caller that foldsits rows; a paged read keeps its ordering whatever the caller asks, because paging an unordered
relation may repeat or skip rows. The aggregate also reports
producedOfficialEvidence, soofficialMeasureRatereads the rows once instead of probing and then aggregating.That the overview's other reads are not the culprit is why the export row is in the table: 120,000
rows with evidence come back in one statement without timing out, and every other read the overview
makes is smaller than that.
3 · Every memo is in-process, so a deploy guarantees a cold dashboard.
warmReadModelshas existedsince #547 and runs after each population run — which covers the nightly and misses every restart, the
one moment the caches are certainly empty. It now runs at boot too, off the request path, twice if
the first attempt fails, never once shutdown has begun, and best-effort exactly as the post-run warm
is. Two greppable log lines, documented in
DEPLOY.md.What this does NOT fix, named rather than implied
CREATE INDEX … ON outcomes (run_id, measure_id)is almost certainly the largest single win left,and it is owner DDL. It would take the probes from 2.5–4 s to index lookups and give the evidence
read a plan that does not sort. Additive, reversible, no data migration — the same class as the three
OWNER-APPROVED DDLblocks already inschema-pg.ts. Recommended with the measurements in ADR-087 andDEPLOY.md, and deliberately not written: schema is the owner's. What ships here removes repeatedwork; the index would make each unit of work cheap. They are not substitutes.
The overview still derives its status buckets from 120,000 rows in JavaScript, where
GROUP BY measure_id, status, out_of_populationover the winners returns about 90. ADR-084's ownpattern applies, but the site, tenant and profile predicates read the in-memory directory and have no
SQL form — so it is a fast-path-plus-fallback with a conformance test. Deferred on purpose: worth
measuring this change before shipping two performance stories in one breath.
Honest about the method
The endpoint timings are measured on the live stack. WHICH statement the server cancelled is
inferred from the read shapes: this host cannot run a pilot-scale Postgres (2.0 GB free of 15.2,
Docker down) and the deployment gives no query log, so there is no
EXPLAIN ANALYZEbehind theattribution. Recorded as inference in ADR-087 so nobody later cites it as a measurement. The AFTER
numbers are measured on the deploy and written into
JOURNAL.mdand guide ch. 9 — never predicted.Verification
backend-tssuitetsc --noEmitpnpm test:shards:verifyProbeCache, 5 ceiling-SQL, 2 store-contract × both stores, 3 rate, +1 paging)The one failure is
corpus-membership.test.ts— this host's.official-contentsparse checkout, thesame pre-existing failure #609 carried. Unrelated to these files.
Review round: eight findings, five of them defects in the above
My own code review of this branch found eight things. Two would have put the timeout back, and one
was a fresh vacuous guard created while fixing three others. Commit
ae90ba97.The memo reached the wrong store.
getStorescaches its bundle in aWeakMapkeyed by the envOBJECT, and a live container builds two: the
schedulerEnvliteral inserver.tsand the one the hostbuilds for the worker. Only the pool is module-global, so there are two
PgOutcomeStoreinstances.Per-instance, the compaction invalidation landed on the instance that does the DELETING and stayed open
on the one that does the READING — and the boot warm filled a cache no request would ever hit, so the
cold
?include=detailthis PR exists to fix was essentially unimproved. Now keyed by the databasehandle.
officialMeasureRatedid not memoizenull, and the null now costs a full read.programOverviewruns that loop per request outside its own memo, so a measure with no official evidence (an
all-errored nightly, a run predating a flip, any authored measure) would have re-read its whole
evidence on every dashboard load, forever. The 30-second cliff, reintroduced by the fix for it.
The aggregate lost its memory bound and my note for it was wrong twice. I claimed the unpaged read
was "smaller than the 120,000-row read the overview already makes" and that rows are "folded as they
arrive".
listOutcomesWithRunhas a lean projection with noevidence_jsonat all, andfor (const row of await …)awaits the whole parsed array. The bound is back as a projection —listOutcomeMembershipsForRunreturns theofficialobject and the error marker, the few dozen bytesrun-aggregate.tsalready said a sum needs. This matters beyond the dashboard: the subject cap gatesthe individual and bundle MeasureReport variants only, so on a 20,000-patient run the summary report
and QRDA III are the reachable exports and this is what bounds them.
routes/runs.ts's commentclaiming the cap covered them is corrected.
producedOfficialEvidencewas decided by an arbitrary row. Unordered, "first evaluated row" iswhatever the planner returns, and the answer is memoized — so one unreadable
populationResultsarriving first would have made an official measure read as authored and dropped its rate off the
dashboard, non-deterministically. Any evaluated row now settles it. My reason for keeping the first-row
rule (duplicate alerts) was false:
aggregator.addalready callsofficialMembershipon everynon-error row.
The boot warm's retry could not fire, and its success line printed on failure.
warmReadModelsswallows every error by design — including the cold-connection failure the retry was keyed on — so
read models warmed at bootwas logged whatever happened, andDEPLOY.mdpoints an operator at thatline. It returns a result now. A control that reads as present and cannot fire is the defect class this
project keeps finding, and I added one while removing three.
Three tests were missing and one was vacuous
runs.map((r) => r.id)from the probe keyAll three are pinned and mutation-checked. The fakes now derive their membership read from their own
fixture (
test-support/memberships.ts), so the two readers cannot drift.Stated in ADR-087 rather than fixed
finalizeRunflips a run into thequalifying set after its rows were written, so a run too old for the 25-run probe budget can enter
step 2's reckoning without the candidate list moving. Two adjacent statements of one finalize, and it
takes a backdated rerun to reach.
Map's insertion order). Differs onlywhere one (run, measure)'s rows carry heterogeneous stratifier sets; every stratum carries its own
id, so a consumer keying by id is unaffected.runProducedOfficialEvidence, and only when therouting flag is off — so on the pilot it short-circuits and this is TWH's authored rosters.
Verification after the review round
backend-tssuitecorpus-membership, pre-existing, this host's sparse checkout)tsc --noEmitpnpm test:shards:verifyfrontendsuite / lint