perf(db): index every acting foreign key and drop prefix indexes

Twenty-one foreign keys with ON DELETE CASCADE or SET NULL had no index
leading with their column, so deleting a parent scanned the child table
once per row; medication_intake_events.medication_id was the one an
account delete paid for. Seventeen small-table indexes and the measurements
(user_id, type, measured_at) index repeated the leading columns of another
index and only cost writes.

Migration 0415 adds and drops the small-table ones in one DO block under a
bounded lock wait; 0416 drops the measurements index concurrently, in a
file of its own. An integration test holds the schema to both rules from
the catalog, each with a counter-check, and compares the plans of the
user-and-type query shapes on a seeded table with and without the dropped
index. The lock-timeout guard now also requires a CONCURRENTLY statement
to stand alone in its file.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 13:07:24 +02:00
1 parent 41de0ca126
commit 03aec3ec12
6 files changed
+572 -21

No files matched your search

@@ -89,6 +89,56 @@ function violations(name: string, sql: string): string[] {
return found;
}
/**
* `CREATE INDEX CONCURRENTLY` / `DROP INDEX CONCURRENTLY` refuse to run in a
* transaction block, and under `migrate deploy` they do so whenever the file
* holds any other statement (SQLSTATE 25001, seen against Prisma 7 and
* Postgres 16). Such a statement therefore stands alone in its file.
*/
function concurrentViolations(name: string, sql: string): string[] {
const statements = topLevelStatements(sql);
const concurrent = statements.filter((statement) =>
/\bCONCURRENTLY\b/i.test(statement),
);
if (concurrent.length === 0 || statements.length === 1) return [];
return [
`${name}: a CONCURRENTLY statement must be the only statement in its file`,
];
}
describe("concurrent index statements", () => {
it("stand alone in their migration file", () => {
const found = migrations().flatMap(({ name, sql }) =>
concurrentViolations(name, sql),
);
expect(found, found.join("\n")).toEqual([]);
});
it("sees the concurrent migrations there are", () => {
const concurrent = migrations()
.filter(({ sql }) =>
/\bCONCURRENTLY\b/i.test(topLevelStatements(sql).join(";")),
)
.map(({ name }) => name);
expect(concurrent).toContain("0416_drop_measurements_type_prefix_index");
});
it("refuses a concurrent statement beside another one", () => {
expect(
concurrentViolations(
"a",
'DROP INDEX CONCURRENTLY IF EXISTS "x";\nDROP INDEX CONCURRENTLY IF EXISTS "y";',
),
).toHaveLength(1);
expect(
concurrentViolations(
"b",
'-- CONCURRENTLY in a comment\nDROP INDEX CONCURRENTLY IF EXISTS "x";',
),
).toEqual([]);
});
});
describe("migration lock waits", () => {
it("bounds lock waits where they take effect and marks only repeatable files", () => {
const found = migrations().flatMap(({ name, sql }) =>
@@ -97,11 +147,13 @@ describe("migration lock waits", () => {
expect(found, found.join("\n")).toEqual([]);
});
it("sees the one migration that bounds its wait today", () => {
it("sees the migrations that bound their wait", () => {
const marked = migrations()
.filter(({ sql }) => RETRY_MARKER.test(sql))
.map(({ name }) => name);
expect(marked).toContain("0412_measurement_hot_updates");
expect(marked).toContain("0414_drop_medication_compliance_rollups");
expect(marked).toContain("0415_foreign_key_and_prefix_indexes");
});
it("catches each shape it exists to refuse", () => {
@@ -19,8 +19,8 @@
* - All-time scan rather than a rolling window. The
* `personal_records_user_metric_idx` index makes
* `ORDER BY value LIMIT 1` constant-time; reading the worst-case
* ~30k measurement rows per metric is ~50 ms with the typed
* `measurements_user_id_type_measured_at_idx` index.
* ~30k measurement rows per metric is ~50 ms over the
* `(user_id, type, measured_at, …)` unique index.
* - Tie handling: an equal-value reading writes a row but the caller
* suppresses the push notification (the user matched, not beat
* their best). The notification suppression lives on the job