Skip to content

Commit 95091b2

Browse files
committed
fix(migrations): address #1411 review — non-blocking FK validation + complete integration coverage
Apply five review findings on the #1126 FK ON DELETE drift fix: 1. Migration 0094 now uses ADD CONSTRAINT ... NOT VALID instead of a bare ADD CONSTRAINT. The bare form would have taken AccessExclusiveLock on flowsheet (~857k rows) for the full validation-scan duration, blocking on-air DJ writes during deploy. NOT VALID makes the ADD metadata-only and instant. Drizzle's migrator wraps the whole migration in one transaction (drizzle-orm/pg-core/dialect.js:60), so an in-migration VALIDATE would defeat the lock benefit — VALIDATE is documented as an out-of-band operator step in the header instead. For these five constraints the validation is effectively a no-op anyway: the existing NO ACTION FK already kept the reference relation consistent, and only the ON DELETE action is changing (forward-looking). 2. Integration spec now exercises all three ON DELETE actions in the fix (flowsheet SET NULL, rotation CASCADE, reviews CASCADE) instead of only the flowsheet SET NULL path. The previous spec ran two tests: a static pg_constraint.confdeltype assertion plus a single behavioural test. The behavioural test silently never ran because its library INSERT omitted four NOT NULL columns (artist_id, genre_id, format_id, code_number) and its flowsheet INSERT omitted play_order — so the spec was a false green for the actual cascade/SET-NULL behaviour. 3. Spec now uses the shared getTestDb() pool + withRollback helper from tests/utils/db.js instead of opening its own postgres() connection and rolling back via a hand-written intentional-error idiom. Matches the convention used by neighbouring specs (album-metadata-upsert, enrichment-worker-claim, library-identity-backfill). 4. Inserts now use the seed_db.sql baseline rows (artist 1, genre 11, format 1) to satisfy library's NOT NULL FK columns rather than threading FK resolution through the test. Sequence-assigned ids land at 7200+ (shape fixture sets library_id_seq to 7199), safely above the explicit-id fixture range. Local CI: lint, format:check, typecheck, test:unit all pass. Migration dry-run not available locally (Docker daemon not running); will be covered by CI's migrate-dryrun job since the change touches db-init paths.
1 parent 3147e26 commit 95091b2

3 files changed

Lines changed: 153 additions & 59 deletions

File tree

