From 2392928d25f9b9f36db359412c4115bd56293ee5 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 23 Oct 2026 08:27:22 -0500 Subject: [PATCH 1/3] test(server): a large write keeps its statement journal off the temp directory Co-Authored-By: Claude Opus 5.5 --- apps/server/src/persistence/Sqlite.test.ts | 45 ++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/apps/server/src/persistence/Sqlite.test.ts b/apps/server/src/persistence/Sqlite.test.ts index 58895755ff983..02659aee474b6 100644 --- a/apps/server/src/persistence/Sqlite.test.ts +++ b/apps/server/src/persistence/Sqlite.test.ts @@ -5,12 +5,57 @@ import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as SqlClient from "effect/sql/SqlClient"; import * as SqlitePersistence from "./Sqlite.ts"; +// Linux names an open, already-deleted file " (deleted)" in /proc/self/fd. +// SQLite names its temp files "etilqs_*" and, without TMPDIR, puts them in +// /var/tmp while os.tmpdir() is /tmp, so match the name, not the directory. +const openDeletedTempFiles = () => + NodeFS.readdirSync("/proc/self/fd").flatMap((fd) => { + try { + const target = NodeFS.readlinkSync(`/proc/self/fd/${fd}`); + return target.includes("/etilqs_") && target.endsWith(" (deleted)") ? [target] : []; + } catch { + return []; + } + }); + +it.effect.skipIf(HostProcess.Platform.defaultValue() !== "linux")( + "keeps a write transaction's statement journal off the temp directory", + () => { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-sqlite-temp-")); + const persistence = SqlitePersistence.layerFromPath( + NodePath.join(tempDir, "state.sqlite"), + ).pipe(Layer.provide(NodeServices.layer)); + + return Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`CREATE TABLE journal_probe(id INTEGER PRIMARY KEY, payload TEXT NOT NULL)`; + for (let id = 0; id < 64; id++) { + yield* sql`INSERT INTO journal_probe VALUES (${id}, ${"x".repeat(4096)})`; + } + const before = new Set(openDeletedTempFiles()); + const opened = yield* sql.withTransaction( + Effect.gen(function* () { + // A multi-row update can fail halfway, so SQLite journals every page + // it changes, here well past the 64 KiB it holds in memory by default. + yield* sql`UPDATE journal_probe SET payload = payload || 'y'`; + return openDeletedTempFiles().filter((target) => !before.has(target)); + }), + ); + assert.deepEqual(opened, []); + }).pipe( + Effect.provide(persistence), + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))), + ); + }, +); + it.effect("drops planner statistics a database already has", () => { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-sqlite-stats-")); const dbPath = NodePath.join(tempDir, "state.sqlite"); From 2fec879b60dd5cfe27eb532ada17cccd657da95a Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 23 Oct 2026 08:27:43 -0500 Subject: [PATCH 2/3] fix(server): SQLite temp data no longer blocks the event loop on slow filesystems The writer runs on the main thread. A multi-row write inside a transaction spills its statement journal to a temp file in $TMPDIR once it passes 64 KiB, and large sorts do the same. On a loaded btrfs host each temp-file create waited seconds for a filesystem commit. Keep temp data in memory. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/persistence/Sqlite.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/server/src/persistence/Sqlite.ts b/apps/server/src/persistence/Sqlite.ts index aeff92f08aa67..c3f2c2e43d4cd 100644 --- a/apps/server/src/persistence/Sqlite.ts +++ b/apps/server/src/persistence/Sqlite.ts @@ -33,6 +33,11 @@ const layerSetup = Layer.effectDiscard( // Checkpoints are now the only sync, and macOS fsync() does not flush the // drive cache. F_FULLFSYNC makes them reach disk; other platforms ignore it. yield* sql`PRAGMA checkpoint_fullfsync = ON;`; + // Statement journals past 64 KiB and large sorts otherwise go to temp + // files in $TMPDIR, created on the main thread. On a busy copy-on-write + // filesystem such as btrfs, each create can wait seconds for a filesystem + // commit and blocks the event loop. Memory use is bounded by one statement. + yield* sql`PRAGMA temp_store = MEMORY;`; // PASSIVE checkpoints never shrink the -wal file, so it otherwise keeps its // largest size until the last connection closes. yield* sql.unsafe(`PRAGMA journal_size_limit = ${WAL_SIZE_LIMIT_BYTES};`); From faef74285f4cd8e11c319623cd7b75cda8f6adcc Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 23 Oct 2026 12:20:33 -0500 Subject: [PATCH 3/3] docs(server): say what in-memory SQLite temp data can cost The comment promised memory bounded by one statement. An open savepoint keeps its journal pages across statements, so large statements and open savepoints can both raise peak memory. SQLite also falls back to /var/tmp without TMPDIR, so the comment no longer names $TMPDIR. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/persistence/Sqlite.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/server/src/persistence/Sqlite.ts b/apps/server/src/persistence/Sqlite.ts index c3f2c2e43d4cd..e985453b2248a 100644 --- a/apps/server/src/persistence/Sqlite.ts +++ b/apps/server/src/persistence/Sqlite.ts @@ -34,9 +34,10 @@ const layerSetup = Layer.effectDiscard( // drive cache. F_FULLFSYNC makes them reach disk; other platforms ignore it. yield* sql`PRAGMA checkpoint_fullfsync = ON;`; // Statement journals past 64 KiB and large sorts otherwise go to temp - // files in $TMPDIR, created on the main thread. On a busy copy-on-write - // filesystem such as btrfs, each create can wait seconds for a filesystem - // commit and blocks the event loop. Memory use is bounded by one statement. + // files, created on the main thread. On a busy copy-on-write filesystem + // such as btrfs, each create can wait seconds for a filesystem commit and + // blocks the event loop. Temporary data stays in memory; large statements + // and open savepoints can increase peak memory use. yield* sql`PRAGMA temp_store = MEMORY;`; // PASSIVE checkpoints never shrink the -wal file, so it otherwise keeps its // largest size until the last connection closes.