Skip to content

fix(db): index the two hot unindexed FK referencing columns - #934

Merged
jiashuoz merged 1 commit into
mainfrom
fix/wsd-message-id-index
Aug 24, 2026
Merged

fix(db): index the two hot unindexed FK referencing columns#934
jiashuoz merged 1 commit into
mainfrom
fix/wsd-message-id-index

Conversation

@jiashuoz

Copy link
Copy Markdown
Member

The bug

Postgres auto-indexes the referenced side of a foreign key (the PK) but never the referencing side. Both columns below are ON DELETE SET NULL, which Postgres implements as an internal per-row trigger firing once for every deleted parent row:

UPDATE ONLY child SET fk = NULL WHERE fk = $1

Unindexed, that's a sequential scan of the entire child table — per deleted row, not per statement.

The measurement

Same database, same moment, same predicate shape, same FK action. The only difference is the index:

table rows pages index on FK column plan cost
webhook_subscriber_deliveries 421,319 88,177 none Seq Scan 93,443.49
webhook_events 454,487 98,432 idx_webhook_events_message_created (026) Index Scan 8.44

~11,000× — and webhook_events is the larger table.

Why it mattered

A 1000-message delete batch was ~88M page visits. Every batch aborted on a 60s statement_timeout. Downstream, one missing index produced four symptoms that looked unrelated:

  1. deleteAgent exceeded the 60s proxy budget → HTML 504
  2. that HTML body failed the conformance suite's response-schema gate (spec documents application/json)
  3. the slow deletes pinned both slots of maxConcurrentDestructive, so unrelated deletes 429'd
  4. two of those 504s in one 5m window fired the HTTP 5xx fast burn alert

Latent in production, not staging-specific. 025's own header says message_id is ON DELETE SET NULL so the 30-day retention purge can proceed — the design depends on exactly the path that was unindexed, and the cost scales with table size.

The two migrations

106webhook_subscriber_deliveries.message_id, plain index. 419,593 of 424,276 rows (98.9%) are non-null, so a partial index would be within ~1% of the same size.

107messages.reviewed_by_user_id, partial index. Only 4,333 of 549,579 rows (0.79%) are non-null, making partial ~127× smaller. The FK check still uses it because col = $1 implies col IS NOT NULL — the same implication the planner already exploits for the partial idx_webhook_events_message_created, confirmed by EXPLAIN. Rarer path, but worse per occurrence, and deleteAccount is itself in destructiveOps, so it runs under the same cap and timeout budget — and "the account deletion timed out" is a poor answer on a data-rights path.

Both use CREATE INDEX CONCURRENTLY with e2a:no-transaction, following 059, which added an index to this same table for this same class of problem. Both carry the invalid-index recovery note 059 established.

Deliberately not in scope

The audit found 9 more single-column FKs with no leading index, all on tables that are currently empty or never-analyzed on staging:

contact_engagements.contact_id (CASCADE)      oauth_refresh_tokens.user_id (CASCADE)
oauth_auth_codes.user_id (CASCADE)            oauth_clients.created_by_user_id (SET NULL)
oauth_refresh_tokens.client_id (CASCADE)      oauth_auth_codes.client_id (CASCADE)
user_sessions.user_id (CASCADE)               sending_ramp_reservations.user_id (CASCADE)
oauth_pkce_requests.client_id (CASCADE)

I did not blanket-index these: indexes cost write throughput, and a seq scan of an empty table is free. But several are per-user tables that grow (user_sessions, oauth_refresh_tokens), and I can't size them on prod from staging. Worth a follow-up audit against prod row counts, plus a schema test with an explicit allowlist so a new unindexed FK becomes a deliberate decision rather than an accident.

Tests

go test ./internal/identity/ -run Migrat -short green; go build ./... clean. The migration runner's e2a:no-transaction single-statement requirement is satisfied by both files.

🤖 Generated with Claude Code

Postgres auto-indexes the REFERENCED side of a foreign key but never the
REFERENCING side. Both of these are ON DELETE SET NULL, which Postgres
implements as an internal per-row trigger that fires once for EVERY deleted
parent row and runs `UPDATE ONLY child SET fk = NULL WHERE fk = $1`. Unindexed,
that is a sequential scan of the whole child table — per deleted row, not per
statement.

Measured on staging against the structurally identical sibling webhook_events,
which HAS such an index (idx_webhook_events_message_created, 026):

  webhook_subscriber_deliveries  Seq Scan     cost 93443.49  421,319 rows
  webhook_events                 Index Scan   cost     8.44  454,487 rows

~11,000x, and webhook_events is the larger table.

The consequences were not subtle, and none of them looked like a missing index:
a 1000-message delete batch was ~88M page visits and aborted on a 60s
statement_timeout; a plain agent delete blew the 60s proxy budget and returned
HAProxy's HTML 504, which failed the conformance suite's response-schema gate
because the spec documents application/json; and the same slow deletes pinned
both slots of the per-account concurrent-destructive cap, so unrelated deletes
429'd. Four unrelated-looking symptoms, one cause.

This is latent in production, not staging-specific: 025's own header says
message_id is ON DELETE SET NULL so the 30-day retention purge can proceed —
i.e. the design depends on exactly the path that was unindexed, and the cost
grows with table size.

106 (webhook_subscriber_deliveries.message_id) is a plain index: 98.9% of rows
carry a message_id, so a partial one would be within ~1% of the same size.

107 (messages.reviewed_by_user_id) is partial: only 0.79% of rows are non-null,
making the partial index ~127x smaller. The FK check can still use it, because
`col = $1` implies `col IS NOT NULL` — the same implication the planner already
exploits for the partial idx_webhook_events_message_created, confirmed by
EXPLAIN. Its path is rarer but worse per occurrence, and deleteAccount is itself
in destructiveOps, so it runs under the same cap and timeout budget.

Both use CREATE INDEX CONCURRENTLY with e2a:no-transaction, following 059 —
which added an index to this same table for the same class of problem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jiashuoz
jiashuoz merged commit 65d5603 into main Aug 24, 2026
24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant