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.
This commit is contained in:
Mario Zechner 2026-10-27 11:28:28 +00:00
parent 177e2ad7e4
commit db14038bd8
3 changed files with 38 additions and 6 deletions

View file

@ -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

View file

@ -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<T>(operation: () => T): Promise<T>;
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;
}
}

View file

@ -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 {