Prepared statement leak: expired cache entry overwritten without Close
GORM Playground Link
https://github.com/go-gorm/playground/pull/855
Description
A long-lived service using gorm.Config{PrepareStmt: true} (and therefore a PrepareStmtTTL, default 24h) slowly accumulates *sql.Stmt values that are never closed. The count is not bounded by PrepareStmtMaxSize, and the statements are released only when the whole *sql.DB is closed — so on PostgreSQL/MySQL the server-side prepared statements stay allocated too. It leaks one statement per TTL cycle per hot query.
LRU.Get treats an expired entry as a miss but leaves it in c.items:
if ent, ok = c.items[key]; ok {
// Expired item check
if time.Now().After(ent.ExpiresAt) {
return value, false
}Because it is a miss, PreparedStmtDB.prepare falls through to Stmts.New, which caches the new statement via Set → LRU.Add. Add finds the key still present and takes the existing-item branch, which overwrites the value without calling onEvict:
// Check for existing item
if ent, ok := c.items[key]; ok {
c.evictList.MoveToFront(ent)
c.removeFromBucket(ent) // remove the entry from its current bucket as expiresAt is renewed
ent.Value = value
ent.ExpiresAt = now.Add(c.ttl)
c.addToBucket(ent)
return false
}onEvict is the only place cached statements are ever closed — internal/stmt_store/stmt_store.go#L108-L112:
onEvicted := func(k string, v *Stmt) {
if v != nil {
go v.Close()
}
}After the overwrite nothing in the cache references the previous *sql.Stmt, so no later cleanup pass can close it. The window is the gap between an entry expiring and the background deleteExpired goroutine collecting its bucket — exactly when a hot query gets re-run.
Reproduction
The playground test prepares SELECT 1, then SELECT 2 half a cleanup tick later so both land in the same expiration bucket. Since deleteExpired waits for the newest entry in a bucket before collecting it, that second statement delays collection and opens the window; re-running SELECT 1 inside it takes the overwrite branch.
After the bucket has been collected, a counting database/sql driver has seen 3 prepares and 1 close (SELECT 2's). The first SELECT 1 statement is still open, with nothing left that could close it. (database/sql exposes no open-statement count, which is why the test needs its own driver.)
Versions
- GORM: master @
1d6ce99(= v1.31.2) - Go: 1.25.12 linux/amd64 (playground CI, ubuntu-latest); also reproduced on go1.26.5 darwin/arm64
- Driver:
gorm.io/driver/sqlitev1.6.0 /mattn/go-sqlite3v1.14.49 — the affected code is dialect-independent, so it reproduces on every driver
Related
#7831 is a use-after-close race in the same cache. Both are symptoms of statement lifetime being tied to cache-slot lifetime rather than to whether anyone is still using the statement, so a fix for either should account for the other.
Source: go-gorm/gorm