Skip to content

perf(dotc1z): add prepared statement cache for repeated SQL queries#864

Open
arreyder wants to merge 3 commits into
mainfrom
arreyder/perf-prepared-stmt-cache
Open

perf(dotc1z): add prepared statement cache for repeated SQL queries#864
arreyder wants to merge 3 commits into
mainfrom
arreyder/perf-prepared-stmt-cache

Conversation

@arreyder
Copy link
Copy Markdown
Contributor

@arreyder arreyder commented May 24, 2026

Summary

  • Add a map[string]*sql.Stmt cache on *C1File that eliminates repeated SQLite statement compilation for read queries
  • Profile evidence: modernc.org/sqlite.(*conn).bind consumes 163s/hr (5.6% CPU) recompiling the same ~10 SQL shapes thousands of times per sync
  • With MaxOpenConns(1), all statements are bound to a single connection for the lifetime of the sync activity — ideal for caching

How it works

goqu already uses .Prepared(true) on all read paths, generating stable ?-placeholder SQL. The SQL text is deterministic for each query shape (determined by which WHERE clauses are active). getOrPrepare(ctx, query) checks the cache; on miss, prepares the statement and stores it. On hit, returns the cached *sql.Stmt immediately.

Call sites converted (7)

File Function Hot path
sql_helpers.go listConnectorObjects Main sync list loop
sql_helpers.go getConnectorObject GetEntitlement, GetResource
sql_helpers.go getResourceObject GetResource variant
grants.go listGrantsGeneric ListGrantsForEntitlement
grants_expandable_query.go listExpandableGrantsInternal Expansion work queue
grants_expandable_query.go listGrantsWithExpansionInternal Expansion data read
grants_hydrate.go hydrateSingleGrant Slim-grant hydration

What's NOT changed

  • Write paths (executeChunkedInsert) — already use transactions, INSERT shapes vary with batch size
  • The goqu query builder still runs (SQL text still built each call to use as cache key) — eliminating goqu is a future follow-up

Benchmark

                              main (baseline)      stmt-cache           delta
BenchmarkExpandSmall            89.0s                73.3s              -17.7%
BenchmarkExpandSmallMedium     113.9s                94.4s              -17.1%

17-18% wall-clock improvement. Allocations flat (goqu still builds the SQL string — savings are pure CPU from eliminating bytecode recompilation).

Test plan

  • go test -short ./pkg/dotc1z/... — all pass
  • go test ./pkg/sync/... — all pass
  • go vet ./pkg/dotc1z/... — clean

🤖 Generated with Claude Code

Profile of be-temporal-sync shows modernc.org/sqlite.(*conn).bind
consuming 163s/hr (5.6% CPU) recompiling the same SQL shapes
thousands of times per sync. With MaxOpenConns(1), all queries
execute on a single connection — ideal for a statement cache.

Add a map[string]*sql.Stmt on C1File that lazily caches prepared
statements keyed by their SQL text. Since goqu already uses
.Prepared(true) on read paths (generating stable ?-placeholder SQL),
the SQL text is deterministic for each query shape and serves as a
natural cache key.

Seven read-path call sites converted from db.QueryContext/
db.QueryRowContext to stmt.QueryContext/stmt.QueryRowContext:
- listConnectorObjects (sql_helpers.go) — sync's main list loop
- getConnectorObject (sql_helpers.go) — GetEntitlement, GetResource
- getResourceObject (sql_helpers.go) — GetResource variant
- listGrantsGeneric (grants.go) — ListGrantsForEntitlement
- listExpandableGrantsInternal (grants_expandable_query.go)
- listGrantsWithExpansionInternal (grants_expandable_query.go)
- hydrateSingleGrant (grants_hydrate.go)

Write paths (executeChunkedInsert) left unchanged — they already
use transactions and the INSERT shapes vary with batch size.

Statements are closed in closeRawDB before the database handle is
released. Double-check locking in getOrPrepare handles concurrent
callers safely (though MaxOpenConns(1) makes true concurrency
unlikely in practice).

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
@arreyder arreyder requested a review from a team May 24, 2026 03:52
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 24, 2026

General PR Review: perf(dotc1z): add prepared statement cache for repeated SQL queries

