authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-25 19:28:02-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-25 19:28:02-07:00
log8aa5be2f02c3b4b9b5b3b86acbf5f2b0467438f4
tree3f4e87fcc78c144a11ec954447a647962f380eaa
parent7c901a37c4e8f35c408a27dbd1bd2efea06a74f5
signature Signed by PGP key B5690EEEBB952194

fix(server): the SQLite WAL file shrinks back after large writes (#13684)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

2 files changed, 37 insertions(+), 1 deletions(-)

apps/server/src/persistence/Layers/Sqlite.test.ts+31-1
...@@ -10,7 +10,11 @@ import * as Effect from "effect/Effect";...@@ -10,7 +10,11 @@ import * as Effect from "effect/Effect";
10import * as Layer from "effect/Layer";10import * as Layer from "effect/Layer";
11import * as SqlClient from "effect/unstable/sql/SqlClient";11import * as SqlClient from "effect/unstable/sql/SqlClient";
1212
13import { SqlitePersistenceMemory, makeSqlitePersistenceLive } from "./Sqlite.ts";13import {
14 SqlitePersistenceMemory,
15 WAL_SIZE_LIMIT_BYTES,
16 makeSqlitePersistenceLive,
17} from "./Sqlite.ts";
1418
15const lockHolderSource = `19const lockHolderSource = `
16const { DatabaseSync } = require("node:sqlite");20const { DatabaseSync } = require("node:sqlite");
...@@ -57,6 +61,32 @@ it.effect("waits out a concurrent writer instead of failing with SQLITE_BUSY", (...@@ -57,6 +61,32 @@ it.effect("waits out a concurrent writer instead of failing with SQLITE_BUSY", (
57 );61 );
58});62});
5963
64it.effect("shrinks the WAL file back to the size limit after a large write", () => {
65 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-sqlite-wal-"));
66 const dbPath = NodePath.join(tempDir, "state.sqlite");
67 const walFileSize = () => NodeFS.statSync(`${dbPath}-wal`).size;
68 // About 25% more 4 KB rows than the limit holds, in one transaction.
69 const rowCount = Math.ceil((WAL_SIZE_LIMIT_BYTES * 1.25) / 4000);
70
71 return Effect.gen(function* () {
72 const sql = yield* SqlClient.SqlClient;
73 yield* sql`CREATE TABLE wal_probe(payload BLOB)`;
74 yield* sql`
75 WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < ${rowCount})
76 INSERT INTO wal_probe(payload) SELECT randomblob(4000) FROM n
77 `;
78 assert.isAbove(walFileSize(), WAL_SIZE_LIMIT_BYTES);
79
80 // The auto-checkpoint after the large commit copied every frame into the
81 // database, so the next commit restarts the WAL and cuts the file back.
82 yield* sql`INSERT INTO wal_probe(payload) VALUES (x'00')`;
83 assert.isAtMost(walFileSize(), WAL_SIZE_LIMIT_BYTES);
84 }).pipe(
85 Effect.provide(makeSqlitePersistenceLive(dbPath).pipe(Layer.provide(NodeServices.layer))),
86 Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))),
87 );
88});
89
60it.effect("applies busy_timeout in the shared persistence setup", () =>90it.effect("applies busy_timeout in the shared persistence setup", () =>
61 Effect.gen(function* () {91 Effect.gen(function* () {
62 const sql = yield* SqlClient.SqlClient;92 const sql = yield* SqlClient.SqlClient;
apps/server/src/persistence/Layers/Sqlite.ts+6
...@@ -8,6 +8,9 @@ import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";...@@ -8,6 +8,9 @@ import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
8import { runMigrations } from "../Migrations.ts";8import { runMigrations } from "../Migrations.ts";
9import { ServerConfig } from "../../config.ts";9import { ServerConfig } from "../../config.ts";
1010
11// Size the -wal file is cut back to on the first commit after a WAL reset.
12export const WAL_SIZE_LIMIT_BYTES = 32 * 1024 * 1024;
13
11const setup = Layer.effectDiscard(14const setup = Layer.effectDiscard(
12 Effect.gen(function* () {15 Effect.gen(function* () {
13 const sql = yield* SqlClient.SqlClient;16 const sql = yield* SqlClient.SqlClient;
...@@ -15,6 +18,9 @@ const setup = Layer.effectDiscard(...@@ -15,6 +18,9 @@ const setup = Layer.effectDiscard(
15 yield* sql`PRAGMA busy_timeout = 5000;`;18 yield* sql`PRAGMA busy_timeout = 5000;`;
16 yield* sql`PRAGMA foreign_keys = ON;`;19 yield* sql`PRAGMA foreign_keys = ON;`;
17 yield* sql`PRAGMA journal_mode = WAL;`;20 yield* sql`PRAGMA journal_mode = WAL;`;
21 // PASSIVE checkpoints never shrink the -wal file, so it otherwise keeps its
22 // largest size until the last connection closes.
23 yield* sql.unsafe(`PRAGMA journal_size_limit = ${WAL_SIZE_LIMIT_BYTES};`);
18 yield* runMigrations();24 yield* runMigrations();
19 }),25 }),
20);26);