Process-wide prepared statement cache can leak statement modes between callers #76

Closed
opened 2026-10-08 16:33:32 -04:00 by goddard · 1 comment
Collaborator

db().prepare is replaced with a process-wide cache (db/index.ts:451-461). Statement modes like .raw()/.pluck() and in-progress iterations would leak between unrelated callers using the same SQL. Nothing hits this today.

Fix

  • Scope the cache so modes can't leak (e.g. don't cache statements that set modes, or reset modes on each fetch), or drop the global override.
`db().prepare` is replaced with a process-wide cache (`db/index.ts:451-461`). Statement modes like `.raw()`/`.pluck()` and in-progress iterations would leak between unrelated callers using the same SQL. Nothing hits this today. **Fix** - Scope the cache so modes can't leak (e.g. don't cache statements that set modes, or reset modes on each fetch), or drop the global override.
goddard added the
priority
low
type/maintainability
labels 2026-10-08 16:39:18 -04:00
josh was assigned by goddard 2026-10-08 17:31:50 -04:00
Author
Collaborator

Reviewed bd16737dd8. The process-wide statement cache is gone, so each caller now gets its own statement and modes can't leak. I'm closing this.

Checklist

  • Drop the global override. db/index.ts:648-656 no longer replaces prepare with a shared cache. The wrapper is installed only when EC_SLOW_SQL_MS is set, and it still returns a fresh statement every time. The comment there now says statements are caller-owned. New db-statements.test.ts cases run with slow-SQL logging both off and on (EC_SLOW_SQL_MS 0 and 20). They show that raw, pluck, expand, safeIntegers and permanent bindings stay on their own statement, and that the same SQL can run while another statement is mid-iteration.

Review notes

  • host-observation-retention.test.ts now checks that retention uses only three distinct delete queries, instead of checking statement identity. That still covers what it was meant to.
  • Cost: preparing a statement takes about 25 µs here, against about 0.5 µs for a cached one, so a request running 20 queries pays about 0.5 ms. The only prepare calls inside loops are in migrations (which never used the cache) and a three-table loop in server-history.ts:62. The full CI suite took 185 s on this commit and 190 s on the one before.
  • The 3 affected test files pass locally (28 tests), and CI run 840 passed on this commit.
Reviewed bd16737dd87c9339814b67cedfa4bded75b05058. The process-wide statement cache is gone, so each caller now gets its own statement and modes can't leak. I'm closing this. **Checklist** - [x] **Drop the global override.** `db/index.ts:648-656` no longer replaces `prepare` with a shared cache. The wrapper is installed only when `EC_SLOW_SQL_MS` is set, and it still returns a fresh statement every time. The comment there now says statements are caller-owned. New `db-statements.test.ts` cases run with slow-SQL logging both off and on (`EC_SLOW_SQL_MS` 0 and 20). They show that `raw`, `pluck`, `expand`, `safeIntegers` and permanent bindings stay on their own statement, and that the same SQL can run while another statement is mid-iteration. **Review notes** - `host-observation-retention.test.ts` now checks that retention uses only three distinct delete queries, instead of checking statement identity. That still covers what it was meant to. - Cost: preparing a statement takes about 25 µs here, against about 0.5 µs for a cached one, so a request running 20 queries pays about 0.5 ms. The only `prepare` calls inside loops are in migrations (which never used the cache) and a three-table loop in `server-history.ts:62`. The full CI suite took 185 s on this commit and 190 s on the one before. - The 3 affected test files pass locally (28 tests), and CI [run 840](https://gitea.thewrightserver.net/josh/electric-cherry/actions/runs/840) passed on this commit.
Sign in to join this conversation.