Blocking Issues: 0 | Suggestions: 0 | Threads Resolved: 0
Review mode: incremental since c4bafdab
View review run

Review Summary

The new commit adds the nil-guard for stmtCache in the second lock section of getOrPrepare, exactly addressing the previous suggestion. If closeRawDB races between the first unlock and the re-lock, the function now safely closes the freshly prepared statement and returns ErrDbNotOpen instead of panicking on a nil-map write. No new issues found.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

None.

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

No blocking issues found.

…t error

Addresses code review findings:

1. If closeRawDB runs (nils stmtCache) and getOrPrepare is called
   after (stale reference, context-cancelled goroutine), the nil map
   write would panic. Guard with nil check returning ErrDbNotOpen.

2. Wrap PrepareContext error with function name for debuggability
   in stack-trace-free logging.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Comment thread pkg/dotc1z/c1file.go
Comment on lines +534 to +539
c.stmtCacheMu.Unlock()
stmt.Close()
return existing, nil
}
c.stmtCache[query] = stmt
c.stmtCacheMu.Unlock()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Suggestion: The new nil guard at the top of getOrPrepare prevents a nil-map read on entry, but this second lock section can still write to a nil map if closeRawDB sets stmtCache = nil between the first unlock and this re-lock (the window exists because closeRawDB nils the cache before closing rawDb). Adding a nil check here would make the function fully safe against that shutdown race.

Suggested change
c.stmtCacheMu.Unlock()
stmt.Close()
return existing, nil
}
c.stmtCache[query] = stmt
c.stmtCacheMu.Unlock()
c.stmtCacheMu.Lock()
if c.stmtCache == nil {
c.stmtCacheMu.Unlock()
stmt.Close()
return nil, ErrDbNotOpen
}
if existing, ok := c.stmtCache[query]; ok {
c.stmtCacheMu.Unlock()
stmt.Close()
return existing, nil
}
c.stmtCache[query] = stmt
c.stmtCacheMu.Unlock()

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

No blocking issues found.

@arreyder
Copy link
Copy Markdown
Contributor Author

Benchmark results

Same machine, same fixtures as PR #863 benchmarks. This branch has ONLY the stmt cache (no prefetch from #863) — measuring the cache in isolation.

goos: linux
goarch: amd64
cpu: AMD Ryzen 7 5700X3D 8-Core Processor

                              main (baseline)      stmt-cache           delta
─────────────────────────────────────────────────────────────────────────────────
BenchmarkExpandSmall            89.0s                73.3s              -17.7%
BenchmarkExpandSmallMedium     113.9s                94.4s              -17.1%

                              main                 stmt-cache           delta
─────────────────────────────────────────────────────────────────────────────────
allocs/op (Small)              299M                 300M                ~flat
allocs/op (SmallMedium)        384M                 385M                ~flat
B/op (Small)                  14.8 GB              14.8 GB              ~flat
B/op (SmallMedium)            19.0 GB              19.0 GB              ~flat

17-18% wall-clock improvement, allocations flat.

The savings are pure CPU: eliminating repeated SQLite bytecode compilation for the same ~10-20 query shapes that execute thousands of times per sync. Allocations stay flat because goqu still builds the SQL string each call (used as the cache key) — cutting that is Phase 3 (raw SQL templates), a future PR.

This stacks with PR #863 (prefetch): #863 reduces the NUMBER of queries by 10,000x; #864 makes each remaining query faster by ~18%. Together they compound.

Comment thread pkg/dotc1z/c1file.go
stmt.Close()
return existing, nil
}
c.stmtCache[query] = stmt
Copy link
Copy Markdown
Contributor

@alan-lee-12 alan-lee-12 May 24, 2026

Choose a reason for hiding this comment

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

[🤖] P1: getOrPrepare can still panic on the cache-miss path if closeRawDB runs between preparing the new statement and re-acquiring stmtCacheMu. closeRawDB sets c.stmtCache = nil, so this write can become a write to a nil map. Please mirror the entry nil check after the second lock, close the newly prepared statement, and return ErrDbNotOpen before checking/storing the cache entry.

If closeRawDB runs between the first unlock (cache-miss path) and
the second lock (store path), stmtCache is nil and the map write
panics. Add the same nil guard as the first lock section.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

No blocking issues found.

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