Repository navigation
perf(schema): remove N+1 queries and nested open cursors from the Postgres/MySQL/SQLite schema loaders (#565) - #570
Conversation
…#565) Load listed tables and then called LoadTable per table inside the open table-list cursor, issuing three more queries each: 3N+1 round trips against information_schema, with two connections held at once. SQLite's loadIndexes also nested PRAGMA index_info inside the index_list cursor. With db.SetMaxOpenConns(1) Load deadlocked, and with a pooled SQLite ":memory:" database the nested queries opened a second, empty database, so every table came back with no columns, indexes or foreign keys. All three loaders (postgres, mysql, sqlite) now run one schema-wide query per kind (table list, columns, indexes, foreign keys) and group rows by table in Go. Every cursor is drained and closed before the next statement runs, so one connection is enough. LoadTable reuses the same three query functions with a table filter, so Load and LoadTable share SQL and return identical tables. Load: 3N+1 -> 4 statements; LoadTable: 3+I -> 3. SQLite now uses the pragma_table_info / pragma_index_list / pragma_index_info / pragma_foreign_key_list table-valued functions with the table name as a bound parameter instead of %q-formatted PRAGMA text. Names with embedded double quotes now load correctly. Postgres pk/uq subqueries join on (table_name, column_name), so IsPrimary/IsUnique stay per table. MySQL LoadTable tolerates case-insensitive table-name matching (lower_case_table_names). Output matches the old code for existing fixtures (verified by golden diffs on SQLite and on a local PostgreSQL 17). The one difference: tables with no foreign keys (and MySQL tables with no indexes) now get nil slices instead of empty non-nil ones. Exported signatures are unchanged. benchstat, BenchmarkSQLiteLoader_Load (file DB, count=6): tables=10: 471.8us -> 282.4us (-40%), 75.7KiB -> 30.1KiB (-60%), 1805 -> 1106 allocs/op (-39%) tables=100: 3.59ms -> 1.92ms (-47%), 679KiB -> 250KiB (-63%), 16749 -> 9947 allocs/op (-41%) New regression tests cover in-memory full detail, MaxOpenConns=1 for Load and LoadTable, Load/LoadTable consistency, multi-column indexes, composite FKs, quoted table names and missing tables. The Postgres/MySQL equivalents run in CI under testcontainers. Fixes #565 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UZSk1292jPc8ipDboNxeve
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Claude finished @ajitpratap0's task in 33s —— View job Review of PR #570: schema loader N+1 removalI read the three loaders in full. I did not run the tests, because the checkout has no merge base with Overall: this is a clean change. Each query is now a single statement per kind, every cursor is drained and closed before the next query, and the group-by-table step in Go is correct. The exported API is unchanged. What looks good
Issues and suggestions
PerformanceThe benchstat numbers look credible: about 40% fewer allocs and 63% less memory at 100 tables. The Postgres and MySQL queries are also bounded at 4 statements. The per-table SecurityNo new concerns. The SQLite Overall this is a good change to merge. Item 1 is worth fixing before or soon after, and items 2–4 can be follow-ups. |
…t empty table name SQLite Load failed on expression indexes (NULL index column name) and on REFERENCES without a column list (NULL "to"). Scan both as NullString: skip expression columns and resolve an omitted FK target to the parent's primary key columns. LoadTable now returns an error for an empty table name in the Postgres, MySQL and SQLite loaders. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Claude finished @ajitpratap0's task in 24s —— View job Review of PR #570 (schema loader N+1 removal), updated for 09b7016I read the diff of the latest commit, Earlier feedback that 09b7016 fixes
Remaining itemsNone of these block the merge. All of them predate the PR, but batching exposes every table to them at once.
Performance and security
VerdictGood to merge. The earlier blocking suggestion, the SQLite NULL scans, is fixed. Items 1 and 2 are good candidates for a follow-up issue. |
|
Claude finished @ajitpratap0's task in 14s —— View job Review of PR #570: schema loader N+1 removal (HEAD
|
…resh performance baselines (#566) CHANGELOG [Unreleased] now covers every PR merged since v1.14.0, with a Behavior changes section for the observable changes in #597, #595, #584, #585, #588, #581, #582/#570, #517/#518 and #511, plus Added, Changed, Fixed, Performance, Security and Internal entries. The two existing entries (#526 and the tokenizer-pool dialect fix) are kept verbatim. performance_baselines.json is re-measured on an Apple M4 with BenchmarkPerformanceBaseline -count=10 -benchmem (median). Baselines are set at about 2.5x the M4 median rather than 1.5x: the CI Benchmark job on ubuntu-latest measures these queries about 1.5-3.2x slower than M4 (last 9 Tests runs on main, 0715bb9..61ca6c8), so 1.5x with the unchanged tolerances would fail CI's slowest runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UZSk1292jPc8ipDboNxeve
…xes; refresh performance baselines (#566) (#598) * docs: consolidate CHANGELOG for the perf audit and batch-2 fixes; refresh performance baselines (#566) CHANGELOG [Unreleased] now covers every PR merged since v1.14.0, with a Behavior changes section for the observable changes in #597, #595, #584, #585, #588, #581, #582/#570, #517/#518 and #511, plus Added, Changed, Fixed, Performance, Security and Internal entries. The two existing entries (#526 and the tokenizer-pool dialect fix) are kept verbatim. performance_baselines.json is re-measured on an Apple M4 with BenchmarkPerformanceBaseline -count=10 -benchmem (median). Baselines are set at about 2.5x the M4 median rather than 1.5x: the CI Benchmark job on ubuntu-latest measures these queries about 1.5-3.2x slower than M4 (last 9 Tests runs on main, 0715bb9..61ca6c8), so 1.5x with the unchanged tolerances would fail CI's slowest runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UZSk1292jPc8ipDboNxeve * docs(changelog): add batch-3 follow-up fixes (#599-#620) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UZSk1292jPc8ipDboNxeve * docs(changelog): record #632 render-injection hardening and #624 quote guards Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UZSk1292jPc8ipDboNxeve * docs(changelog): correct batch-3 entries after review; record measured costs - #628: SQLite still rejects count(*) [cnt]; only SQL Server keeps it. - #631: bare FORMAT / FORMAT <name> rules apply after SETTINGS. - Performance: record the measured costs of the batch-3 correctness fixes (Identifier and TableReference growth, quoting in SQL(), lineStarts reallocation, sorted Extract results). - #632: note the lıke, WITH CHECK OPTION and MINUS rendering changes. - Fix the [Unreleased] compare link (v1.14.0...HEAD) and add [1.14.0]. - Keep each section a single tight list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UZSk1292jPc8ipDboNxeve * docs(changelog): describe the release as source-compatible with runtime behaviour changes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UZSk1292jPc8ipDboNxeve --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Summary
#565 perf(schema): remove N+1 queries and nested open cursors from the Postgres/MySQL/SQLite schema loaders
The Postgres, MySQL and SQLite schema loaders now issue four statements per Load (table list, columns, indexes, foreign keys for the whole schema, grouped by table in Go) instead of 3N+1, and LoadTable issues three; every cursor is drained and closed before the next query runs, so a MaxOpenConns=1 pool no longer deadlocks and a pooled SQLite ":memory:" database no longer returns tables with empty columns/indexes/FKs (both reproduced as failing tests against the old code). SQLite table names reach the engine as bound parameters via the pragma_* table-valued functions instead of %q-formatted PRAGMA text, and Postgres PK/UNIQUE flags are now joined on table_name AND column_name so they cannot leak between tables sharing a column name. Measured on BenchmarkSQLiteLoader_Load (count=6, benchstat): tables=100 drops from 16.7K to 9.9K allocs/op (-40.6%), 679 KiB to 250 KiB B/op (-63.3%) and 3.59 ms to 1.92 ms (-46.5%); tables=10 improves similarly. Exported API and go.mod are unchanged; the only observable output difference is that tables without foreign keys (and MySQL tables without indexes) now carry a nil slice rather than an empty one, which serializes as JSON null instead of []. Non-blocking review notes: the MySQL forTable helper falls back to any single map entry (safe only because the query is filtered to one table; LoadTable with an empty name would return an arbitrary table), and the pre-existing PG constraint_name-based pk/uq join is unchanged in scope.
Commits (1), one per issue:
6 files changed, 1047 insertions(+), 242 deletions(-)Test Plan
CGO_ENABLED=1 go test -race -timeout 120s $(go list ./... | grep -v /cbinding)),go vet, andgolangci-linton this branch.benchstat -count=6before/after on the same machine; allocs/op and B/op are the deterministic signal, ns/op is directional on a loaded machine.#565 benchstat
#565 race gate: cd /Users/ajitpratapsingh/dev/GoSQLX-worktrees/perf-schema-loader-n-plus-1 && CGO_ENABLED=1 go test -race -timeout 120s $(go list ./... | grep -v /cbinding) -> EXIT=0, 44 packages ok, 0 FAIL lines, 0 DATA RACE warnings (log: scratchpad/fix/565/validator-race.txt). Targeted -race -v -count=1 run of pkg/schema/{sqlite,postgres,mysql} + pkg/gosqlx: all 12 SQLite tests PASS (4 pre-existing + 8 new); TestPostgresLoader_Load_MaxOpenConns1, TestPostgresLoader_LoadMatchesLoadTable, TestMySQLLoader_Load_MaxOpenConns1, TestMySQLLoader_LoadMatchesLoadTable compile and SKIP with "Docker not available".
Breaking Changes
Notes
main.Fixes #565
Part of #566
🤖 Generated with Claude Code
https://claude.ai/code/session_01UZSk1292jPc8ipDboNxeve