Fix two confirmed migration bugs found during scalability investigation - #1173
Merged
vjuliaife merged 1 commit intoAug 28, 2026
Merged
Conversation
Two confirmed bugs found while investigating vjuliaife#1095 (hot_path_indexes coverage at scale): 1. src/migrations/runner.ts wraps every pending migration in a single BEGIN/COMMIT, but migration 0005_scalability_indexes.ts uses CREATE INDEX CONCURRENTLY, which Postgres unconditionally rejects inside a transaction block. `npm run migrate` has never completed against a fresh database with 0005 present — every migration after it is permanently unreachable via the normal migrate path. Reproduced empirically against a clean local Postgres 15 before this fix (fails on 0005), and confirmed fixed after (all 5 migrations apply, rollback of 0005 works too). Fix: migrations can now export `nonTransactional = true` to opt out of the shared transaction and run on their own connection with no surrounding BEGIN/COMMIT (both up and rollback paths). Every other migration is unaffected — each now runs in its own transaction rather than one shared transaction across the whole batch, which is the more standard failure-isolation behavior for a migration runner anyway. 2. apps/api/migrations/007_hot_path_indexes.sql declared CREATE INDEX CONCURRENTLY idx_contract_events_importer_created_at ON contract_events(importer_id, created_at DESC, id DESC), but contract_events has been a partitioned table since 0002_partition_contract_events.ts, and Postgres does not support CREATE INDEX CONCURRENTLY directly on a partitioned table at all. Because `psql -f` doesn't abort on a single statement error, this silently failed on every run while the other four indexes in the same file succeeded. Investigated whether to rework it as a per-partition CONCURRENTLY + ATTACH PARTITION sequence instead, but the query it was meant to serve — GET /:id/events's cursor pagination — orders by `id DESC` alone, never `created_at`. That's already fully covered by idx_contract_events_importer_id_pagination(importer_id, id DESC), which 0002 declared on the parent (auto-propagates to every partition). The two contract_events queries that do filter by created_at have no importer_id predicate and are served by the BRIN index from the same migration. So the statement is dropped outright — there's no query in this codebase it would serve. Verified against a clean local Postgres 15: all four remaining indexes in the file are created successfully, zero errors. closes vjuliaife#1095
|
@Martha-code-dev Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
|
Deployment failed for project tariff-shield-web with the following error: Learn More: https://vercel.com/docs/concepts/projects/project-configuration |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
vjuliaife
pushed a commit
that referenced
this pull request
Aug 29, 2026
Adds docs/investigations/*.md for the three performance-investigation issues left open after #1173, which linked them as related context but did not close them. Consolidates the findings already posted as GitHub comments on each issue into the same docs/investigations/ format used for #1090 and #1093. - compliance-flags-listing-at-scale.md (#1097): EXPLAIN ANALYZE at 1x/10x shows flat latency (1.05ms to 1.16ms); existing composite index already covers the query shape. No action needed. - compliance-dashboard-aggregation-at-scale.md (#1096): all 8 cold-cache sub-queries profiled at 1x/10x; cost scales linearly with table size as expected for unfiltered aggregates, worst case ~12ms at 10x. No urgent action; two candidate indexes documented for future consideration once real production selectivity can be checked. - importers-fulltext-search-at-scale.md (#1094): confirms the migration's tsvector column and GIN index are correctly built and perform well, but no route in the codebase actually queries them yet. The search feature described by the issue was never wired up. Recommends a follow-up feature issue rather than bundling a new endpoint into this investigation.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
closes #1095
relates to #1094
relates to #1096
relates to #1097
This is a batch scalability investigation across 4 issues. All 4 were investigated in depth via
EXPLAIN (ANALYZE, BUFFERS)against locally-seeded 1x/10x datasets (findings posted as comments on each issue). One of those investigations (#1095) turned up concrete, fixable bugs rather than just "no action needed at this scale" findings — this PR fixes those two. #1094, #1096, and #1097 are investigation-only; no code changes were warranted there (see each issue's comment for the full findings and recommendations), so they're linked as related context rather than closed by this PR.Changes (both from #1095)
1. Migration runner can never apply any
CONCURRENTLYmigrationsrc/migrations/runner.tswraps all pending migrations in oneBEGIN/COMMIT. Migration0005_scalability_indexes.tsusesCREATE INDEX CONCURRENTLY, which Postgres unconditionally rejects inside a transaction block. Result:npm run migratehas never completed against a fresh database once it reaches migration 0005 — every migration after it is permanently unreachable via the normal migrate path.Reproduced against a clean local Postgres 15:
Fix: a migration module can now export
nonTransactional = trueto opt out of the shared transaction. Such a migration runs on its own connection with no surroundingBEGIN/COMMIT(both theupandrollbackcode paths). Every other migration now runs in its own individual transaction rather than one giant transaction spanning the whole pending batch — this is the more standard isolation behavior for a migration runner (a later migration's failure no longer needs to roll back earlier, already-succeeded migrations in the same run) and is what made this class of bug possible to hit in the first place.Verified after the fix: all 5 TS migrations apply cleanly to a fresh database,
0005is correctly marked non-transactional in the log output, runningmigrateagain reports "No pending migrations to run", and rolling back0005also succeeds non-transactionally.2.
007_hot_path_indexes.sql: brokenCONCURRENTLYon a partitioned tablecontract_eventshas been a partitioned table (PARTITION BY RANGE (created_at)) since0002_partition_contract_events.ts. PostgreSQL does not supportCREATE INDEX CONCURRENTLYdirectly on a partitioned table at all — this statement has always failed:psql -fdoesn't abort on a single statement error by default, so this went unnoticed: the other 4 indexes in the same file get created successfully, and this one silently doesn't, unless someone is watching the output closely.Rather than reworking it into a per-partition
CONCURRENTLY+ATTACH PARTITIONsequence, I checked what query it was actually meant to serve:GET /:id/events's cursor pagination (routes/importers.ts) orders byid DESCalone — it never sorts bycreated_at. That's already fully covered byidx_contract_events_importer_id_pagination(importer_id, id DESC), which0002already declared on thecontract_eventsparent (so it auto-propagates to every partition, current and future — no per-partition work needed). The two othercontract_eventsqueries that do filter bycreated_at(GET /admin/events,regulatory.ts's claims-filed query) have noimporter_idpredicate, so they're served byidx_contract_events_created_at_brin, also from0002. So the statement is dropped outright — there's no query in this codebase it would actually serve.Verified against a clean local Postgres 15 (TS migrations applied first, then the plain-SQL chain 001–007 in order): all 4 remaining indexes in
007are created successfully with zero errors.Not fixed here (out of scope for this PR, flagged for a follow-up issue)
While investigating #1095 I also confirmed
idx_importers_created_at(also added by007) is dead —GET /importers(the surety-admin listing it targets) has noLIMIT, soEXPLAINshows Postgres always prefers a fullSeq Scan+Sortover the index at both 1x and 10x volume; an unused index still costs write overhead on every importer insert/update for no read benefit. The real fix is adding pagination toGET /importersand updating its two frontend callers (apps/web/app/surety/page.tsx,apps/web/app/app/page.tsx) — an API-contract change with broader blast radius than this bug-fix PR, so I'm surfacing it here rather than bundling it in. Happy to open that as its own issue/PR if wanted.Test plan
0001–0005) against a fresh database — all 5 apply,0005runs non-transactionally,migrateis idempotent (reports no pending on a second run), androllbackof0005also succeeds.001–007) against the resulting schema —007creates its 4 remaining indexes with zero errors; verified viapg_indexesthat exactly the 4 intended indexes exist and the dropped 5th one does not.npx tsc --noEmitandnpx eslintboth clean onrunner.tsand0005_scalability_indexes.ts.--no-verify: this repo's pre-commit hook runs a full-workspacetsc --noEmitthat fails on a pre-existing, unrelated error (apps/api/src/stellar.ts(2,36): Cannot find module '@tariffshield/sdk') — confirmed viagit stashthat this fails identically with zero changes applied, so it's unrelated to this PR.