Cached prepared statement closed by eviction between lookup and execution
GORM Playground Link
https://github.com/go-gorm/playground/pull/856
Description
With gorm.Config{PrepareStmt: true} and a bounded cache (PrepareStmtMaxSize, or just PrepareStmtTTL expiry), live requests intermittently fail with sql: statement is closed.
prepare has released every cache lock by the time it returns, so the execution on the next line runs unprotected — prepare_stmt.go#L109-L118:
func (db *PreparedStmtDB) ExecContext(ctx context.Context, query string, args ...interface{}) (result sql.Result, err error) {
stmt, err := db.prepare(ctx, db.ConnPool, false, query)
if err == nil {
result, err = stmt.ExecContext(ctx, args...)
if errors.Is(err, driver.ErrBadConn) {
db.Stmts.Delete(query)
}
}
return result, err
}In that window another caller can evict the entry — by size in LRU.Add or by the TTL cleanup goroutine in deleteExpired.
Both reach removeElement:
func (c *LRU[K, V]) removeElement(e *Entry[K, V]) {
c.evictList.Remove(e)
delete(c.items, e.Key)
c.removeFromBucket(e)
if c.onEvict != nil {
c.onEvict(e.Key, e.Value)
}
}and stmt_store's callback closes the statement on another goroutine - internal/stmt_store/stmt_store.go#L108-L112:
onEvicted := func(k string, v *Stmt) {
if v != nil {
go v.Close()
}
}Two things turn this into a failed request rather than a retry:
sql: statement is closedis notdriver.ErrBadConn, andExecContextabove drops the cache entry only onErrBadConn, so the entry is neither invalidated nor re-prepared.- Cache hits don't extend an entry's TTL -
LRU.Getonly callsMoveToFront— so the hottest statements expire on the same schedule as idle ones.
Reproduction
Two callers, two queries, cache size 1: each call evicts the other's statement while that other call sits between prepare() and Exec. Fails in ~30ms, and the test also asserts the error is not driver.ErrBadConn.
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
#7832 is a statement leak in the same cache: LRU.Add's existing-item branch overwrites an expired entry's value without calling onEvict, so the replaced *sql.Stmt is never closed. 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