From 522ca671b5a8025e124674742bbea2c75ac13dd6 Mon Sep 17 00:00:00 2001 From: Josh Voyles <120749218+Josh-Voyles@users.noreply.github.com> Date: Wed, 10 Jun 2026 20:12:00 -0400 Subject: [PATCH] fix: remove no-op autoFinalizeStatement wrapper and redundant busy_timeout (#2120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit better-sqlite3 11.x exposes no Statement.finalize() — the wrapper threw and swallowed a TypeError on every query (verified: 'Statement.finalize exists: undefined' in the runner image) while adding +122% per-statement overhead (3.90 -> 8.66 us/op, 200k-op in-container microbench) and freeing nothing. Statement lifecycle is GC-managed by the driver; drizzle-orm prepares fresh per query, so nothing accumulates unbounded. busy_timeout=5000 duplicates better-sqlite3's default timeout option, which already arms sqlite3_busy_timeout(db, 5000) at open (lib/database.js). With ENABLE_SQLITE_WAL_MODE unset the driver is now runtime-identical to pre-1.18.3 (zero pragmas). The env-gated WAL block stays: journal_mode is sticky in the DB file, so removing it would strand opted-in databases on WAL+synchronous=FULL. Co-Authored-By: Claude Fable 5 --- server/db/sqlite/driver.ts | 52 +++++++------------------------------- 1 file changed, 9 insertions(+), 43 deletions(-) diff --git a/server/db/sqlite/driver.ts b/server/db/sqlite/driver.ts index 644a160aa..a7eee52b7 100644 --- a/server/db/sqlite/driver.ts +++ b/server/db/sqlite/driver.ts @@ -1,6 +1,5 @@ import { drizzle as DrizzleSqlite } from "drizzle-orm/better-sqlite3"; import Database from "better-sqlite3"; -import type BetterSqlite3 from "better-sqlite3"; import * as schema from "./schema/schema"; import path from "path"; import fs from "fs"; @@ -12,64 +11,31 @@ export const exists = checkFileExists(location); bootstrapVolume(); -/** - * Wraps better-sqlite3 Statement to call `finalize()` immediately after - * execution, freeing native sqlite3_stmt memory deterministically instead - * of waiting for GC. Fixes steady off-heap growth under load (#2120). - * WARNING: Finalizes after first execution — incompatible with drizzle's - * reusable .prepare() builders. No such usage exists in this codebase. - */ -function autoFinalizeStatement( - stmt: BetterSqlite3.Statement -): BetterSqlite3.Statement { - const wrapExec = any>(fn: T): T => { - return function (this: any, ...args: any[]) { - try { - return fn.apply(this, args); - } finally { - try { - // finalize() exists on the native Statement at runtime but - // is missing from @types/better-sqlite3. - (stmt as any).finalize(); - } catch { - // Already finalized — harmless - } - } - } as unknown as T; - }; - - stmt.run = wrapExec(stmt.run); - stmt.get = wrapExec(stmt.get); - stmt.all = wrapExec(stmt.all); - - return stmt; -} - function createDb() { const sqlite = new Database(location); if (process.env.ENABLE_SQLITE_WAL_MODE == "true") { // Enable WAL mode — allows concurrent readers + single writer, preventing // contention across subsystems (verifySession, Traefik, audit, ping). + // NOTE: journal_mode persists in the DB file once set; unsetting this + // env var does NOT revert an existing WAL database. sqlite.pragma("journal_mode = WAL"); // NORMAL sync mode: safe with WAL, reduces write lock hold time. sqlite.pragma("synchronous = NORMAL"); } - // Wait up to 5s on SQLITE_BUSY instead of failing — prevents audit log - // retry loops that accumulate memory. - sqlite.pragma("busy_timeout = 5000"); + // No busy_timeout pragma: better-sqlite3 already arms + // sqlite3_busy_timeout(db, 5000) via its default `timeout` option + // (lib/database.js), so an explicit pragma is redundant. // Intentionally NOT setting cache_size or mmap_size: a large page cache plus // a multi-hundred-MB mmap region inflate RSS and cause page-cache thrashing // on small (~1 GB) instances. Leave SQLite on its conservative defaults. - // Wrap prepare() so every drizzle-orm statement is auto-finalized after - // first use, preventing sqlite3_stmt accumulation between GC cycles. - const originalPrepare = sqlite.prepare.bind(sqlite); - (sqlite as any).prepare = function autoFinalizePrepare(source: string) { - return autoFinalizeStatement(originalPrepare(source)); - }; + // Intentionally NOT wrapping prepare()/statements: better-sqlite3 finalizes + // sqlite3_stmt in the Statement destructor at GC, and drizzle-orm prepares a + // fresh statement per query (no statement cache), so statements cannot + // accumulate. better-sqlite3 11.x exposes no Statement.finalize() at all. return DrizzleSqlite(sqlite, { schema