Skip to content

Commit 4f1eeed

Browse files
committed
fix(flowsheet): switch nextPlayOrder index to composite (show_id, play_order DESC) (#1133)
`flowsheet_play_order_idx` was introduced by 0073 as a single-column DESC index to back the then-global `SELECT max(play_order) FROM flowsheet`. #693 then scoped `nextPlayOrder()` to `WHERE show_id = ?`, leaving the index misaligned with the actual query shape: on the 2.6M-row flowsheet the planner has to either backward-scan the global DESC index and filter by show_id (slow for any non-current show), bitmap-scan `flowsheet_show_id_idx` + aggregate (multiple heap reads), or seq scan for small mis-estimated shows — none are the O(1) leaf lookup the original migration claimed. This swaps in a composite `(show_id, play_order DESC)` that makes the per-show MAX a true O(1) leaf-page lookup, with each show's rows forming a contiguous run inside the index whose leading edge is the per-show max. The same shape also covers the legacy mirror's `WHERE show_id = ? ORDER BY play_order DESC LIMIT 1` announcement lookups and `getEntriesByShow`'s `WHERE show_id IN (...) ORDER BY play_order DESC` listing — both of which the misaligned single-column index never served well either. The predecessor `flowsheet_play_order_idx` is dropped in the same migration. The only remaining global-MAX(play_order) caller is `jobs/flowsheet-etl/job.ts:resetSequences()`, a one-shot post-bulk-load sequence reset that runs occasionally and is not performance-sensitive. Test pins the migration shape (DROP + CREATE composite), the schema declaration (column order + DESC), and the post-#693 query shape in `nextPlayOrder()` so future drift either way trips before reaching prod. Closes #1133
1 parent c22a9f6 commit 4f1eeed

6 files changed

Lines changed: 4870 additions & 9 deletions

File tree

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
-- BS#1133. Swap the single-column DESC `flowsheet_play_order_idx`
2+
-- (migration 0073) for a composite `(show_id, play_order DESC)`
3+
-- index that matches the actual `nextPlayOrder()` query shape.
4+
--
5+
-- Background:
6+
-- - 0073 shipped a single-column DESC index on `play_order` to back the
7+
-- then-global `SELECT max(play_order) FROM flowsheet` that
8+
-- `nextPlayOrder()` ran on every POST /flowsheet/ insert. At that time
9+
-- it was correctly O(1): top of the index = global max.
10+
-- - #693 then rewrote `nextPlayOrder()` to scope by `show_id`
11+
-- (apps/backend/services/flowsheet.service.ts) so brand-new shows
12+
-- no longer inherit `max + 1` from a prior show's late additions —
13+
-- necessary to preserve dj-site's optimistic-update reconciliation.
14+
-- - The index shape was not revisited at the time. On the 2.6M-row
15+
-- `flowsheet` table the planner now has three choices, none O(1):
16+
-- 1) Backward index scan of `flowsheet_play_order_idx`, applying
17+
-- `show_id = ?` as a filter — fast only when the show is the
18+
-- most recent one (its rows are at the leading edge); for any
19+
-- earlier show the planner walks an increasingly large
20+
-- fraction of the index before finding a match.
21+
-- 2) Bitmap Index Scan on `flowsheet_show_id_idx` (0068) →
22+
-- Bitmap Heap Scan → Aggregate — multiple heap pages per
23+
-- show, well beyond the 0.119 ms #687 measured.
24+
-- 3) Seq scan + filter for small shows the planner mis-estimates.
25+
--
26+
-- A composite `(show_id, play_order DESC)` makes the per-show MAX a true
27+
-- O(1) leaf-page lookup: each show's rows form a contiguous run inside
28+
-- the index, with DESC ordering placing the per-show max at the run's
29+
-- leading edge. Same shape also covers two existing related queries:
30+
-- - `apps/backend/middleware/legacy/flowsheet.mirror.ts`'s show_start
31+
-- and show_end announcement lookups:
32+
-- SELECT * FROM flowsheet
33+
-- WHERE show_id = ?
34+
-- ORDER BY play_order DESC LIMIT 1;
35+
-- - `apps/backend/services/flowsheet.service.ts:getEntriesByShow`:
36+
-- SELECT ... FROM flowsheet
37+
-- WHERE show_id IN (...) ORDER BY play_order DESC;
38+
--
39+
-- The previous single-column `flowsheet_play_order_idx` is dropped in
40+
-- the same migration. The only remaining caller of the global
41+
-- MAX(play_order) shape is `jobs/flowsheet-etl/job.ts:resetSequences()`
42+
-- — a one-shot post-bulk-load sequence reset that runs occasionally
43+
-- (not on a hot request path), so the planner falling back to a seq
44+
-- scan there is acceptable and reclaims the 17 MB the single-column
45+
-- index used.
46+
--
47+
-- Production ops (run out-of-band BEFORE merging this PR to avoid
48+
-- the AccessExclusiveLock that the in-migration DDL would otherwise
49+
-- take on the 2.6M-row table during deploy):
50+
--
51+
-- CREATE INDEX CONCURRENTLY "flowsheet_show_id_play_order_idx"
52+
-- ON "wxyc_schema"."flowsheet"
53+
-- USING btree ("show_id", "play_order" DESC);
54+
--
55+
-- -- After the new index is VALID, drop the misaligned predecessor:
56+
-- DROP INDEX CONCURRENTLY IF EXISTS "wxyc_schema"."flowsheet_play_order_idx";
57+
--
58+
-- `CREATE INDEX CONCURRENTLY` takes only a `ShareUpdateExclusiveLock`,
59+
-- so DJs continue inserting flowsheet rows during the build. The new
60+
-- index's expected size is ~70 MB (composite on 2.6M rows, two ints
61+
-- per row plus tuple overhead) and the build typically completes in
62+
-- under a minute on the prod RDS instance — see the comparable 0080
63+
-- runbook for the same shape.
64+
--
65+
-- The in-migration forms below are NOT `CONCURRENTLY` because Drizzle
66+
-- wraps each migration in a transaction (`CREATE INDEX CONCURRENTLY
67+
-- cannot run inside a transaction block`). Same constraint and
68+
-- mitigation as 0057, 0061, 0068, 0070, 0074, 0078, 0080.
69+
--
70+
-- `CREATE INDEX IF NOT EXISTS` makes the create a no-op against the
71+
-- prod DB where the composite is already in place after the
72+
-- out-of-band build. `DROP INDEX IF EXISTS` makes the drop idempotent
73+
-- if the old index was already removed manually. Fresh dev databases
74+
-- pick the composite up on first migrate and never have the old
75+
-- single-column index to drop.
76+
77+
DROP INDEX IF EXISTS "wxyc_schema"."flowsheet_play_order_idx";--> statement-breakpoint
78+
CREATE INDEX IF NOT EXISTS "flowsheet_show_id_play_order_idx" ON "wxyc_schema"."flowsheet" USING btree ("show_id","play_order" DESC);

0 commit comments

Comments
 (0)