authorgravatar for 33957189+ostapondo@users.noreply.github.comOstap <33957189+ostapondo@users.noreply.github.com> 2026-08-15 14:06:53+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-15 12:06:53+00:00
logc0f9d917c1ab08d30f2b3715dd25d2175a6d2ecf
treedc7dc8aa2e8b589f7f12d647fc756740b390e435
parenta5d35321bd620400dc03ddf7a42ee2672b225ac3
signature Signed by PGP key B5690EEEBB952194

fix(server): wait for concurrent SQLite writers instead of failing with SQLITE_BUSY (#5134)


2 files changed, 68 insertions(+), 0 deletions(-)

apps/server/src/persistence/Layers/Sqlite.test.ts created+66
...@@ -0,0 +1,66 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeChildProcess from "node:child_process";
3import * as NodeFS from "node:fs";
4import * as NodeOS from "node:os";
5import * as NodePath from "node:path";
6
7import * as NodeServices from "@effect/platform-node/NodeServices";
8import { assert, it } from "@effect/vitest";
9import * as Effect from "effect/Effect";
10import * as Layer from "effect/Layer";
11import * as SqlClient from "effect/unstable/sql/SqlClient";
12
13import { SqlitePersistenceMemory, makeSqlitePersistenceLive } from "./Sqlite.ts";
14
15const lockHolderSource = `
16const { DatabaseSync } = require("node:sqlite");
17const db = new DatabaseSync(process.argv[1]);
18db.exec("BEGIN IMMEDIATE");
19process.stdout.write("locked\\n");
20setTimeout(() => {
21 db.exec("COMMIT");
22 db.close();
23}, Number(process.argv[2]));
24`;
25
26const spawnWriteLockHolder = (dbPath: string, holdMs: number) =>
27 Effect.promise(
28 () =>
29 new Promise<void>((resolve, reject) => {
30 const holder = NodeChildProcess.spawn(
31 process.execPath,
32 ["-e", lockHolderSource, dbPath, String(holdMs)],
33 { stdio: ["ignore", "pipe", "ignore"] },
34 );
35 holder.stdout.once("data", () => resolve());
36 holder.on("error", reject);
37 holder.on("exit", () =>
38 reject(new Error("lock holder exited before acquiring the write lock")),
39 );
40 }),
41 );
42
43it.effect("waits out a concurrent writer instead of failing with SQLITE_BUSY", () => {
44 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-sqlite-busy-"));
45 const dbPath = NodePath.join(tempDir, "state.sqlite");
46
47 return Effect.gen(function* () {
48 const sql = yield* SqlClient.SqlClient;
49 yield* sql`CREATE TABLE busy_probe(id INTEGER PRIMARY KEY)`;
50 yield* spawnWriteLockHolder(dbPath, 300);
51 yield* sql`INSERT INTO busy_probe(id) VALUES (${1})`;
52 const rows = yield* sql<{ readonly id: number }>`SELECT id FROM busy_probe`;
53 assert.deepEqual([...rows], [{ id: 1 }]);
54 }).pipe(
55 Effect.provide(makeSqlitePersistenceLive(dbPath).pipe(Layer.provide(NodeServices.layer))),
56 Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))),
57 );
58});
59
60it.effect("applies busy_timeout in the shared persistence setup", () =>
61 Effect.gen(function* () {
62 const sql = yield* SqlClient.SqlClient;
63 const rows = yield* sql<{ readonly timeout: number }>`PRAGMA busy_timeout`;
64 assert.equal(rows[0]?.timeout, 5000);
65 }).pipe(Effect.provide(SqlitePersistenceMemory)),
66);
apps/server/src/persistence/Layers/Sqlite.ts+2
...@@ -33,6 +33,8 @@ const makeRuntimeSqliteLayer = Effect.fn("makeRuntimeSqliteLayer")(function* (...@@ -33,6 +33,8 @@ const makeRuntimeSqliteLayer = Effect.fn("makeRuntimeSqliteLayer")(function* (
33const setup = Layer.effectDiscard(33const setup = Layer.effectDiscard(
34 Effect.gen(function* () {34 Effect.gen(function* () {
35 const sql = yield* SqlClient.SqlClient;35 const sql = yield* SqlClient.SqlClient;
36 // CLI and server write from separate processes; wait rather than fail with SQLITE_BUSY.
37 yield* sql`PRAGMA busy_timeout = 5000;`;
36 yield* sql`PRAGMA foreign_keys = ON;`;38 yield* sql`PRAGMA foreign_keys = ON;`;
37 yield* sql`PRAGMA journal_mode = WAL;`;39 yield* sql`PRAGMA journal_mode = WAL;`;
38 yield* runMigrations();40 yield* runMigrations();