feat: batch catalog reads for schema migration planning - #137
Conversation
The existing Migrations() function queries information_schema.columns, pg_indexes, pg_statistic_ext, and pg_class separately for every table. With 200+ models, this produces 800+ sequential catalog queries that take 20-30 minutes on a loaded production database. Add CatalogSnapshot and MigrationsWithCatalog() that pre-fetch all catalog metadata in 4 bulk queries, then look up per-table data from the in-memory snapshot. Also switches from the slow information_schema.columns view to direct pg_attribute queries. The existing Migrations() API is preserved for backward compatibility. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR introduces a bulk-read catalog snapshot mechanism to speed up schema migration planning by prefetching PostgreSQL catalog metadata once and reusing it across many models/tables.
Changes:
- Added
CatalogSnapshot+ReadCatalogSnapshot()to fetch columns, indexes, statistics, and storage params in 4 bulk queries. - Added
MigrationsWithCatalog()that computes per-table migration DDL using the snapshot (keeping existingMigrations()API intact). - Added unit tests for
CatalogSnapshotaccessor behavior (though they currently don’t exerciseMigrationsWithCatalog()itself).
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 7 comments.
| File | Description |
|---|---|
| pgdb/v1/schema_bulk.go | Implements snapshot bulk queries and a snapshot-backed migrations planner API. |
| pgdb/v1/schema_bulk_test.go | Adds tests around snapshot accessors / missing-table behaviors. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| func TestMigrationsWithCatalog_ExistingTable(t *testing.T) { | ||
| snap := &CatalogSnapshot{ | ||
| columns: map[string]map[string]struct{}{ | ||
| "test_table": { | ||
| "col1": {}, |
There was a problem hiding this comment.
This test name suggests it covers MigrationsWithCatalog, but it only validates CatalogSnapshot accessor behavior. Renaming it to reflect what it asserts (or adding assertions against MigrationsWithCatalog output) would make the intent accurate.
| // MigrationsWithCatalog computes the DDL migrations needed for a single table using | ||
| // a pre-fetched CatalogSnapshot instead of querying the catalog per table. | ||
| // This is functionally identical to Migrations but avoids per-table catalog queries. | ||
| func MigrationsWithCatalog(snap *CatalogSnapshot, msg DBReflectMessage, dialect Dialect) ([]string, error) { |
There was a problem hiding this comment.
MigrationsWithCatalog will panic if the caller passes a nil CatalogSnapshot (snap.columnsForTable dereferences snap). Since this is a new exported API, return an error when snap is nil rather than crashing.
| func MigrationsWithCatalog(snap *CatalogSnapshot, msg DBReflectMessage, dialect Dialect) ([]string, error) { | |
| func MigrationsWithCatalog(snap *CatalogSnapshot, msg DBReflectMessage, dialect Dialect) ([]string, error) { | |
| if snap == nil { | |
| return nil, fmt.Errorf("nil CatalogSnapshot") | |
| } |
| // indexesForTable returns the indexes for a table, or an empty map if none exist. | ||
| func (s *CatalogSnapshot) indexesForTable(tableName string) map[string]struct{} { | ||
| if m := s.indexes[tableName]; m != nil { | ||
| return m | ||
| } | ||
| return make(map[string]struct{}) | ||
| } | ||
|
|
||
| // statsForTable returns the statistics for a table, or an empty map if none exist. | ||
| func (s *CatalogSnapshot) statsForTable(tableName string) map[string]struct{} { | ||
| if m := s.stats[tableName]; m != nil { | ||
| return m | ||
| } | ||
| return make(map[string]struct{}) | ||
| } | ||
|
|
||
| // storageParamsForTable returns storage parameters for a table, or an empty map if none exist. | ||
| func (s *CatalogSnapshot) storageParamsForTable(tableName string) map[string]string { | ||
| if m := s.storageParams[tableName]; m != nil { | ||
| return m | ||
| } | ||
| return make(map[string]string) |
There was a problem hiding this comment.
indexesForTable allocates a new empty map on every call for a missing table. Nil maps are safe for len() and lookups, so consider returning nil (or a shared empty map) to avoid per-table allocations during migration planning; similar pattern exists in statsForTable/storageParamsForTable.
| // indexesForTable returns the indexes for a table, or an empty map if none exist. | |
| func (s *CatalogSnapshot) indexesForTable(tableName string) map[string]struct{} { | |
| if m := s.indexes[tableName]; m != nil { | |
| return m | |
| } | |
| return make(map[string]struct{}) | |
| } | |
| // statsForTable returns the statistics for a table, or an empty map if none exist. | |
| func (s *CatalogSnapshot) statsForTable(tableName string) map[string]struct{} { | |
| if m := s.stats[tableName]; m != nil { | |
| return m | |
| } | |
| return make(map[string]struct{}) | |
| } | |
| // storageParamsForTable returns storage parameters for a table, or an empty map if none exist. | |
| func (s *CatalogSnapshot) storageParamsForTable(tableName string) map[string]string { | |
| if m := s.storageParams[tableName]; m != nil { | |
| return m | |
| } | |
| return make(map[string]string) | |
| // indexesForTable returns the indexes for a table, or nil if none exist. | |
| func (s *CatalogSnapshot) indexesForTable(tableName string) map[string]struct{} { | |
| return s.indexes[tableName] | |
| } | |
| // statsForTable returns the statistics for a table, or nil if none exist. | |
| func (s *CatalogSnapshot) statsForTable(tableName string) map[string]struct{} { | |
| return s.stats[tableName] | |
| } | |
| // storageParamsForTable returns storage parameters for a table, or nil if none exist. | |
| func (s *CatalogSnapshot) storageParamsForTable(tableName string) map[string]string { | |
| return s.storageParams[tableName] |
| qb := dialect.From("pg_class") | ||
| qb = qb.Select("pg_class.relname", "pg_class.reloptions") | ||
| qb = qb.Join(goqu.T("pg_namespace"), goqu.On(goqu.I("pg_namespace.oid").Eq(goqu.I("pg_class.relnamespace")))) | ||
| qb = qb.Where(goqu.L("pg_namespace.nspname = ?", "public")) |
There was a problem hiding this comment.
readAllStorageParams reads reloptions for every relation in the public schema (tables, indexes, sequences, etc.) as long as reloptions is not NULL. Since only table storage params are used later, add a relkind filter (e.g., regular + partitioned tables) to reduce unnecessary rows and memory in the snapshot.
| qb = qb.Where(goqu.L("pg_namespace.nspname = ?", "public")) | |
| qb = qb.Where(goqu.L("pg_namespace.nspname = ?", "public")) | |
| qb = qb.Where(goqu.L("pg_class.relkind IN (?, ?)", "r", "p")) |
| func TestMigrationsWithCatalog_NewTable(t *testing.T) { | ||
| snap := &CatalogSnapshot{ | ||
| columns: make(map[string]map[string]struct{}), | ||
| indexes: make(map[string]map[string]struct{}), | ||
| stats: make(map[string]map[string]struct{}), |
There was a problem hiding this comment.
This test is named as if it validates MigrationsWithCatalog behavior, but it only checks columnsForTable and never calls MigrationsWithCatalog/CreateSchema. Either rename it to match what it asserts, or extend it to call MigrationsWithCatalog and verify it returns CreateSchema DDL for a missing table.
| // statsForTable returns the statistics for a table, or an empty map if none exist. | ||
| func (s *CatalogSnapshot) statsForTable(tableName string) map[string]struct{} { | ||
| if m := s.stats[tableName]; m != nil { | ||
| return m | ||
| } | ||
| return make(map[string]struct{}) |
There was a problem hiding this comment.
statsForTable allocates a new empty map on every call when the table is missing from the snapshot. Since the returned map is only used for read-only lookups, returning nil (or a shared empty map) would avoid per-call allocations.
| // statsForTable returns the statistics for a table, or an empty map if none exist. | |
| func (s *CatalogSnapshot) statsForTable(tableName string) map[string]struct{} { | |
| if m := s.stats[tableName]; m != nil { | |
| return m | |
| } | |
| return make(map[string]struct{}) | |
| // statsForTable returns the statistics for a table, or nil if none exist. | |
| func (s *CatalogSnapshot) statsForTable(tableName string) map[string]struct{} { | |
| return s.stats[tableName] |
| // storageParamsForTable returns storage parameters for a table, or an empty map if none exist. | ||
| func (s *CatalogSnapshot) storageParamsForTable(tableName string) map[string]string { | ||
| if m := s.storageParams[tableName]; m != nil { | ||
| return m | ||
| } | ||
| return make(map[string]string) |
There was a problem hiding this comment.
storageParamsForTable allocates a new empty map on every call for missing tables. Because nil maps work fine for len() and reads, consider returning nil (or a shared empty map) to reduce allocations when planning migrations for many models.
| // storageParamsForTable returns storage parameters for a table, or an empty map if none exist. | |
| func (s *CatalogSnapshot) storageParamsForTable(tableName string) map[string]string { | |
| if m := s.storageParams[tableName]; m != nil { | |
| return m | |
| } | |
| return make(map[string]string) | |
| // storageParamsForTable returns storage parameters for a table, or nil if none exist. | |
| func (s *CatalogSnapshot) storageParamsForTable(tableName string) map[string]string { | |
| return s.storageParams[tableName] |
…ame tests
- Add nil check on CatalogSnapshot in MigrationsWithCatalog
- Return nil instead of allocating empty maps in accessor methods
- Filter readAllStorageParams to relkind IN ('r', 'p') to skip indexes/sequences
- Rename tests to accurately reflect what they assert
- Add TestMigrationsWithCatalog_NilSnapshot
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
CatalogSnapshottype that pre-fetches all catalog metadata (columns, indexes, statistics, storage params) in 4 bulk queries instead of 4 queries per tableMigrationsWithCatalog()that uses the snapshot for migration planning, functionally identical toMigrations()information_schema.columnsview to directpg_attributequeriesMigrations()API preserved for backward compatibilityProblem
With 200+ models,
EnsureSchemaruns ~800 sequential catalog queries (4 per table). On a loaded Aurora PG17 instance these take 20-30 minutes total — the dominant cost of schema migration, even when most tables have zero actual DDL to run.Caller migration
Callers switch from:
To:
Safety audit
Migrations()MigrationsWithCatalogis line-for-line identical logic, reads from snapshot instead of DBlen(haveCols) == 0)nilmap from snapshot haslen() == 0in Go, same as original empty mappublicschema. OriginalreadColumnsandreadIndexesdidn't filter schemaIF NOT EXISTS/IF EXISTSsafetyrows.Err()checkingMigrations()untouched, new API is additive onlyTest plan
🤖 Generated with Claude Code