authorgravatar for 153842990+yashranaway@users.noreply.github.comAditya Garud <153842990+yashranaway@users.noreply.github.com> 2026-09-25 20:20:04+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-25 14:50:04+00:00
logd06f0ff1048d42a156824765316c5a2dc54f5bca
tree5a5f3ae0f10ca987c8df1c071a7091a541ca337a
parente5a46d6c5d00b89afba5274a94d42428c8d79763
signature Signed by PGP key B5690EEEBB952194

fix(sqlite): retry failed statement preparations (#10584)

Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com>

2 files changed, 53 insertions(+), 3 deletions(-)

packages/shared/src/nodeSqliteClient.test.ts+48
...@@ -1,6 +1,10 @@...@@ -1,6 +1,10 @@
1import * as NodeSqlite from "node:sqlite";
2import * as NodeServices from "@effect/platform-node/NodeServices";
1import { assert, it } from "@effect/vitest";3import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
3import * as Layer from "effect/Layer";6import * as Layer from "effect/Layer";
7import * as Path from "effect/Path";
4import * as SqlClient from "effect/unstable/sql/SqlClient";8import * as SqlClient from "effect/unstable/sql/SqlClient";
59
6import * as SqliteClient from "./nodeSqliteClient.ts";10import * as SqliteClient from "./nodeSqliteClient.ts";
...@@ -8,6 +12,21 @@ import * as SqliteClient from "./nodeSqliteClient.ts";...@@ -8,6 +12,21 @@ import * as SqliteClient from "./nodeSqliteClient.ts";
8const layer = it.layer(SqliteClient.layer({ filename: ":memory:" }));12const layer = it.layer(SqliteClient.layer({ filename: ":memory:" }));
913
10layer("NodeSqliteClient", (it) => {14layer("NodeSqliteClient", (it) => {
15 it.effect("retries preparing a query after the missing schema becomes available", () =>
16 Effect.gen(function* () {
17 const sql = yield* SqlClient.SqlClient;
18 const select = sql<{ name: string }>`SELECT name FROM created_after_prepare_failure`;
19 const error = yield* select.pipe(Effect.flip);
20 assert.equal(error._tag, "SqlError");
21 assert.equal(error.reason.operation, "prepare");
22
23 yield* sql`CREATE TABLE created_after_prepare_failure(name TEXT NOT NULL)`;
24 yield* sql`INSERT INTO created_after_prepare_failure VALUES ('recovered')`;
25 assert.deepEqual(yield* select, [{ name: "recovered" }]);
26 assert.deepEqual(yield* select.values, [["recovered"]]);
27 }),
28 );
29
11 it.effect("runs prepared queries and returns positional values", () =>30 it.effect("runs prepared queries and returns positional values", () =>
12 Effect.gen(function* () {31 Effect.gen(function* () {
13 const sql = yield* SqlClient.SqlClient;32 const sql = yield* SqlClient.SqlClient;
...@@ -54,3 +73,32 @@ it.effect("returns a typed failure when the database cannot be opened", () =>...@@ -54,3 +73,32 @@ it.effect("returns a typed failure when the database cannot be opened", () =>
54 assert.equal(error.reason.operation, "open");73 assert.equal(error.reason.operation, "open");
55 }),74 }),
56);75);
76
77it.effect(
78 "recovers a prepared query immediately after an exclusive database lock is released",
79 () =>
80 Effect.gen(function* () {
81 const fs = yield* FileSystem.FileSystem;
82 const path = yield* Path.Path;
83 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-sqlite-prepare-" });
84 const filename = path.join(directory, "state.sqlite");
85 const blocker = yield* Effect.acquireRelease(
86 Effect.sync(() => new NodeSqlite.DatabaseSync(filename)),
87 (database) => Effect.sync(() => database.close()),
88 );
89 yield* Effect.sync(() => {
90 blocker.exec("CREATE TABLE entries(value TEXT); INSERT INTO entries VALUES ('retained')");
91 });
92 yield* Effect.gen(function* () {
93 const sql = yield* SqlClient.SqlClient;
94 yield* Effect.sync(() => blocker.exec("BEGIN EXCLUSIVE"));
95 const select = sql`SELECT value FROM entries`;
96 const error = yield* select.values.pipe(Effect.flip);
97 assert.equal(error._tag, "SqlError");
98 assert.equal(error.reason.operation, "prepare");
99 yield* Effect.sync(() => blocker.exec("ROLLBACK"));
100 assert.deepEqual(yield* select.values, [["retained"]]);
101 assert.deepEqual(yield* select, [{ value: "retained" }]);
102 }).pipe(Effect.provide(SqliteClient.layer({ filename })));
103 }).pipe(Effect.provide(NodeServices.layer)),
104);
packages/shared/src/nodeSqliteClient.ts+5-3
...@@ -9,6 +9,7 @@ import * as NodeSqlite from "node:sqlite";...@@ -9,6 +9,7 @@ import * as NodeSqlite from "node:sqlite";
9import * as Cache from "effect/Cache";9import * as Cache from "effect/Cache";
10import * as Duration from "effect/Duration";10import * as Duration from "effect/Duration";
11import * as Effect from "effect/Effect";11import * as Effect from "effect/Effect";
12import * as Exit from "effect/Exit";
12import * as Fiber from "effect/Fiber";13import * as Fiber from "effect/Fiber";
13import { identity } from "effect/Function";14import { identity } from "effect/Function";
14import * as Layer from "effect/Layer";15import * as Layer from "effect/Layer";
...@@ -144,10 +145,11 @@ const make = Effect.fn("makeWithDatabase")(function* (...@@ -144,10 +145,11 @@ const make = Effect.fn("makeWithDatabase")(function* (
144 }),145 }),
145 });146 });
146147
147 const prepareCache = yield* Cache.make({148 const prepareCache = yield* Cache.makeWith(prepare, {
148 capacity: options.prepareCacheSize ?? 200,149 capacity: options.prepareCacheSize ?? 200,
149 timeToLive: options.prepareCacheTTL ?? Duration.minutes(10),150 // A transient prepare failure must not outlive the lock or missing schema.
150 lookup: prepare,151 timeToLive: (exit) =>
152 Exit.isSuccess(exit) ? (options.prepareCacheTTL ?? Duration.minutes(10)) : Duration.zero,
151 });153 });
152154
153 const runStatement = (155 const runStatement = (