From 7dc0df0534d7fc1fbb598f5d58db443f907bfbdf Mon Sep 17 00:00:00 2001 From: arreyder Date: Sat, 23 May 2026 22:52:29 -0500 Subject: [PATCH 1/3] perf(dotc1z): add prepared statement cache for repeated SQL queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- pkg/dotc1z/c1file.go | 38 +++++++++++++++++++++++++++ pkg/dotc1z/grants.go | 6 ++++- pkg/dotc1z/grants_expandable_query.go | 12 +++++++-- pkg/dotc1z/grants_hydrate.go | 10 ++++--- pkg/dotc1z/sql_helpers.go | 20 +++++++++++--- 5 files changed, 76 insertions(+), 10 deletions(-) diff --git a/pkg/dotc1z/c1file.go b/pkg/dotc1z/c1file.go index 38a1f685b..f2cf6ba14 100644 --- a/pkg/dotc1z/c1file.go +++ b/pkg/dotc1z/c1file.go @@ -67,6 +67,11 @@ type C1File struct { slowQueryThreshold time.Duration slowQueryLogFrequency time.Duration + // Prepared statement cache: keyed by SQL text, lazily populated. + // With MaxOpenConns(1) all stmts are bound to the single connection. + stmtCache map[string]*sql.Stmt + stmtCacheMu sync.Mutex + // Sync cleanup settings syncLimit int skipCleanup bool @@ -184,6 +189,7 @@ func NewC1File(ctx context.Context, dbFilePath string, opts ...C1FOption) (*C1Fi slowQueryThreshold: 5 * time.Second, slowQueryLogFrequency: 1 * time.Minute, encoderConcurrency: 1, + stmtCache: make(map[string]*sql.Stmt), } for _, opt := range opts { @@ -492,12 +498,44 @@ func (c *C1File) closeRawDB(ctx context.Context) error { _, span := tracer.Start(ctx, "C1File.closeRawDB") var err error defer func() { uotel.EndSpanWithError(span, err) }() + + c.stmtCacheMu.Lock() + for _, stmt := range c.stmtCache { + stmt.Close() + } + c.stmtCache = nil + c.stmtCacheMu.Unlock() + err = c.rawDb.Close() c.rawDb = nil c.db = nil return err } +func (c *C1File) getOrPrepare(ctx context.Context, query string) (*sql.Stmt, error) { + c.stmtCacheMu.Lock() + if stmt, ok := c.stmtCache[query]; ok { + c.stmtCacheMu.Unlock() + return stmt, nil + } + c.stmtCacheMu.Unlock() + + stmt, err := c.rawDb.PrepareContext(ctx, query) + if err != nil { + return nil, err + } + + c.stmtCacheMu.Lock() + if existing, ok := c.stmtCache[query]; ok { + c.stmtCacheMu.Unlock() + stmt.Close() + return existing, nil + } + c.stmtCache[query] = stmt + c.stmtCacheMu.Unlock() + return stmt, nil +} + // truncateWAL truncates the WAL file. // Returns the busy, log, and checkpointed values. func (c *C1File) truncateWAL(ctx context.Context) (int, int, int, error) { diff --git a/pkg/dotc1z/grants.go b/pkg/dotc1z/grants.go index 5b0bedcd9..2aef3fba7 100644 --- a/pkg/dotc1z/grants.go +++ b/pkg/dotc1z/grants.go @@ -229,7 +229,11 @@ func listGrantsGeneric(ctx context.Context, c *C1File, req listRequest) ([]*v2.G } queryStart := time.Now() - rows, err := c.db.QueryContext(ctx, query, args...) + stmt, err := c.getOrPrepare(ctx, query) + if err != nil { + return nil, "", err + } + rows, err := stmt.QueryContext(ctx, args...) if err != nil { return nil, "", err } diff --git a/pkg/dotc1z/grants_expandable_query.go b/pkg/dotc1z/grants_expandable_query.go index 049d58a01..f3d8f4c83 100644 --- a/pkg/dotc1z/grants_expandable_query.go +++ b/pkg/dotc1z/grants_expandable_query.go @@ -73,7 +73,11 @@ func (c *C1File) listExpandableGrantsInternal( return nil, "", err } - rows, err := c.db.QueryContext(ctx, query, args...) + stmt, err := c.getOrPrepare(ctx, query) + if err != nil { + return nil, "", err + } + rows, err := stmt.QueryContext(ctx, args...) if err != nil { return nil, "", err } @@ -196,7 +200,11 @@ func (c *C1File) listGrantsWithExpansionInternal(ctx context.Context, opts grant return nil, err } - rows, err := c.db.QueryContext(ctx, query, args...) + stmt, err := c.getOrPrepare(ctx, query) + if err != nil { + return nil, err + } + rows, err := stmt.QueryContext(ctx, args...) if err != nil { return nil, err } diff --git a/pkg/dotc1z/grants_hydrate.go b/pkg/dotc1z/grants_hydrate.go index 1a47a970c..593f5a6d5 100644 --- a/pkg/dotc1z/grants_hydrate.go +++ b/pkg/dotc1z/grants_hydrate.go @@ -64,10 +64,14 @@ func hydrateSingleGrant(ctx context.Context, c *C1File, syncID string, g *v2.Gra return nil } - row := c.db.QueryRowContext(ctx, fmt.Sprintf( + q := fmt.Sprintf( `SELECT entitlement_id, resource_type_id, resource_id, principal_resource_type_id, principal_resource_id - FROM %s WHERE external_id = ? AND sync_id = ?`, grants.Name(), - ), g.GetId(), syncID) + FROM %s WHERE external_id = ? AND sync_id = ?`, grants.Name()) + stmt, err := c.getOrPrepare(ctx, q) + if err != nil { + return err + } + row := stmt.QueryRowContext(ctx, g.GetId(), syncID) var k grantJoinKeys if err := row.Scan( &k.EntitlementID, diff --git a/pkg/dotc1z/sql_helpers.go b/pkg/dotc1z/sql_helpers.go index cb1f613cc..bccb58d9a 100644 --- a/pkg/dotc1z/sql_helpers.go +++ b/pkg/dotc1z/sql_helpers.go @@ -244,8 +244,12 @@ func listConnectorObjects[T proto.Message](ctx context.Context, c *C1File, table // Start timing the query execution queryStartTime := time.Now() - // Execute the query - rows, err := c.db.QueryContext(ctx, query, args...) + // Execute the query via prepared statement cache + stmt, err := c.getOrPrepare(ctx, query) + if err != nil { + return nil, "", err + } + rows, err := stmt.QueryContext(ctx, args...) if err != nil { return nil, "", err } @@ -640,7 +644,11 @@ func (c *C1File) getResourceObject(ctx context.Context, resourceID *v2.ResourceI } data := make([]byte, 0) - row := c.db.QueryRowContext(ctx, query, args...) + stmt, err := c.getOrPrepare(ctx, query) + if err != nil { + return err + } + row := stmt.QueryRowContext(ctx, args...) err = row.Scan(&data) if err != nil { return err @@ -700,7 +708,11 @@ func (c *C1File) getConnectorObject(ctx context.Context, tableName string, id st } var data []byte - row := c.db.QueryRowContext(ctx, query, args...) + stmt, err := c.getOrPrepare(ctx, query) + if err != nil { + return err + } + row := stmt.QueryRowContext(ctx, args...) err = row.Scan(&data) if err != nil { return err From c4bafdab2198270b9c9a9450ee79e4afd3c4b805 Mon Sep 17 00:00:00 2001 From: arreyder Date: Sat, 23 May 2026 22:56:17 -0500 Subject: [PATCH 2/3] review: guard getOrPrepare against nil stmtCache + wrap PrepareContext 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 --- pkg/dotc1z/c1file.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/dotc1z/c1file.go b/pkg/dotc1z/c1file.go index f2cf6ba14..0fdcc350d 100644 --- a/pkg/dotc1z/c1file.go +++ b/pkg/dotc1z/c1file.go @@ -514,6 +514,10 @@ func (c *C1File) closeRawDB(ctx context.Context) error { func (c *C1File) getOrPrepare(ctx context.Context, query string) (*sql.Stmt, error) { c.stmtCacheMu.Lock() + if c.stmtCache == nil { + c.stmtCacheMu.Unlock() + return nil, ErrDbNotOpen + } if stmt, ok := c.stmtCache[query]; ok { c.stmtCacheMu.Unlock() return stmt, nil @@ -522,7 +526,7 @@ func (c *C1File) getOrPrepare(ctx context.Context, query string) (*sql.Stmt, err stmt, err := c.rawDb.PrepareContext(ctx, query) if err != nil { - return nil, err + return nil, fmt.Errorf("getOrPrepare: %w", err) } c.stmtCacheMu.Lock() From 426e885458d59799d8d57d10b824810712c92a71 Mon Sep 17 00:00:00 2001 From: arreyder Date: Sun, 24 May 2026 08:24:21 -0500 Subject: [PATCH 3/3] review: add nil check in second lock section of getOrPrepare 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 --- pkg/dotc1z/c1file.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/dotc1z/c1file.go b/pkg/dotc1z/c1file.go index 0fdcc350d..9d7aba6b3 100644 --- a/pkg/dotc1z/c1file.go +++ b/pkg/dotc1z/c1file.go @@ -530,6 +530,11 @@ func (c *C1File) getOrPrepare(ctx context.Context, query string) (*sql.Stmt, err } 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()