Skip to content

feat: batch catalog reads for schema migration planning - #137

Open
arreyder wants to merge 3 commits into
mainfrom
batch-catalog-reads
Open

feat: batch catalog reads for schema migration planning#137
arreyder wants to merge 3 commits into
mainfrom
batch-catalog-reads

Conversation

@arreyder

@arreyder arreyder commented Apr 6, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds CatalogSnapshot type that pre-fetches all catalog metadata (columns, indexes, statistics, storage params) in 4 bulk queries instead of 4 queries per table
  • Adds MigrationsWithCatalog() that uses the snapshot for migration planning, functionally identical to Migrations()
  • Switches from slow information_schema.columns view to direct pg_attribute queries
  • Existing Migrations() API preserved for backward compatibility

Problem

With 200+ models, EnsureSchema runs ~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:

for _, model := range models {
    lines, _ := pgdb_v1.Migrations(ctx, conn, model, dialect)
    // execute lines...
}

To:

snap, _ := pgdb_v1.ReadCatalogSnapshot(ctx, conn)
for _, model := range models {
    lines, _ := pgdb_v1.MigrationsWithCatalog(snap, model, dialect)
    // execute lines...
}

Safety audit

Concern Status
Logic parity with Migrations() MigrationsWithCatalog is line-for-line identical logic, reads from snapshot instead of DB
New table detection (len(haveCols) == 0) Works — nil map from snapshot has len() == 0 in Go, same as original empty map
Schema filtering Improvement — bulk queries all filter on public schema. Original readColumns and readIndexes didn't filter schema
IF NOT EXISTS / IF EXISTS safety All DDL uses these clauses, so stale snapshot can't cause duplicate creation errors
rows.Err() checking Improvement — bulk queries check it, original doesn't
Backward compatibility Migrations() untouched, new API is additive only

Test plan

  • Existing unit tests pass
  • New unit tests for CatalogSnapshot accessors
  • Integration test with real PG (covered by existing example/models tests in CI)
  • Validate in c1 repo after vendoring update

🤖 Generated with Claude Code

arreyder and others added 2 commits April 6, 2026 16:18
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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 existing Migrations() API intact).
  • Added unit tests for CatalogSnapshot accessor behavior (though they currently don’t exercise MigrationsWithCatalog() 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.

Comment thread pgdb/v1/schema_bulk_test.go Outdated
Comment on lines +22 to +26
func TestMigrationsWithCatalog_ExistingTable(t *testing.T) {
snap := &CatalogSnapshot{
columns: map[string]map[string]struct{}{
"test_table": {
"col1": {},

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread pgdb/v1/schema_bulk.go
// 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) {

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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")
}

Copilot uses AI. Check for mistakes.
Comment thread pgdb/v1/schema_bulk.go Outdated
Comment on lines +191 to +212
// 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)

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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]

Copilot uses AI. Check for mistakes.
Comment thread pgdb/v1/schema_bulk.go
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"))

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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"))

Copilot uses AI. Check for mistakes.
Comment thread pgdb/v1/schema_bulk_test.go Outdated
Comment on lines +7 to +11
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{}),

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread pgdb/v1/schema_bulk.go Outdated
Comment on lines +199 to +204
// 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{})

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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]

Copilot uses AI. Check for mistakes.
Comment thread pgdb/v1/schema_bulk.go Outdated
Comment on lines +207 to +212
// 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)

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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]

Copilot uses AI. Check for mistakes.
@arreyder
arreyder requested a review from pquerna April 7, 2026 13:52
…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>
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.

2 participants