fix(db): carry entity_id in statements_prop_value_idx so ref lookups are index-only
Why
/v1/committees/totals returned HTTP 500 for understory's two large campaign sets after pdxcivic-postgres moved to the Hetzner data volume on 2026-10-13:
| Campaign | Committees | Before stopgap |
|---|---|---|
| multnomah-d2 | 38 | 500 at 61 s |
| washington-chair | 62 | 500 at 61 s |
| multnomah-chair | 3 | 200 in 8 s |
EXPLAIN ANALYZE of the exact d2 SQL on prod took 73 s against the pdxc_orestar_api role's 60 s statement_timeout; it measured 32.6 s on 2026-09-01. About 35 s of that is the refs step: 185,471 matches and 223,775 heap pages read, because statements_prop_value_idx stops at (dataset, prop, value) and every match needs a table read for its entity_id. Random reads are the network volume's weak point, the 4 GB host can't cache a 6.7 GB table, and the non-shared parallel hash join repeats the scan in each of three workers.
What
One migration, 20261014000001_ftm_statements_prop_value_covering.sql:
-
CREATE INDEX CONCURRENTLYthe same keys and partial filter withINCLUDE (entity_id), under a temporary name. -
DROP INDEX CONCURRENTLYthe old index. It isn't kept alongside because the ingest's refresh UPDATE is non-HOT, so every extra index costs a write on every re-observed statement. - Rename the new index to
statements_prop_value_idx, so the plan comments in orestar-api stay true.
atlas:txmode none + CONCURRENTLY, as in 20260805000002, so the ingest is never blocked.
Already done on prod (outside this MR)
- Plain
VACUUM (ANALYZE)onftm.statements: yesterday'sVACUUM FULLhad left the visibility map at 0 of 853,575 pages. -
ALTER ROLE pdxc_orestar_api SET statement_timeout = '180s': a stopgap. With it, d2 returns 200 in 74 s and washington-chair in 107 s.
Tested
- Migration applied with Atlas to a local
pdxcivic_testalready at20260805000003: ok, the index is valid, and the definition hasINCLUDE (entity_id). -
task orestar-api:test: 126 passed. -
pytest packages/ingestagainst the migrated test DB: 195 passed, 1 skipped (template placeholder). -
Not measured: the speedup on prod. That needs the index built there. After
task db:migrate:prod, re-run the understory totals probe and, if d2 and washington-chair land well under 60 s, set the role timeout back to 60 s.
Applying
task db:migrate:prod:status
op run --env-file=cli/prod-migrate.env.op -- task db:migrate:prod
If the concurrent build is interrupted it leaves an INVALID statements_prop_value_cov_idx; drop it by hand before re-running (noted in the file).