From 7716c20f00f7b86a6334fce7c937d4733fd5d62d Mon Sep 17 00:00:00 2001 From: Yasuhiro Matsumoto Date: Sat, 11 Apr 2026 19:56:03 +0900 Subject: [PATCH] evict LRU stmt when stmt cache is full --- sqlite3.go | 82 +++++++++++------ sqlite3_stmt_cache_bench_test.go | 88 ++++++++++++++++++ sqlite3_stmt_cache_test.go | 152 +++++++++++++++++++++++++++++++ 3 files changed, 293 insertions(+), 29 deletions(-) create mode 100644 sqlite3_stmt_cache_bench_test.go create mode 100644 sqlite3_stmt_cache_test.go diff --git a/sqlite3.go b/sqlite3.go index 1a5433c..df2c6a1 100644 --- a/sqlite3.go +++ b/sqlite3.go @@ -451,7 +451,13 @@ type SQLiteConn struct { txlock string funcs []*functionInfo aggregators []*aggInfo - stmtCache map[string][]*SQLiteStmt + // Prepared-statement cache. stmtCacheBuf is a preallocated slice of + // length stmtCacheSize holding up to stmtCacheCount live entries at + // indices [0, stmtCacheCount). Ordering is LRU-first: index 0 is the + // oldest (next to be evicted), index stmtCacheCount-1 is the most + // recently put. put at the tail is O(1) when not full; eviction shifts + // the remaining entries left by one. + stmtCacheBuf []*SQLiteStmt stmtCacheSize int stmtCacheCount int } @@ -1613,7 +1619,7 @@ func (d *SQLiteDriver) Open(dsn string) (driver.Conn, error) { // Create connection to SQLite conn := &SQLiteConn{db: db, loc: loc, txlock: txlock, stmtCacheSize: stmtCacheSize} if stmtCacheSize > 0 { - conn.stmtCache = make(map[string][]*SQLiteStmt) + conn.stmtCacheBuf = make([]*SQLiteStmt, stmtCacheSize) } // Password Cipher has to be registered before authentication @@ -1917,55 +1923,73 @@ func (c *SQLiteConn) takeCachedStmt(query string) *SQLiteStmt { if c.db == nil { return nil } - stmts := c.stmtCache[query] - if len(stmts) == 0 { - return nil + // Scan from the MRU end (tail) so that a stmt put just before is + // found immediately. + for i := c.stmtCacheCount - 1; i >= 0; i-- { + s := c.stmtCacheBuf[i] + if s.cacheKey != query { + continue + } + // Remove s from the buffer by shifting subsequent entries left. + if i != c.stmtCacheCount-1 { + copy(c.stmtCacheBuf[i:c.stmtCacheCount-1], c.stmtCacheBuf[i+1:c.stmtCacheCount]) + } + c.stmtCacheCount-- + c.stmtCacheBuf[c.stmtCacheCount] = nil + s.closed = false + s.cls = false + s.t = "" + return s } - s := stmts[len(stmts)-1] - if len(stmts) == 1 { - delete(c.stmtCache, query) - } else { - c.stmtCache[query] = stmts[:len(stmts)-1] - } - c.stmtCacheCount-- - s.closed = false - s.cls = false - s.t = "" - return s + return nil } func (c *SQLiteConn) putCachedStmt(s *SQLiteStmt) bool { - if c == nil || s == nil || s.s == nil || s.cacheKey == "" { + if c == nil || s == nil || s.s == nil || s.cacheKey == "" || c.stmtCacheSize <= 0 { return false } c.mu.Lock() defer c.mu.Unlock() - if c.db == nil || c.stmtCacheCount >= c.stmtCacheSize { + if c.db == nil { return false } rv := C._sqlite3_reset_clear(s.s) if rv != C.SQLITE_ROW && rv != C.SQLITE_OK && rv != C.SQLITE_DONE { return false } - c.stmtCache[s.cacheKey] = append(c.stmtCache[s.cacheKey], s) + // If full, finalize the least-recently-used entry at index 0 and + // compact the remaining entries left by one. + if c.stmtCacheCount == c.stmtCacheSize { + victim := c.stmtCacheBuf[0] + runtime.SetFinalizer(victim, nil) + if victim.s != nil { + C.sqlite3_finalize(victim.s) + victim.s = nil + } + victim.c = nil + victim.closed = true + copy(c.stmtCacheBuf[0:c.stmtCacheCount-1], c.stmtCacheBuf[1:c.stmtCacheCount]) + c.stmtCacheCount-- + } + // Append at the MRU tail. + c.stmtCacheBuf[c.stmtCacheCount] = s c.stmtCacheCount++ return true } func (c *SQLiteConn) closeCachedStmtsLocked() { - for key, stmts := range c.stmtCache { - for _, s := range stmts { - if s == nil || s.s == nil { - continue - } - runtime.SetFinalizer(s, nil) - C.sqlite3_finalize(s.s) - s.s = nil - s.c = nil + for i := 0; i < c.stmtCacheCount; i++ { + s := c.stmtCacheBuf[i] + c.stmtCacheBuf[i] = nil + if s == nil || s.s == nil { + continue } - delete(c.stmtCache, key) + runtime.SetFinalizer(s, nil) + C.sqlite3_finalize(s.s) + s.s = nil + s.c = nil } c.stmtCacheCount = 0 } diff --git a/sqlite3_stmt_cache_bench_test.go b/sqlite3_stmt_cache_bench_test.go new file mode 100644 index 0000000..0433350 --- /dev/null +++ b/sqlite3_stmt_cache_bench_test.go @@ -0,0 +1,88 @@ +// Copyright (C) 2019 Yasuhiro Matsumoto . +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +//go:build cgo +// +build cgo + +package sqlite3 + +import ( + "context" + "database/sql/driver" + "fmt" + "testing" +) + +// BenchmarkStmtCache measures the stmt cache hit / miss / eviction paths by +// cycling through a fixed set of queries under various cache sizes. It is +// intended for comparing cache behavior changes, not for absolute numbers. +func BenchmarkStmtCache(b *testing.B) { + cases := []struct { + name string + cacheSize int + keyCount int + }{ + {"off", 0, 1}, // baseline: no cache + {"size4_keys1_hit", 4, 1}, // trivial hit path + {"size4_keys4_hit", 4, 4}, // all queries fit, always hit + {"size4_keys8_evict", 4, 8}, // working set > cache: miss + eviction + {"size16_keys8_hit", 16, 8}, // all queries fit in larger cache + {"size16_keys32_evict", 16, 32}, // working set >> cache + } + for _, tc := range cases { + b.Run(tc.name, func(b *testing.B) { + dsn := ":memory:" + if tc.cacheSize > 0 { + dsn = fmt.Sprintf(":memory:?_stmt_cache_size=%d", tc.cacheSize) + } + d := SQLiteDriver{} + conn, err := d.Open(dsn) + if err != nil { + b.Fatal(err) + } + defer conn.Close() + c := conn.(*SQLiteConn) + + queries := make([]string, tc.keyCount) + for i := range queries { + // Distinct literal forces a distinct prepared statement. + queries[i] = fmt.Sprintf("SELECT %d", i+1) + } + + ctx := context.Background() + // Warm up: exercise each query at least once so the cache (if any) + // reaches steady state before timing begins. + for _, q := range queries { + rows, err := c.query(ctx, q, nil) + if err != nil { + b.Fatal(err) + } + drainRows(b, rows) + } + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + q := queries[i%len(queries)] + rows, err := c.query(ctx, q, nil) + if err != nil { + b.Fatal(err) + } + drainRows(b, rows) + } + }) + } +} + +func drainRows(b *testing.B, rows driver.Rows) { + b.Helper() + dest := make([]driver.Value, len(rows.Columns())) + for { + if err := rows.Next(dest); err != nil { + break + } + } + rows.Close() +} diff --git a/sqlite3_stmt_cache_test.go b/sqlite3_stmt_cache_test.go new file mode 100644 index 0000000..27082e5 --- /dev/null +++ b/sqlite3_stmt_cache_test.go @@ -0,0 +1,152 @@ +// Copyright (C) 2019 Yasuhiro Matsumoto . +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +//go:build cgo +// +build cgo + +package sqlite3 + +import ( + "context" + "testing" +) + +// TestStmtCacheLRUEviction verifies that when the prepared-statement cache is +// full, the least-recently-used entry is evicted to make room for a new one. +// Without eviction, the first N queries to enter the cache would squat on +// every slot forever and any subsequently-prepared query (even a hot one) +// would never benefit from caching. +func TestStmtCacheLRUEviction(t *testing.T) { + d := SQLiteDriver{} + conn, err := d.Open(":memory:?_stmt_cache_size=2") + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + c := conn.(*SQLiteConn) + ctx := context.Background() + + prepareAndClose := func(q string) { + t.Helper() + stmt, err := c.prepareWithCache(ctx, q) + if err != nil { + t.Fatalf("prepareWithCache(%q): %v", q, err) + } + if err := stmt.Close(); err != nil { + t.Fatalf("Close(%q): %v", q, err) + } + } + + q1 := "SELECT 1" + q2 := "SELECT 2" + q3 := "SELECT 3" + + // Fill the cache with q1 and q2. + prepareAndClose(q1) + prepareAndClose(q2) + if got, want := c.stmtCacheCount, 2; got != want { + t.Fatalf("after filling: stmtCacheCount = %d, want %d", got, want) + } + if cacheCount(c, q1) != 1 || cacheCount(c, q2) != 1 { + t.Fatalf("after filling: expected q1 and q2 cached, got %#v", cacheKeys(c)) + } + + // Insert q3. q1 is the oldest entry and should be evicted. + prepareAndClose(q3) + if got, want := c.stmtCacheCount, 2; got != want { + t.Fatalf("after q3: stmtCacheCount = %d, want %d", got, want) + } + if cacheCount(c, q1) != 0 { + t.Fatalf("after q3: q1 should have been evicted, cache=%#v", cacheKeys(c)) + } + if cacheCount(c, q2) != 1 || cacheCount(c, q3) != 1 { + t.Fatalf("after q3: expected q2 and q3 cached, got %#v", cacheKeys(c)) + } + + // Touching q2 should make q3 the oldest (the entry at buf[0]). + prepareAndClose(q2) + if c.stmtCacheCount == 0 || c.stmtCacheBuf[0].cacheKey != q3 { + var head string + if c.stmtCacheCount > 0 { + head = c.stmtCacheBuf[0].cacheKey + } + t.Fatalf("after touching q2: expected q3 at buf[0] (LRU), got %q", head) + } + + // Insert q1 again. Now q3 should be evicted (q2 is newer). + prepareAndClose(q1) + if cacheCount(c, q3) != 0 { + t.Fatalf("after reinserting q1: q3 should have been evicted, cache=%#v", cacheKeys(c)) + } + if cacheCount(c, q1) != 1 || cacheCount(c, q2) != 1 { + t.Fatalf("after reinserting q1: expected q1 and q2 cached, got %#v", cacheKeys(c)) + } + if got, want := c.stmtCacheCount, 2; got != want { + t.Fatalf("after reinserting q1: stmtCacheCount = %d, want %d", got, want) + } + + // Sanity-check: no dangling entries past stmtCacheCount. + for i := c.stmtCacheCount; i < len(c.stmtCacheBuf); i++ { + if c.stmtCacheBuf[i] != nil { + t.Fatalf("stmtCacheBuf[%d] = %p, expected nil tail slot", i, c.stmtCacheBuf[i]) + } + } +} + +// TestStmtCacheReuseReturnsSameHandle verifies that a cached prepare reuses +// the underlying sqlite3_stmt rather than preparing a fresh one. +func TestStmtCacheReuseReturnsSameHandle(t *testing.T) { + d := SQLiteDriver{} + conn, err := d.Open(":memory:?_stmt_cache_size=4") + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + c := conn.(*SQLiteConn) + ctx := context.Background() + + const q = "SELECT 42" + stmt1, err := c.prepareWithCache(ctx, q) + if err != nil { + t.Fatal(err) + } + h1 := stmt1.(*SQLiteStmt).s + if err := stmt1.Close(); err != nil { + t.Fatal(err) + } + + stmt2, err := c.prepareWithCache(ctx, q) + if err != nil { + t.Fatal(err) + } + h2 := stmt2.(*SQLiteStmt).s + if err := stmt2.Close(); err != nil { + t.Fatal(err) + } + + if h1 != h2 { + t.Fatalf("expected cached prepare to reuse sqlite3_stmt handle, got %p vs %p", h1, h2) + } +} + +func cacheKeys(c *SQLiteConn) map[string]int { + out := make(map[string]int) + for i := 0; i < c.stmtCacheCount; i++ { + out[c.stmtCacheBuf[i].cacheKey]++ + } + return out +} + +func cacheCount(c *SQLiteConn, q string) int { + n := 0 + for i := 0; i < c.stmtCacheCount; i++ { + if c.stmtCacheBuf[i].cacheKey == q { + n++ + } + } + return n +}