Artifact 814770505367edc0bcfd4163685820d9614db382592250798b75867b4873235f:
- File
db/querier.go
— part of check-in
[218ebaec74]
at
2026-07-26 00:03:00
on branch trunk
— perf: cache prepared statements in db.Querier to stop per-call SQL re-parse (#171)
The hot per-artifact queries in the phantom-fill crosslink cascade — the content/blob-by-rid lookup and the per-node orphan/delta-child lookups in afterDephantomize — re-tokenized, re-parsed, and re-compiled their SQL text on every call because db.Querier delegated straight to the raw driver each time with no statement reuse. On a large repository (67k+ artifacts) the SQLite parser/compiler dominated CPU during the slow clone stretch.
Add a statement cache to both Querier implementations, keyed by exact query text, with lifetimes matched to each backing type:
- *DB caches *sql.Stmt prepared from its long-lived *sql.DB pool (safe for concurrent use, manages its own connections). Statements are prepared lazily on first use, reused thereafter, and closed in DB.Close. - *Tx caches *sql.Stmt scoped to a single *sql.Tx; the cache is torn down (all statements closed) when the transaction commits or rolls back, so no statement outlives its transaction or leaks across a clone/sync session.
Exec/QueryRow/Query transparently get-or-prepare the cached statement — no signature or caller-visible behavior change. QueryRow preserves database/sql's deferred-error contract by falling back to the raw path on prepare failure so the error still surfaces from Scan. Multi-statement strings (DDL setup batches) bypass the cache and run through the raw driver path, since a prepared statement compiles only one statement.
Benchmark (Apple M4 Pro, modernc driver): the blob-by-rid lookup drops from 4779 ns/op to 2289 ns/op (~2.1x). Cache reuse, transaction-boundary cleanup, and multi-statement bypass are covered by new tests in db/stmtcache_test.go. (user: [email protected], size: 3677) [annotate] [blame] [check-ins using]
package db import ( "database/sql" "strings" "sync" ) // Querier is the common interface satisfied by both *DB and *Tx. // Functions that need to work inside transactions accept Querier // instead of *DB. type Querier interface { Exec(query string, args ...any) (sql.Result, error) QueryRow(query string, args ...any) *sql.Row Query(query string, args ...any) (*sql.Rows, error) } // preparer is the subset of *sql.DB and *sql.Tx used to compile statements. // Both expose an identical Prepare, letting a single stmtCache back either a // connection pool or a transaction. type preparer interface { Prepare(query string) (*sql.Stmt, error) } // stmtCache memoizes prepared statements by exact query text so a query // executed repeatedly with the same text is compiled once instead of on every // call. One cache is owned by a single *DB (backed by its *sql.DB pool) or a // single *Tx (backed by its *sql.Tx); it is never shared across transactions. // The zero value is not usable — set src before first use. Safe for concurrent // use by multiple goroutines, matching *sql.DB's own contract. // // The *DB-backed cache has no eviction: every distinct query text a caller // ever runs through it stays cached for the pool's lifetime. This is fine for // this codebase's fixed, bounded set of query shapes, but a caller that builds // query text by interpolating a value (a UUID, a table name) instead of // binding it as a placeholder arg would grow the cache without bound. Always // parameterize with `?` — never inline a value into the query string. type stmtCache struct { src preparer mu sync.Mutex stmts map[string]*sql.Stmt } // get returns the cached statement for query, compiling and caching it on // first use. Preparation happens outside the lock so a slow compile never // blocks lookups of already-cached statements; a lost race simply discards the // redundant handle. func (c *stmtCache) get(query string) (*sql.Stmt, error) { c.mu.Lock() if st, ok := c.stmts[query]; ok { c.mu.Unlock() return st, nil } c.mu.Unlock() st, err := c.src.Prepare(query) if err != nil { return nil, err } c.mu.Lock() defer c.mu.Unlock() if existing, ok := c.stmts[query]; ok { // Another goroutine won the race; keep the first handle and drop ours. _ = st.Close() return existing, nil } if c.stmts == nil { c.stmts = make(map[string]*sql.Stmt) } c.stmts[query] = st return st, nil } // closeAll closes every cached statement and empties the cache. It is safe to // call more than once and after the backing pool or transaction has closed: // *sql.Stmt.Close is idempotent. func (c *stmtCache) closeAll() { c.mu.Lock() defer c.mu.Unlock() for _, st := range c.stmts { _ = st.Close() } c.stmts = nil } // size reports the number of distinct statements currently cached. Used by // tests to assert that repeated identical queries prepare only once. func (c *stmtCache) size() int { c.mu.Lock() defer c.mu.Unlock() return len(c.stmts) } // cacheableStmt reports whether query is a single statement eligible for a // cached prepared statement. A prepared statement compiles exactly one SQL // statement, so multi-statement strings (e.g. DDL setup batches that create a // table and its indexes in one call) must run through the raw driver path that // splits and executes each statement. A semicolon anywhere other than trailing // whitespace signals more than one statement. A false negative — a semicolon // inside a string literal — only forgoes caching for that query and never // changes results. func cacheableStmt(query string) bool { return !strings.Contains(strings.TrimRight(query, " \t\r\n;"), ";") }