docs(ops): record the Postgres 16 → 18 upgrade, add db:reindex-trgm - #4363
gilgardosh wants to merge 4 commits into
Conversation
Production moved from PostgreSQL 16.14 to 18.6 on 2026-09-02. This lands the tooling and the record; the schema and query changes shipped earlier in #4326, #4327, #4329, #4330 and #4331. Tool: `yarn db:reindex-trgm` rebuilds every GIN trigram index, always CONCURRENTLY. It discovers indexes from pg_index/pg_opclass at runtime rather than a hardcoded list, because the set grew from six to seven when idx_financial_entities_name_trgm was added and any copied list silently skips it. CONCURRENTLY is not optional: only that seventh index was originally built concurrently, so a plain REINDEX would take ACCESS EXCLUSIVE on charges, transactions and documents. Also reports the collation provider, detects invalid indexes and _ccnew leftovers, disables statement_timeout, and re-reads the catalog instead of trusting the absence of errors. 29 unit tests. Docs: - docs/postgresql-v18-migration.md — the PG17/PG18 incompatibility audit. The SQL needed no changes; the stack is Render (app) plus Azure Flexible Server (database), which is why both vendors appear. - docs/operations/postgres-18-upgrade.md — the runbook, with the executed record at the top: 17m54s rehearsal, 5.2s ANALYZE, no collation drift, all security-relevant catalog sections byte-identical before and after. - docs/operations/postgres-18-baselines/ — the capture scripts, the rehearsal command sheet as executed, and the before/after catalog snapshots. Findings worth keeping, none of which are reconstructable from the code: - Under RLS, a non-leakproof qual cannot become an index condition, so a composite index whose non-leading column is an expression is unusable by the application. That is why two of #4331's four indexes are never chosen (issue #4341) while the two plain-column ones work. Reproduced on 18.6; the upgrade does not fix it. A STORED generated column does. - CREATE INDEX does not populate expression-index statistics, so ANALYZE must run before capturing plans. Measured 30x misestimates otherwise, which read exactly like a planner regression. - A PITR-restored server arrives with no firewall rules and max_connections pending restart — the latter fails the upgrade precheck. Both matter because PITR-restore-to-a-new-server is the only rollback after a successful upgrade. - Production's schema has drifted from what the migrations build: 101 tables versus 99, plus three column type/nullability differences. Unread by code, but it means a rebuild from migrations is not equivalent to production. Measured benefit: PG18 B-tree skip scan is live on two scraper dedup queries — the dedup index is now chosen with value_date indexed past an unconstrained trade_date, and Rows Removed by Filter went from 262/261 to zero. Raw EXPLAIN captures are deliberately not committed. They contained real bank account numbers, branch numbers and security identifiers; capture-plans.psql section 3 now reports counts only so a re-run cannot reproduce that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The latest changes of this PR are not available as |
There was a problem hiding this comment.
🟡 Changes recommended
The new CLI has a couple of confirmed safety/correctness issues (arg parsing edge cases, brittle “run directly” detection, and missing remote-target opt-in guard) and the rehearsal command sheet contains a hardcoded personal path / inconsistent “uncommitted” wording.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds an operator-facing Postgres maintenance tool and documents the completed Postgres 16 → 18 production upgrade, including runbook and evidence snapshots, so future upgrades/rehearsals can be repeated safely and verified against known-good baselines.
Changes:
- Added
yarn db:reindex-trgmtool (REINDEX INDEX CONCURRENTLYfor all trigram indexes discovered at runtime) with unit tests. - Added/updated Postgres 18 migration audit + an executed upgrade runbook, plus baseline capture scripts and before/after catalog snapshots.
- Wired the new CLI into root
package.jsonand recorded the change via a changeset.
File summaries
| File | Description |
|---|---|
| scripts/reindex-trigram-indexes.ts | New CLI to discover and concurrently reindex all trigram indexes; prints safety/verification info. |
| scripts/tests/reindex-trigram-indexes.test.ts | Unit tests covering SQL generation, quoting, arg parsing, discovery mapping, and formatting helpers. |
| package.json | Adds db:reindex-trgm script entry to run the new tool via tsx. |
| docs/postgresql-v18-migration.md | Full audit/plan doc updated to reflect the v18 migration work and where the runbook lives. |
| docs/operations/postgres-18-upgrade.md | Operational runbook with an execution record for the 2026-09-02 production upgrade. |
| docs/operations/postgres-18-baselines/rehearsal-command-sheet.md | Step-by-step rehearsal command sheet used to validate the upgrade on a PITR-restored server. |
| docs/operations/postgres-18-baselines/README.md | Explains the baselines directory purpose, redaction rules, and key findings. |
| docs/operations/postgres-18-baselines/pre-upgrade-catalog.psql | PSQL script to snapshot catalog/security invariants and index validity pre/post upgrade. |
| docs/operations/postgres-18-baselines/capture-plans.psql | PSQL script to capture plan baselines (counts-only + skip-scan/date-range queries) under RLS context. |
| docs/operations/postgres-18-baselines/16-catalog-snapshot.txt | Captured production catalog snapshot before upgrade (PG 16.14). |
| docs/operations/postgres-18-baselines/PROD18-catalog-snapshot.txt | Captured production catalog snapshot after upgrade (PG 18.6). |
| .changeset/postgres-18-upgrade-record.md | Changeset recording the new tool and the upgrade documentation landing. |
Review details
- Files reviewed: 12/12 changed files
- Comments generated: 5
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| const getValue = (flag: string): string | undefined => { | ||
| const index = args.indexOf(flag); | ||
| if (index === -1) return undefined; | ||
| return args[index + 1]; | ||
| }; |
| async function main(): Promise<void> { | ||
| const options = parseArgs(process.argv.slice(2)); | ||
|
|
||
| const client = new pg.Client({ |
| if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) { | ||
| main().catch(error => { | ||
| console.error(error); | ||
| process.exit(1); | ||
| }); |
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Production moved from PostgreSQL 16.14 to 18.6 on 2026-09-02. This PR lands the tooling and the record — the schema and query changes shipped earlier in #4326, #4327, #4329, #4330 and #4331.
yarn db:reindex-trgmscripts/reindex-trigram-indexes.tsrebuilds every GIN trigram index, alwaysCONCURRENTLY.Two design points worth reviewing:
pg_index/pg_opclassat runtime rather than from a hardcoded list. The set grew from six to seven whenidx_financial_entities_name_trgmlanded in perf(migrations): add GIN trigram index on financial_entities.name #4330, and any copied list silently skips the new one. Discovery makes staleness impossible.CONCURRENTLYis not optional. Only that seventh index was originally built concurrently; the other six came from a plainCREATE INDEX IF NOT EXISTS. A plainREINDEXwould takeACCESS EXCLUSIVEoncharges,transactionsanddocuments.It also reports the collation provider (which decides whether the reindex is required at all), detects invalid indexes and
_ccnewleftovers from a failed rebuild, disablesstatement_timeoutfor the session, times each index, and re-reads the catalog afterwards rather than trusting the absence of errors. 29 unit tests; validated against Azure 18.6 during the rehearsal.The upgrade, measured
ANALYZEafterwardsdatcollversiondatlocprovider = 'c'), not runAzure's post-upgrade ADMIN-option privilege change altered nothing — RLS coverage stayed 101/60/60, ownership unchanged,
security_invokerstill unset on all views, and theprod_group → accounter_prod_user INHERIT TRUEgrant intact. That grant is whyFORCE ROW LEVEL SECURITYis load-bearing here, so it was the thing most worth verifying.Measured benefit
PG18 B-tree skip scan is live on two scraper dedup queries.
poalim_securities_transactions_dedup_uindexis now chosen withvalue_date(7th column) in theIndex Condwhiletrade_date(6th) has no predicate — the definitional signature of skip scan.Rows Removed by Filterwent from 262/261 to zero. These run per ingested row, so the benefit grows with the tables.Findings kept in the docs
None of these are reconstructable from the code, which is the main argument for committing the docs at all:
STOREDgenerated column does.CREATE INDEXdoes not populate expression-index statistics, soANALYZEmust run before capturing plans. Measured 30× misestimates otherwise, which read exactly like a planner regression. This is now the loudest warning in the command sheet.max_connectionspending restart — the latter fails the upgrade precheck outright. Both matter because PITR-restore-to-a-new-server is the only rollback after a successful upgrade.On sensitive data
Raw
EXPLAINcaptures are deliberately not committed. They contained real bank account numbers, branch numbers and security identifiers. They were deleted after use; every conclusion drawn from them is in the docs.capture-plans.psqlsection 3 previously printed sample rows, which is how that data reached a file — it now reports counts only, so a re-run cannot reproduce the problem. Hostnames are templated to$PROD_HOST/$RESTORED_HOSTand business UUIDs to$OWNER_A/$OWNER_B.What is committed from the run: the two catalog snapshots, whose only numbers are row counts.
Verification
prettierclean, 29 tool tests pass,package.jsondiff is exactly one line (db:reindex-trgm) on top of currentmain— including #4202's changesets upgrade, which an earlier rebase attempt would have silently reverted.🤖 Generated with Claude Code