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

@@ -0,0 +1,101 @@
-- Index hygiene: a leading index for every foreign key that cascades, and
-- no index that is a prefix of another.
--
-- 1. Twenty-one foreign keys with ON DELETE CASCADE or SET NULL had no index
-- leading with their column. Deleting the parent row (a medication, a
-- schedule, a practitioner, an account) then scans the whole child table
-- once per deleted parent: `medication_intake_events.medication_id` is the
-- one that hurts, scanned in full for every medication an account delete
-- removes. Each gets a plain btree on the column.
--
-- 2. Seventeen indexes repeated the leading columns of another index on the
-- same table, in the same order and direction, mostly a `(user_id)` index
-- beside a `(user_id, …)` unique key. The longer index answers every
-- lookup the shorter one did, so the shorter one only cost writes and
-- space. They go. `measurements` has its own migration (0416), because a
-- lock on that table weighs differently.
--
-- `tests/integration/index-hygiene.test.ts` holds the schema to both rules
-- from the catalog.
--
-- Lock wait. `CREATE INDEX` takes SHARE on its table (reads go on, writes
-- wait while it builds; every table here is small, so the build is quick)
-- and `DROP INDEX` takes ACCESS EXCLUSIVE for the instant the catalog
-- changes. `CREATE INDEX CONCURRENTLY` cannot share a migration file with
-- any other statement (0416), so the whole set runs in one `DO` block: one
-- statement that commits or rolls back whole, with the lock wait bounded at
-- five seconds so a long reader cannot queue the tables' writes behind the
-- migration. Every statement is `IF [NOT] EXISTS`, so a lock timeout leaves
-- the file safe to apply again, which the marker declares
-- (docs/ops/deploy.md, "A migration that timed out on a lock").
--
-- @retry-on-lock-timeout
DO $migration$
BEGIN
PERFORM set_config('lock_timeout', '5s', true);
-- 1. Foreign keys without a leading index.
CREATE INDEX IF NOT EXISTS "passkeys_user_id_idx"
ON "passkeys" ("user_id");
CREATE INDEX IF NOT EXISTS "medication_schedules_medication_id_idx"
ON "medication_schedules" ("medication_id");
CREATE INDEX IF NOT EXISTS "medication_intake_events_medication_id_idx"
ON "medication_intake_events" ("medication_id");
CREATE INDEX IF NOT EXISTS "telegram_reminder_messages_schedule_id_idx"
ON "telegram_reminder_messages" ("schedule_id");
CREATE INDEX IF NOT EXISTS "medication_inventory_items_medication_id_idx"
ON "medication_inventory_items" ("medication_id");
CREATE INDEX IF NOT EXISTS "medication_side_effects_medication_id_idx"
ON "medication_side_effects" ("medication_id");
CREATE INDEX IF NOT EXISTS "withings_oauth_states_user_id_idx"
ON "withings_oauth_states" ("user_id");
CREATE INDEX IF NOT EXISTS "whoop_oauth_states_user_id_idx"
ON "whoop_oauth_states" ("user_id");
CREATE INDEX IF NOT EXISTS "fitbit_oauth_states_user_id_idx"
ON "fitbit_oauth_states" ("user_id");
CREATE INDEX IF NOT EXISTS "google_health_oauth_states_user_id_idx"
ON "google_health_oauth_states" ("user_id");
CREATE INDEX IF NOT EXISTS "whoop_connect_tickets_user_id_idx"
ON "whoop_connect_tickets" ("user_id");
CREATE INDEX IF NOT EXISTS "cycle_day_logs_cycle_id_idx"
ON "cycle_day_logs" ("cycle_id");
CREATE INDEX IF NOT EXISTS "invite_tokens_created_by_idx"
ON "invite_tokens" ("created_by");
CREATE INDEX IF NOT EXISTS "invite_tokens_used_by_idx"
ON "invite_tokens" ("used_by");
CREATE INDEX IF NOT EXISTS "invite_redemptions_user_id_idx"
ON "invite_redemptions" ("user_id");
CREATE INDEX IF NOT EXISTS "custom_metric_entries_custom_metric_id_idx"
ON "custom_metric_entries" ("custom_metric_id");
CREATE INDEX IF NOT EXISTS "encounters_reminder_id_idx"
ON "encounters" ("reminder_id");
CREATE INDEX IF NOT EXISTS "vaccination_records_encounter_id_idx"
ON "vaccination_records" ("encounter_id");
CREATE INDEX IF NOT EXISTS "vaccination_records_practitioner_id_idx"
ON "vaccination_records" ("practitioner_id");
CREATE INDEX IF NOT EXISTS "vaccination_records_reminder_id_idx"
ON "vaccination_records" ("reminder_id");
CREATE INDEX IF NOT EXISTS "managed_profile_handovers_created_by_id_idx"
ON "managed_profile_handovers" ("created_by_id");
-- 2. Indexes that are a prefix of another index on the same table.
DROP INDEX IF EXISTS "biomarkers_user_id_idx";
DROP INDEX IF EXISTS "clinician_share_link_documents_share_link_id_idx";
DROP INDEX IF EXISTS "coach_usage_user_id_date_key_idx";
DROP INDEX IF EXISTS "custom_metrics_user_id_idx";
DROP INDEX IF EXISTS "cycle_day_logs_user_id_date_idx";
DROP INDEX IF EXISTS "data_backups_user_id_idx";
DROP INDEX IF EXISTS "environment_contexts_user_id_date_idx";
DROP INDEX IF EXISTS "illness_day_logs_episode_id_idx";
DROP INDEX IF EXISTS "integration_statuses_user_id_idx";
DROP INDEX IF EXISTS "medications_user_id_idx";
DROP INDEX IF EXISTS "menstrual_cycles_user_id_start_date_idx";
DROP INDEX IF EXISTS "mood_entries_user_id_date_idx";
DROP INDEX IF EXISTS "notification_channels_user_id_idx";
DROP INDEX IF EXISTS "push_subscriptions_user_id_idx";
DROP INDEX IF EXISTS "refresh_tokens_user_id_idx";
DROP INDEX IF EXISTS "user_achievements_user_id_idx";
DROP INDEX IF EXISTS "user_known_devices_user_id_idx";
END
$migration$;
@@ -0,0 +1,17 @@
-- Drop `measurements_user_id_type_measured_at_idx`.
--
-- `(user_id, type, measured_at)` is the leading part of the unique key
-- `(user_id, type, measured_at, source, sleep_stage)`, and live reads have
-- `measurements_live_covering_idx` besides. Every query shape that used the
-- shorter index plans the same way on the unique key, at a cost within a few
-- percent (`tests/integration/index-hygiene.test.ts` compares the plans on a
-- seeded table). The index cost every write to the densest table in the
-- schema and, on a bloated instance, more space than the table itself.
--
-- `DROP INDEX CONCURRENTLY` takes no lock that blocks reads or writes: it
-- waits for the transactions already using the index to finish and then
-- removes it. It refuses to run inside a transaction block, and under
-- `migrate deploy` it does so whenever the file holds any other statement
-- (Prisma 7 on Postgres 16 reports 25001), so this file holds exactly this
-- one statement. `IF EXISTS` makes a re-run a no-op.
DROP INDEX CONCURRENTLY IF EXISTS "measurements_user_id_type_measured_at_idx";
+39 -18
View File
@@ -1248,7 +1248,6 @@ model EnvironmentContext {
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([userId, date])
@@index([userId, date])
// `environment_contexts_aq_gap_idx` (user_id, date) WHERE aq_fetched_at IS
// NULL is a partial index from migration 0381 (raw SQL only).
@@map("environment_contexts")
@@ -1300,6 +1299,8 @@ model Passkey {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// Leads the foreign key, so a cascade from the parent row is an index lookup.
@@index([userId])
@@map("passkeys")
}
@@ -1484,7 +1485,6 @@ model UserKnownDevice {
// twice for one account, so the "first sighting → alert" decision is a
// single unique-constrained upsert.
@@unique([userId, deviceHash])
@@index([userId])
@@map("user_known_devices")
}
@@ -2088,7 +2088,6 @@ model Measurement {
// v1.4.23 — Apple Health batch dedup key. NULL externalId is distinct
// (Postgres semantics) so manual entries don't collide here.
@@unique([userId, type, source, externalId])
@@index([userId, type, measuredAt])
// v1.25 — type-less `(userId, measuredAt)` range scan. The bulk feature read
// (`extractFeatures`) and consolidation scans walk a user's measurements by
// time with no `type` predicate; `(userId, type, measuredAt)` can't serve
@@ -3003,7 +3002,6 @@ model Medication {
/// re-mirror of the same concept id returns the existing row (the
/// create route pre-queries the triple and catches the P2002 race).
@@unique([userId, externalSource, externalId])
@@index([userId])
@@index([treatmentClass])
@@map("medications")
}
@@ -3213,6 +3211,8 @@ model MedicationSchedule {
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
telegramMessages TelegramReminderMessage[]
// Leads the foreign key, so a cascade from the parent row is an index lookup.
@@index([medicationId])
@@map("medication_schedules")
}
@@ -3491,6 +3491,8 @@ model MedicationIntakeEvent {
// without this one the purge planned a sequential scan and could not finish
// inside statement_timeout. Raw SQL only (partial predicate not expressible
// in Prisma).
// Leads the foreign key, so a cascade from the parent row is an index lookup.
@@index([medicationId])
@@map("medication_intake_events")
}
@@ -3655,6 +3657,8 @@ model MedicationInventoryItem {
// `(user_id) WHERE notes IS NOT NULL AND notes_encrypted IS NULL` — see
// migration 0243 (v1.28.46 H4) for the med-notes boot-discovery scan. Raw
// SQL only (partial predicate not expressible in Prisma).
// Leads the foreign key, so a cascade from the parent row is an index lookup.
@@index([medicationId])
@@map("medication_inventory_items")
}
@@ -3718,6 +3722,8 @@ model MedicationSideEffect {
// `(user_id) WHERE notes IS NOT NULL AND notes_encrypted IS NULL` — see
// migration 0243 (v1.28.46 H4) for the med-notes boot-discovery scan. Raw
// SQL only (partial predicate not expressible in Prisma).
// Leads the foreign key, so a cascade from the parent row is an index lookup.
@@index([medicationId])
@@map("medication_side_effects")
}
@@ -3809,6 +3815,8 @@ model TelegramReminderMessage {
@@unique([recipientUserId, medicationId, scheduleId, date, phase, timeOfDay], map: "telegram_reminder_messages_recipient_slot_key")
@@index([medicationId, date])
// Leads the foreign key, so a cascade from the parent row is an index lookup.
@@index([scheduleId])
@@map("telegram_reminder_messages")
}
@@ -4004,6 +4012,9 @@ model InviteToken {
redemptions InviteRedemption[]
@@index([expiresAt])
// Leads the foreign key, so a cascade from the parent row is an index lookup.
@@index([createdBy])
@@index([usedBy])
@@map("invite_tokens")
}
@@ -4047,6 +4058,8 @@ model ManagedProfileHandover {
@@index([profileId])
// `managed_profile_handovers_open_idx` UNIQUE (profile_id) WHERE used_at IS
// NULL AND revoked_at IS NULL is a partial index from migration 0383.
// Leads the foreign key, so a cascade from the parent row is an index lookup.
@@index([createdById])
@@map("managed_profile_handovers")
}
@@ -4100,6 +4113,8 @@ model InviteRedemption {
user User? @relation("InviteRedemptions", fields: [userId], references: [id], onDelete: SetNull)
@@index([inviteId])
// Leads the foreign key, so a cascade from the parent row is an index lookup.
@@index([userId])
@@map("invite_redemptions")
}
@@ -4136,7 +4151,6 @@ model RefreshToken {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
/// Composite index — every reuse-detection / device-scoped lookup filters
/// on (userId, deviceId), so a single-column device_id index would never
/// be used while costing writes. Multiple live rows per (userId, deviceId)
@@ -4267,6 +4281,8 @@ model WithingsOAuthState {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([expiresAt])
// Leads the foreign key, so a cascade from the parent row is an index lookup.
@@index([userId])
@@map("withings_oauth_states")
}
@@ -4348,6 +4364,8 @@ model WhoopOAuthState {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([expiresAt])
// Leads the foreign key, so a cascade from the parent row is an index lookup.
@@index([userId])
@@map("whoop_oauth_states")
}
@@ -4380,6 +4398,8 @@ model WhoopConnectTicket {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([expiresAt])
// Leads the foreign key, so a cascade from the parent row is an index lookup.
@@index([userId])
@@map("whoop_connect_tickets")
}
@@ -4434,6 +4454,8 @@ model FitbitOAuthState {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([expiresAt])
// Leads the foreign key, so a cascade from the parent row is an index lookup.
@@index([userId])
@@map("fitbit_oauth_states")
}
@@ -4512,6 +4534,8 @@ model GoogleHealthOAuthState {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([expiresAt])
// Leads the foreign key, so a cascade from the parent row is an index lookup.
@@index([userId])
@@map("google_health_oauth_states")
}
@@ -4642,7 +4666,6 @@ model MoodEntry {
// that carry a source-stable id upsert on this; native/MANUAL entries
// (NULL externalId) are distinct here and stay on the legacy key above.
@@unique([userId, source, externalId])
@@index([userId, date])
// v1.12.1 (P1-3) — mood-insights reads filter `{ userId, deletedAt:
// null, moodLoggedAt: { gte } }` ordered by `moodLoggedAt desc`; this
// index turns that into a range scan. See migration 0123.
@@ -5098,7 +5121,6 @@ model IntegrationStatus {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, integration])
@@index([userId])
@@index([state])
@@map("integration_statuses")
}
@@ -5451,7 +5473,6 @@ model NotificationChannel {
preferences NotificationPreference[]
@@unique([userId, type])
@@index([userId])
@@map("notification_channels")
}
@@ -5479,7 +5500,6 @@ model PushSubscription {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, endpoint])
@@index([userId])
@@map("push_subscriptions")
}
@@ -5526,7 +5546,6 @@ model DataBackup {
chunks DataBackupChunk[]
@@unique([userId, type], map: "data_backups_user_id_type_key")
@@index([userId])
@@map("data_backups")
}
@@ -6369,7 +6388,6 @@ model Biomarker {
mergesInto BiomarkerMerge[]
@@unique([userId, name])
@@index([userId])
@@map("biomarkers")
}
@@ -6412,7 +6430,6 @@ model CustomMetric {
entries CustomMetricEntry[]
@@unique([userId, name])
@@index([userId])
@@map("custom_metrics")
}
@@ -6450,6 +6467,8 @@ model CustomMetricEntry {
// `(user_id) WHERE note IS NOT NULL` — see
// migration 0357 for the free-text encryption boot-discovery scan. Raw SQL
// only (partial predicate not expressible in Prisma).
// Leads the foreign key, so a cascade from the parent row is an index lookup.
@@index([customMetricId])
@@map("custom_metric_entries")
}
@@ -6497,7 +6516,6 @@ model UserAchievement {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, achievementId])
@@index([userId])
@@map("user_achievements")
}
@@ -7189,7 +7207,6 @@ model CoachUsage {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, dateKey])
@@index([userId, dateKey])
@@map("coach_usage")
}
@@ -7440,7 +7457,6 @@ model ClinicianShareLinkDocument {
document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
@@unique([shareLinkId, documentId])
@@index([shareLinkId])
@@index([documentId])
@@map("clinician_share_link_documents")
}
@@ -8031,7 +8047,6 @@ model MenstrualCycle {
dayLogs CycleDayLog[]
@@unique([userId, startDate])
@@index([userId, startDate])
// Keyset support for the `/api/sync/changes` cycle feed
// (`where userId` ordered by `(updatedAt asc, id asc)`).
@@index([userId, updatedAt, id])
@@ -8131,9 +8146,10 @@ model CycleDayLog {
// NULL-distinct external dedup key for HealthKit re-ingest (the
// MoodEntry dual-unique precedent).
@@unique([userId, source, externalId])
@@index([userId, date])
// Keyset support for the `/api/sync/changes` cycle-day feed.
@@index([userId, updatedAt, id])
// Leads the foreign key, so a cascade from the parent row is an index lookup.
@@index([cycleId])
@@map("cycle_day_logs")
}
@@ -8325,7 +8341,6 @@ model IllnessDayLog {
@@unique([episodeId, date])
@@index([userId, date])
@@index([episodeId])
@@map("illness_day_logs")
}
@@ -9332,6 +9347,8 @@ model Encounter {
@@index([userId, deletedAt, occurredAt])
@@index([userId, status, occurredAt])
@@index([practitionerId])
// Leads the foreign key, so a cascade from the parent row is an index lookup.
@@index([reminderId])
@@map("encounters")
}
@@ -9592,6 +9609,10 @@ model VaccinationRecord {
@@index([userId, deletedAt, occurredAt])
@@index([userId, antigenSlug, occurredAt])
@@index([customVaccineId])
// Leads the foreign key, so a cascade from the parent row is an index lookup.
@@index([encounterId])
@@index([practitionerId])
@@index([reminderId])
@@map("vaccination_records")
}