From 916fdf3516cfc93447265fbdd8711a64dbb040bd Mon Sep 17 00:00:00 2001 From: Roamin <97863888+roaminro@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:58:25 -0400 Subject: [PATCH] Add timeout config option for local sqlite3 connections The native libsql binding already supports a busy timeout via its Database options ('opts?.timeout ?? 0.0'), but the client never passed it through, so local connections always opened with no busy handler. Applying 'PRAGMA busy_timeout' manually doesn't stick either: since transaction() hands the client's connection to the transaction and lazily opens a fresh one for the next statement, the pragma is silently lost after the first interactive transaction. Any later lock contention (e.g. two processes sharing one database file) then fails instantly with SQLITE_BUSY instead of waiting. Passing the timeout through createClient() config applies it natively to every connection the client opens, including the lazily-created ones after transaction(). Fixes #288 --- .../src/__tests__/client.test.ts | 73 +++++++++++++++++++ .../src/__tests__/config.test.ts | 12 +++ packages/libsql-client/src/sqlite3.ts | 1 + packages/libsql-core/src/api.ts | 11 +++ packages/libsql-core/src/config.ts | 3 + 5 files changed, 100 insertions(+) diff --git a/packages/libsql-client/src/__tests__/client.test.ts b/packages/libsql-client/src/__tests__/client.test.ts index b6566424..a3cd4362 100644 --- a/packages/libsql-client/src/__tests__/client.test.ts +++ b/packages/libsql-client/src/__tests__/client.test.ts @@ -1592,6 +1592,79 @@ describe("transaction()", () => { } }); +describe("timeout option (local files)", () => { + const dbPath = `/tmp/libsql-client-timeout-test-${process.pid}.db`; + const dbUrl = `file:${dbPath}`; + + afterEach(async () => { + const fs = await import("node:fs"); + for (const suffix of ["", "-wal", "-shm"]) { + fs.rmSync(dbPath + suffix, { force: true }); + } + }); + + test("timeout sets busy_timeout on the connection", async () => { + const c = createClient({ url: dbUrl, timeout: 5000 }); + try { + const rs = await c.execute("PRAGMA busy_timeout"); + expect(rs.rows[0]["timeout"]).toStrictEqual(5000); + } finally { + c.close(); + } + }); + + // see issue https://github.com/tursodatabase/libsql-client-ts/issues/288 + test("timeout survives connections created after transaction()", async () => { + const c = createClient({ url: dbUrl, timeout: 5000 }); + try { + const txn = await c.transaction("write"); + await txn.execute("CREATE TABLE t (a)"); + await txn.commit(); + + // transaction() hands the client's connection to the transaction + // and lazily opens a new one; the timeout must apply to it too. + const rs = await c.execute("PRAGMA busy_timeout"); + expect(rs.rows[0]["timeout"]).toStrictEqual(5000); + } finally { + c.close(); + } + }); + + test("locked database waits for timeout instead of failing immediately", async () => { + const setup = createClient({ url: dbUrl }); + await setup.execute("CREATE TABLE t (a)"); + setup.close(); + + const lockHolder = createClient({ url: dbUrl }); + const withTimeout = createClient({ url: dbUrl, timeout: 500 }); + const withoutTimeout = createClient({ url: dbUrl }); + try { + const lock = await lockHolder.transaction("write"); + await lock.execute("INSERT INTO t VALUES (1)"); + + // Without a timeout, a locked write fails immediately. + let start = Date.now(); + await expect( + withoutTimeout.execute("INSERT INTO t VALUES (2)"), + ).rejects.toBeLibsqlError("SQLITE_BUSY"); + expect(Date.now() - start).toBeLessThan(250); + + // With a timeout, the busy handler waits before giving up. + start = Date.now(); + await expect( + withTimeout.execute("INSERT INTO t VALUES (3)"), + ).rejects.toBeLibsqlError("SQLITE_BUSY"); + expect(Date.now() - start).toBeGreaterThanOrEqual(450); + + await lock.rollback(); + } finally { + lockHolder.close(); + withTimeout.close(); + withoutTimeout.close(); + } + }); +}); + (isFile ? test : test.skip)("raw error codes", async () => { const c = createClient(config); try { diff --git a/packages/libsql-client/src/__tests__/config.test.ts b/packages/libsql-client/src/__tests__/config.test.ts index e617f738..30fd41f4 100644 --- a/packages/libsql-client/src/__tests__/config.test.ts +++ b/packages/libsql-client/src/__tests__/config.test.ts @@ -197,6 +197,18 @@ describe("expandConfig - parsing of valid arguments", () => { concurrency: 20, }, }, + { + name: "timeout", + config: { url: "file:local.db", timeout: 5000 }, + expanded: { + scheme: "file", + tls: true, + intMode: "number", + path: "local.db", + concurrency: 20, + timeout: 5000, + }, + }, { name: "override auth token", config: { diff --git a/packages/libsql-client/src/sqlite3.ts b/packages/libsql-client/src/sqlite3.ts index b8a9ad87..e3dbc4e3 100644 --- a/packages/libsql-client/src/sqlite3.ts +++ b/packages/libsql-client/src/sqlite3.ts @@ -86,6 +86,7 @@ export function _createClient(config: ExpandedConfig): Client { syncPeriod: config.syncInterval, readYourWrites: config.readYourWrites, offline: config.offline, + timeout: config.timeout, }; const db = new Database(path, options); diff --git a/packages/libsql-core/src/api.ts b/packages/libsql-core/src/api.ts index 471eb22b..c0f5c422 100644 --- a/packages/libsql-core/src/api.ts +++ b/packages/libsql-core/src/api.ts @@ -62,6 +62,17 @@ export interface Config { * number to increase the concurrency limit or set it to 0 to disable concurrency limits completely. */ concurrency?: number | undefined; + + /** Busy timeout in milliseconds for local `file:` databases. + * + * When another connection holds a lock on the database file, operations wait up to this many + * milliseconds before failing with `SQLITE_BUSY`. By default, operations fail immediately. + * + * This option is applied to every connection the client opens, including connections created + * internally after `transaction()`. It only takes effect for local SQLite databases; remote + * clients ignore it. + */ + timeout?: number; } /** Representation of integers from database as JavaScript values. See {@link Config.intMode}. */ diff --git a/packages/libsql-core/src/config.ts b/packages/libsql-core/src/config.ts index 31d259de..44dd7bf7 100644 --- a/packages/libsql-core/src/config.ts +++ b/packages/libsql-core/src/config.ts @@ -19,6 +19,7 @@ export interface ExpandedConfig { intMode: IntMode; fetch: Function | undefined; concurrency: number; + timeout: number | undefined; } export type ExpandedScheme = "wss" | "ws" | "https" | "http" | "file"; @@ -180,6 +181,7 @@ export function expandConfig( readYourWrites: config.readYourWrites, offline: config.offline, fetch: config.fetch, + timeout: config.timeout, authToken: undefined, encryptionKey: undefined, remoteEncryptionKey: undefined, @@ -202,5 +204,6 @@ export function expandConfig( readYourWrites: config.readYourWrites, offline: config.offline, fetch: config.fetch, + timeout: config.timeout, }; }