From db14038bd847c5bad57de217e0ac0c5ad1ae60ba Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 27 Oct 2026 11:28:28 +0000 Subject: [PATCH] fix(chord): bound the Node SQLite driver's prepared-statement cache The driver kept every statement it ever prepared. It now keeps the 256 most recently used per connection; below half the limit a hit stays one lookup. --- packages/chord/CHANGELOG.md | 1 + packages/chord/src/sqlite/node.ts | 24 +++++++++++++++++++----- packages/chord/test/sqlite/node.test.ts | 19 ++++++++++++++++++- 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/packages/chord/CHANGELOG.md b/packages/chord/CHANGELOG.md index 28fd35e9d..fd2fac9ff 100644 --- a/packages/chord/CHANGELOG.md +++ b/packages/chord/CHANGELOG.md @@ -26,6 +26,7 @@ ### Fixed +- The Node SQLite driver keeps the 256 most recently used prepared statements per connection instead of every statement it ever prepared, so callers generating SQL of unbounded shapes (a query builder) no longer grow memory without bound. - Fixed append ops from the delta tracker and `diffRevisions()` keeping the whole string they were cut from alive (V8 represents a slice as a view of its source), so a delta chain, a watch backlog or a replica built from appends to a growing text held one copy of the text per commit ([#9265](https://github.com/earendil-works/pi/issues/9265)) ## [1.4.0] - 2026-10-21 diff --git a/packages/chord/src/sqlite/node.ts b/packages/chord/src/sqlite/node.ts index 202c37c4f..269249027 100644 --- a/packages/chord/src/sqlite/node.ts +++ b/packages/chord/src/sqlite/node.ts @@ -68,9 +68,14 @@ class SerialOperationQueue { } } +/** How many prepared statements one connection keeps. */ +const STATEMENT_CACHE_LIMIT = 256; + /** * Executes SQL on one connection. Prepared statements are cached per connection by SQL text, so the - * database and its transaction handles share them across transactions. + * database and its transaction handles share them across transactions. The cache keeps the + * `STATEMENT_CACHE_LIMIT` most recently used statements, so callers generating SQL of unbounded shapes + * cannot grow it without bound. */ abstract class NodeSqliteExecutor implements SqliteExecutor { protected readonly database: DatabaseSync; @@ -108,11 +113,20 @@ abstract class NodeSqliteExecutor implements SqliteExecutor { protected abstract runOperation(operation: () => T): Promise; private statement(sql: string): StatementSync { - let statement = this.statements.get(sql); - if (statement === undefined) { - statement = this.database.prepare(sql); - this.statements.set(sql, statement); + const statements = this.statements; + let statement = statements.get(sql); + if (statement !== undefined) { + // Recency matters only once evictions come near: a small cache keeps one lookup per statement. + if (statements.size > STATEMENT_CACHE_LIMIT / 2) { + statements.delete(sql); + statements.set(sql, statement); + } + return statement; } + statement = this.database.prepare(sql); + statements.set(sql, statement); + // A Map iterates in insertion order: its first key is the least recently used statement. + if (statements.size > STATEMENT_CACHE_LIMIT) statements.delete(statements.keys().next().value!); return statement; } } diff --git a/packages/chord/test/sqlite/node.test.ts b/packages/chord/test/sqlite/node.test.ts index c7b72b073..96ad09a96 100644 --- a/packages/chord/test/sqlite/node.test.ts +++ b/packages/chord/test/sqlite/node.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from "vitest"; +import { DatabaseSync } from "node:sqlite"; +import { describe, expect, it, vi } from "vitest"; import type { SqliteExecutor } from "../../src/sqlite/index.ts"; import { openNodeSqliteDatabase } from "../../src/sqlite/node.ts"; import { RUN_CHANGES, runChanges } from "./helpers.ts"; @@ -19,6 +20,22 @@ describe("Node SQLite driver", () => { } }); + it("keeps the most recently used prepared statements and prepares evicted ones again", async () => { + const database = await openNodeSqliteDatabase(":memory:"); + const prepare = vi.spyOn(DatabaseSync.prototype, "prepare"); + try { + for (let i = 0; i < 300; i++) await database.get(`SELECT ${i} AS n`); + const prepared = prepare.mock.calls.length; + expect(await database.get("SELECT 299 AS n")).toEqual({ n: 299 }); + expect(prepare.mock.calls.length).toBe(prepared); + expect(await database.get("SELECT 0 AS n")).toEqual({ n: 0 }); + expect(prepare.mock.calls.length).toBe(prepared + 1); + } finally { + prepare.mockRestore(); + await database.close(); + } + }); + it("reports the rows a statement changed, without index writes", async () => { const database = await openNodeSqliteDatabase(":memory:"); try {