From fb8d0fc19c27ff860bdefb7dcfe69eae546f837c Mon Sep 17 00:00:00 2001 From: Otavio Salvador Date: Tue, 13 Oct 2026 17:19:00 -0300 Subject: [PATCH] fix(server): collect SQLite planner statistics when the database opens (#18471) --- apps/server/src/persistence/Sqlite.test.ts | 30 ++++++++++++++++++++++ apps/server/src/persistence/Sqlite.ts | 4 +++ 2 files changed, 34 insertions(+) diff --git a/apps/server/src/persistence/Sqlite.test.ts b/apps/server/src/persistence/Sqlite.test.ts index 1bdacd0d13d7814905f2e00b736a6191d02becad..666e14d3a5cc69f39b3ceae7b54609df9bfe717e 100644 --- a/apps/server/src/persistence/Sqlite.test.ts +++ b/apps/server/src/persistence/Sqlite.test.ts @@ -84,6 +84,36 @@ it.effect("shrinks the WAL file back to the size limit after a large write", () ); }); +it.effect("collects planner statistics for an existing database on open", () => { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-sqlite-stats-")); + const dbPath = NodePath.join(tempDir, "state.sqlite"); + const persistence = () => + SqlitePersistence.layerFromPath(dbPath).pipe(Layer.provide(NodeServices.layer)); + + return Effect.gen(function* () { + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`CREATE TABLE stats_probe(id INTEGER PRIMARY KEY, thread_id TEXT NOT NULL)`; + yield* sql`CREATE INDEX stats_probe_thread_idx ON stats_probe(thread_id)`; + yield* sql` + WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 1000) + INSERT INTO stats_probe(thread_id) SELECT 'thread-' || (i % 10) FROM n + `; + }).pipe(Effect.provide(persistence())); + + // A database written before statistics existed gets them on the next open. + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ readonly stat: string }>` + SELECT stat FROM sqlite_stat1 WHERE idx = 'stats_probe_thread_idx' + `; + assert.deepEqual([...rows], [{ stat: "1000 100" }]); + }).pipe(Effect.provide(persistence())); + }).pipe( + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))), + ); +}); + it.effect("applies busy_timeout in the shared persistence setup", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/persistence/Sqlite.ts b/apps/server/src/persistence/Sqlite.ts index 0f248b5a2387776659f95aa20fdacc8a893a51b6..2ea5199b28337748918e1e5fd08d0bf52204994f 100644 --- a/apps/server/src/persistence/Sqlite.ts +++ b/apps/server/src/persistence/Sqlite.ts @@ -23,6 +23,10 @@ const layerSetup = Layer.effectDiscard( // largest size until the last connection closes. yield* sql.unsafe(`PRAGMA journal_size_limit = ${WAL_SIZE_LIMIT_BYTES};`); yield* runMigrations(); + // Without sqlite_stat1 the planner guesses at index selectivity. 0x10012 + // analyzes tables whose indexes lack stats or whose row count changed 10x, + // under a temporary analysis_limit. Otherwise it only checks table sizes. + yield* sql`PRAGMA optimize = 0x10012;`; }), ); -- 2.54.0