shared/database/src/migrations/0094_fix-fk-on-delete-flowsheet-rotation-reviews.sql

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,27 +17,85 @@
1717
--
1818
-- This migration follows the pattern in `0048_fix-fk-on-delete-set-null.sql`
1919
-- (the predecessor that patched the analogous drift for schedule /
20-
-- shift_covers / shows.primary_dj_id; see #433). The five constraints below
21-
-- were missed by 0048.
20+
-- shift_covers / shows.primary_dj_id; see #433) but uses `ADD CONSTRAINT
21+
-- ... NOT VALID` rather than a bare `ADD CONSTRAINT` to avoid blocking
22+
-- writes on flowsheet (~857k prod rows) during the deploy. The five
23+
-- constraints below were missed by 0048.
24+
--
25+
-- ## Lock behaviour: why NOT VALID (and why VALIDATE runs out-of-band)
26+
--
27+
-- A bare `ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY ...` takes an
28+
-- `AccessExclusiveLock` AND runs a full-table validation scan that holds
29+
-- the lock for the entire scan — blocking every concurrent INSERT/UPDATE/
30+
-- DELETE on the table for the deploy's duration. On an on-air station with
31+
-- active DJs writing flowsheet rows in real time, that is a user-visible
32+
-- outage window.
33+
--
34+
-- `ADD CONSTRAINT ... NOT VALID` skips the validation scan: it takes
35+
-- `AccessExclusiveLock` for a metadata-only change and releases it
36+
-- instantly. New writes are enforced against the new FK shape immediately;
37+
-- only retroactive validation of pre-existing rows is deferred.
38+
--
39+
-- The companion `ALTER TABLE ... VALIDATE CONSTRAINT` runs the scan under
40+
-- the lighter `ShareUpdateExclusiveLock`, which allows concurrent SELECT,
41+
-- INSERT, UPDATE, DELETE. **But this benefit only materializes if VALIDATE
42+
-- runs in its OWN transaction** — Drizzle's migrator
43+
-- (`drizzle-orm/pg-core/dialect.js:60`) wraps the entire migration in one
44+
-- `session.transaction()`, so a VALIDATE statement inside the migration
45+
-- file would run under the AccessExclusiveLock that the preceding DROP /
46+
-- ADD already acquired, defeating the point. We therefore omit VALIDATE
47+
-- here and document it as the post-deploy operator step below.
48+
--
49+
-- For these five constraints the validation is effectively a no-op anyway:
50+
-- the existing `NO ACTION` FK has already kept the reference relation
51+
-- consistent (every flowsheet.album_id either points at a live library.id
52+
-- or is NULL). Changing only the `ON DELETE` action does not introduce
53+
-- any new data invariant on existing rows — the action governs future
54+
-- parent-row DELETEs. VALIDATE still has to scan because PostgreSQL
55+
-- tracks the `convalidated` flag per constraint; until VALIDATE runs the
56+
-- constraint is recorded as "trusted for new writes but not proven for
57+
-- old rows."
58+
--
59+
-- ## Post-deploy operator step
60+
--
61+
-- After this migration deploys, an operator runs the following five
62+
-- statements (each in its own implicit transaction — do NOT wrap them in
63+
-- BEGIN/COMMIT) during a low-write window to clear the unvalidated state.
64+
-- Skipping this step is harmless for correctness; it only leaves the
65+
-- constraints with `convalidated = false` until the next operator runs
66+
-- it. A bare `ANALYZE` is not needed (no row mutations).
67+
--
68+
-- ALTER TABLE "wxyc_schema"."flowsheet" VALIDATE CONSTRAINT "flowsheet_show_id_shows_id_fk";
69+
-- ALTER TABLE "wxyc_schema"."flowsheet" VALIDATE CONSTRAINT "flowsheet_album_id_library_id_fk";
70+
-- ALTER TABLE "wxyc_schema"."flowsheet" VALIDATE CONSTRAINT "flowsheet_rotation_id_rotation_id_fk";
71+
-- ALTER TABLE "wxyc_schema"."rotation" VALIDATE CONSTRAINT "rotation_album_id_library_id_fk";
72+
-- ALTER TABLE "wxyc_schema"."reviews" VALIDATE CONSTRAINT "reviews_album_id_library_id_fk";
73+
--
74+
-- We DROP+ADD rather than `ALTER CONSTRAINT` because PostgreSQL has no
75+
-- syntax to change `ON DELETE` action in place — you must drop and recreate.
76+
-- The DROP itself is metadata-only and instant.
2277
--
2378
-- See WXYC/Backend-Service#1126 for the full drift table and reproduction.
79+
-- See PostgreSQL docs:
80+
-- https://www.postgresql.org/docs/current/sql-altertable.html (NOT VALID)
81+
-- https://www.postgresql.org/docs/current/explicit-locking.html (lock modes)
2482

2583
-- flowsheet.show_id → shows.id : NO ACTION → SET NULL
2684
ALTER TABLE "wxyc_schema"."flowsheet" DROP CONSTRAINT "flowsheet_show_id_shows_id_fk";
27-
ALTER TABLE "wxyc_schema"."flowsheet" ADD CONSTRAINT "flowsheet_show_id_shows_id_fk" FOREIGN KEY ("show_id") REFERENCES "wxyc_schema"."shows"("id") ON DELETE SET NULL ON UPDATE NO ACTION;
85+
ALTER TABLE "wxyc_schema"."flowsheet" ADD CONSTRAINT "flowsheet_show_id_shows_id_fk" FOREIGN KEY ("show_id") REFERENCES "wxyc_schema"."shows"("id") ON DELETE SET NULL ON UPDATE NO ACTION NOT VALID;
2886

2987
-- flowsheet.album_id → library.id : NO ACTION → SET NULL
3088
ALTER TABLE "wxyc_schema"."flowsheet" DROP CONSTRAINT "flowsheet_album_id_library_id_fk";
31-
ALTER TABLE "wxyc_schema"."flowsheet" ADD CONSTRAINT "flowsheet_album_id_library_id_fk" FOREIGN KEY ("album_id") REFERENCES "wxyc_schema"."library"("id") ON DELETE SET NULL ON UPDATE NO ACTION;
89+
ALTER TABLE "wxyc_schema"."flowsheet" ADD CONSTRAINT "flowsheet_album_id_library_id_fk" FOREIGN KEY ("album_id") REFERENCES "wxyc_schema"."library"("id") ON DELETE SET NULL ON UPDATE NO ACTION NOT VALID;
3290

3391
-- flowsheet.rotation_id → rotation.id : NO ACTION → SET NULL
3492
ALTER TABLE "wxyc_schema"."flowsheet" DROP CONSTRAINT "flowsheet_rotation_id_rotation_id_fk";
35-
ALTER TABLE "wxyc_schema"."flowsheet" ADD CONSTRAINT "flowsheet_rotation_id_rotation_id_fk" FOREIGN KEY ("rotation_id") REFERENCES "wxyc_schema"."rotation"("id") ON DELETE SET NULL ON UPDATE NO ACTION;
93+
ALTER TABLE "wxyc_schema"."flowsheet" ADD CONSTRAINT "flowsheet_rotation_id_rotation_id_fk" FOREIGN KEY ("rotation_id") REFERENCES "wxyc_schema"."rotation"("id") ON DELETE SET NULL ON UPDATE NO ACTION NOT VALID;
3694

3795
-- rotation.album_id → library.id : NO ACTION → CASCADE
3896
ALTER TABLE "wxyc_schema"."rotation" DROP CONSTRAINT "rotation_album_id_library_id_fk";
39-
ALTER TABLE "wxyc_schema"."rotation" ADD CONSTRAINT "rotation_album_id_library_id_fk" FOREIGN KEY ("album_id") REFERENCES "wxyc_schema"."library"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
97+
ALTER TABLE "wxyc_schema"."rotation" ADD CONSTRAINT "rotation_album_id_library_id_fk" FOREIGN KEY ("album_id") REFERENCES "wxyc_schema"."library"("id") ON DELETE CASCADE ON UPDATE NO ACTION NOT VALID;
4098

4199
-- reviews.album_id → library.id : NO ACTION → CASCADE
42100
ALTER TABLE "wxyc_schema"."reviews" DROP CONSTRAINT "reviews_album_id_library_id_fk";
43-
ALTER TABLE "wxyc_schema"."reviews" ADD CONSTRAINT "reviews_album_id_library_id_fk" FOREIGN KEY ("album_id") REFERENCES "wxyc_schema"."library"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
101+
ALTER TABLE "wxyc_schema"."reviews" ADD CONSTRAINT "reviews_album_id_library_id_fk" FOREIGN KEY ("album_id") REFERENCES "wxyc_schema"."library"("id") ON DELETE CASCADE ON UPDATE NO ACTION NOT VALID;

shared/database/src/migrations/meta/applied-hashes.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,5 +91,5 @@
9191
"0091_venues-and-concerts": "e738de9bdb0ab762c982a6e31f664f88705bc2b7336bbd57a15efe3b250e6e87",
9292
"0092_normalize-artist-name": "57bf7f03733591917917c5a19b9b67d8527a64ccce224cdc3f1102f4a9cca368",
9393
"0093_concerts-first-scraped-at": "bbeee146c92ab63fa70e9f2c537d5a06a13f80a5f52c23cb5a1bed1432057421",
94-
"0094_fix-fk-on-delete-flowsheet-rotation-reviews": "35e9df0f17f40dbb348b0a4e437bc3d44c72260a2eeeef9572046f3eef713e47"
94+
"0094_fix-fk-on-delete-flowsheet-rotation-reviews": "558e6ce5fa9ae4589a988f15ea3e8fd798c430a94c20c5dd480b4ab8290ebaf0"
9595
}

tests/integration/fk-on-delete-flowsheet-rotation-reviews.spec.js

Lines changed: 87 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -12,30 +12,19 @@
1212
*
1313
* This spec is the live regression test: it asserts the current
1414
* `pg_constraint.confdeltype` for each of the five FKs (mirroring the
15-
* issue body's reproduction query) AND exercises the actual ON DELETE
16-
* behaviour via a parent-row DELETE under transaction rollback (so the
17-
* shape fixture loaded by globalSetup is not perturbed).
15+
* issue body's reproduction query) AND exercises each of the three
16+
* actual ON DELETE behaviours (flowsheet SET NULL, rotation CASCADE,
17+
* reviews CASCADE) via a parent-row DELETE under transaction rollback
18+
* (so the shape fixture loaded by globalSetup is not perturbed).
1819
*
1920
* If a future schema change re-introduces the drift, this spec fails
2021
* before the migration is ever attempted against prod.
2122
*/
2223

23-
const postgres = require('postgres');
24+
const { getTestDb, withRollback } = require('../utils/db');
2425

2526
const SCHEMA = process.env.WXYC_SCHEMA_NAME || 'wxyc_schema';
2627

27-
function makeSql() {
28-
return postgres({
29-
host: process.env.DB_HOST || 'localhost',
30-
port: parseInt(process.env.DB_PORT || process.env.CI_DB_PORT || '5433', 10),
31-
database: process.env.DB_NAME || 'wxyc_db',
32-
user: process.env.DB_USERNAME || 'test-user',
33-
password: process.env.DB_PASSWORD || 'test-pw',
34-
onnotice: () => {},
35-
max: 2,
36-
});
37-
}
38-
3928
// Map from pg_constraint.confdeltype's single-char encoding to the SQL
4029
// keyword we expect, for readable assertion failure messages.
4130
const CONFDELTYPE_LABEL = {
@@ -46,15 +35,22 @@ const CONFDELTYPE_LABEL = {
4635
d: 'SET DEFAULT',
4736
};
4837

38+
// Seeded baseline (dev_env/seed_db.sql): artists 1-3, genres 1-15
39+
// (rock=11), formats include cd=1 / vinyl=2. The shape fixture
40+
// (tests/fixtures/shape.sql) advances the library/rotation/flowsheet
41+
// sequences to 7199 so any serial-assigned id below lands at 7200+,
42+
// safely above the explicit-id fixture range (7000-7099). Each
43+
// behavioural test wraps its INSERT/DELETE in a withRollback transaction
44+
// so the fixture stays intact for downstream specs.
45+
const SEED_ARTIST_ID = 1;
46+
const SEED_GENRE_ID = 11;
47+
const SEED_FORMAT_ID = 1;
48+
4949
describe('FK ON DELETE on flowsheet / rotation / reviews (#1126, migration 0094)', () => {
5050
let sql;
5151

5252
beforeAll(() => {
53-
sql = makeSql();
54-
});
55-
56-
afterAll(async () => {
57-
if (sql) await sql.end({ timeout: 5 });
53+
sql = getTestDb();
5854
});
5955

6056
test('pg_constraint.confdeltype matches the schema-source declarations', async () => {
@@ -96,35 +92,75 @@ describe('FK ON DELETE on flowsheet / rotation / reviews (#1126, migration 0094)
9692
});
9793

9894
test('DELETE on a library row sets flowsheet.album_id to NULL (was NO ACTION)', async () => {
99-
// Run inside a transaction we abort, so the fixture stays intact for
100-
// any spec that runs after this one in the same Jest worker.
101-
await sql
102-
.begin(async (tx) => {
103-
// Insert a library row + a flowsheet entry referencing it. Use a
104-
// synthetic id well above the shape-fixture range (7000-7099) and
105-
// above the `bigserial`/`serial` PK floor for stability.
106-
const [lib] = await tx.unsafe(
107-
`INSERT INTO "${SCHEMA}".library (artist_name, album_title)
108-
VALUES ('FK Test Artist', 'FK Test Album')
109-
RETURNING id`
110-
);
111-
const [fls] = await tx.unsafe(
112-
`INSERT INTO "${SCHEMA}".flowsheet (album_id, entry_type, message)
113-
VALUES (${lib.id}, 'track', 'fk-test')
114-
RETURNING id`
115-
);
116-
117-
await tx.unsafe(`DELETE FROM "${SCHEMA}".library WHERE id = ${lib.id}`);
118-
119-
const after = await tx.unsafe(`SELECT album_id FROM "${SCHEMA}".flowsheet WHERE id = ${fls.id}`);
120-
expect(after.length).toBe(1);
121-
expect(after[0].album_id).toBeNull();
122-
123-
// Roll back so neither row survives the test.
124-
throw new Error('intentional rollback');
125-
})
126-
.catch((err) => {
127-
if (err.message !== 'intentional rollback') throw err;
128-
});
95+
await withRollback(async (tx) => {
96+
// library: artist_id, genre_id, format_id, album_title, code_number
97+
// are all NOT NULL (see shared/database/src/schema.ts:344).
98+
// Let `serial` auto-assign id from the post-fixture sequence floor
99+
// (7200+); the explicit-id fixture range stops at 7099.
100+
const [lib] = await tx`
101+
INSERT INTO ${tx(SCHEMA)}.library (artist_id, genre_id, format_id, album_title, code_number, artist_name)
102+
VALUES (${SEED_ARTIST_ID}, ${SEED_GENRE_ID}, ${SEED_FORMAT_ID},
103+
'FK Test Album SET NULL', 9001, 'FK Test Artist')
104+
RETURNING id
105+
`;
106+
// flowsheet: entry_type defaults to 'track'; play_order is NOT NULL
107+
// with no default (schema.ts:679).
108+
const [fls] = await tx`
109+
INSERT INTO ${tx(SCHEMA)}.flowsheet (album_id, entry_type, play_order, message)
110+
VALUES (${lib.id}, 'track', 1, 'fk-test-set-null')
111+
RETURNING id
112+
`;
113+
114+
await tx`DELETE FROM ${tx(SCHEMA)}.library WHERE id = ${lib.id}`;
115+
116+
const after = await tx`SELECT album_id FROM ${tx(SCHEMA)}.flowsheet WHERE id = ${fls.id}`;
117+
expect(after).toHaveLength(1);
118+
expect(after[0].album_id).toBeNull();
119+
});
120+
});
121+
122+
test('DELETE on a library row cascades to rotation rows (was NO ACTION)', async () => {
123+
await withRollback(async (tx) => {
124+
const [lib] = await tx`
125+
INSERT INTO ${tx(SCHEMA)}.library (artist_id, genre_id, format_id, album_title, code_number, artist_name)
126+
VALUES (${SEED_ARTIST_ID}, ${SEED_GENRE_ID}, ${SEED_FORMAT_ID},
127+
'FK Test Album CASCADE rotation', 9002, 'FK Test Artist')
128+
RETURNING id
129+
`;
130+
// rotation: album_id is nullable but referenced; rotation_bin is
131+
// NOT NULL (schema.ts:558).
132+
const [rot] = await tx`
133+
INSERT INTO ${tx(SCHEMA)}.rotation (album_id, rotation_bin)
134+
VALUES (${lib.id}, 'L')
135+
RETURNING id
136+
`;
137+
138+
await tx`DELETE FROM ${tx(SCHEMA)}.library WHERE id = ${lib.id}`;
139+
140+
const after = await tx`SELECT id FROM ${tx(SCHEMA)}.rotation WHERE id = ${rot.id}`;
141+
expect(after).toHaveLength(0);
142+
});
143+
});
144+
145+
test('DELETE on a library row cascades to reviews rows (was NO ACTION)', async () => {
146+
await withRollback(async (tx) => {
147+
const [lib] = await tx`
148+
INSERT INTO ${tx(SCHEMA)}.library (artist_id, genre_id, format_id, album_title, code_number, artist_name)
149+
VALUES (${SEED_ARTIST_ID}, ${SEED_GENRE_ID}, ${SEED_FORMAT_ID},
150+
'FK Test Album CASCADE reviews', 9003, 'FK Test Artist')
151+
RETURNING id
152+
`;
153+
// reviews: album_id is NOT NULL + UNIQUE (schema.ts:1075).
154+
const [rev] = await tx`
155+
INSERT INTO ${tx(SCHEMA)}.reviews (album_id, review, author)
156+
VALUES (${lib.id}, 'fk-test-cascade-reviews', 'fk-test')
157+
RETURNING id
158+
`;
159+
160+
await tx`DELETE FROM ${tx(SCHEMA)}.library WHERE id = ${lib.id}`;
161+
162+
const after = await tx`SELECT id FROM ${tx(SCHEMA)}.reviews WHERE id = ${rev.id}`;
163+
expect(after).toHaveLength(0);
164+
});
129165
});
130166
});

0 commit comments

Comments
 (0)