Skip to content

Commit 90639cc

Browse files
committed
Add per-query and default query timeout support
Adds `defaultQueryTimeout` connection option and per-query `{ queryTimeout: ms }` parameter to interrupt long-running queries via sqlite3_interrupt(). Available on exec(), run(), get(), iterate(), and all() in both sync and async APIs. A shared background tokio task manages deadlines with a min-heap. Registering a query returns a TimeoutGuard — if the deadline expires before the guard is dropped, the connection is interrupted. Dropping the guard early (query completed) cancels the timeout and wakes the background task to release the connection reference immediately.
1 parent 99ff1f9 commit 90639cc

File tree

9 files changed

+679
-45
lines changed

9 files changed

+679
-45
lines changed

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ tokio = { version = "1.47.1", features = [ "rt-multi-thread" ] }
1919
tracing = "0.1"
2020
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
2121

22+
[dev-dependencies]
23+
ntest = "0.9"
24+
tokio = { version = "1.47.1", features = ["test-util", "macros"] }
25+
2226
[build-dependencies]
2327
napi-build = "2.0.1"
2428

compat.js

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,26 @@ function convertError(err) {
2828
return err;
2929
}
3030

31+
function isQueryOptions(value) {
32+
return value != null
33+
&& typeof value === "object"
34+
&& !Array.isArray(value)
35+
&& Object.prototype.hasOwnProperty.call(value, "queryTimeout");
36+
}
37+
38+
function splitBindParameters(bindParameters) {
39+
if (bindParameters.length === 0) {
40+
return { params: undefined, queryOptions: undefined };
41+
}
42+
if (bindParameters.length > 1 && isQueryOptions(bindParameters[bindParameters.length - 1])) {
43+
return {
44+
params: bindParameters.length === 2 ? bindParameters[0] : bindParameters.slice(0, -1),
45+
queryOptions: bindParameters[bindParameters.length - 1],
46+
};
47+
}
48+
return { params: bindParameters.length === 1 ? bindParameters[0] : bindParameters, queryOptions: undefined };
49+
}
50+
3151
/**
3252
* Database represents a connection that can prepare and execute SQL statements.
3353
*/
@@ -176,9 +196,9 @@ class Database {
176196
*
177197
* @param {string} sql - The SQL statement string to execute.
178198
*/
179-
exec(sql) {
199+
exec(sql, queryOptions) {
180200
try {
181-
databaseExecSync(this.db, sql);
201+
databaseExecSync(this.db, sql, queryOptions);
182202
} catch (err) {
183203
throw convertError(err);
184204
}
@@ -263,7 +283,8 @@ class Statement {
263283
*/
264284
run(...bindParameters) {
265285
try {
266-
return statementRunSync(this.stmt, ...bindParameters);
286+
const { params, queryOptions } = splitBindParameters(bindParameters);
287+
return statementRunSync(this.stmt, params, queryOptions);
267288
} catch (err) {
268289
throw convertError(err);
269290
}
@@ -276,7 +297,8 @@ class Statement {
276297
*/
277298
get(...bindParameters) {
278299
try {
279-
return statementGetSync(this.stmt, ...bindParameters);
300+
const { params, queryOptions } = splitBindParameters(bindParameters);
301+
return statementGetSync(this.stmt, params, queryOptions);
280302
} catch (err) {
281303
throw convertError(err);
282304
}
@@ -289,7 +311,8 @@ class Statement {
289311
*/
290312
iterate(...bindParameters) {
291313
try {
292-
const it = statementIterateSync(this.stmt, ...bindParameters);
314+
const { params, queryOptions } = splitBindParameters(bindParameters);
315+
const it = statementIterateSync(this.stmt, params, queryOptions);
293316
return {
294317
next: () => iteratorNextSync(it),
295318
[Symbol.iterator]() {

docs/api.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ You can use the `options` parameter to specify various options. Options supporte
2222
- `syncPeriod`: synchronize the database periodically every `syncPeriod` seconds.
2323
- `authToken`: authentication token for the provider URL (optional).
2424
- `timeout`: number of milliseconds to wait on locked database before returning `SQLITE_BUSY` error
25+
- `defaultQueryTimeout`: default maximum number of milliseconds a query is allowed to run before being interrupted with `SQLITE_INTERRUPT` error
2526

2627
The function returns a `Database` object.
2728

@@ -97,13 +98,14 @@ const stmt = db.prepare("SELECT * FROM users");
9798

9899
Loads a SQLite3 extension
99100

100-
### exec(sql) ⇒ this
101+
### exec(sql[, queryOptions]) ⇒ this
101102

102103
Executes a SQL statement.
103104

104105
| Param | Type | Description |
105106
| ------ | ------------------- | ------------------------------------ |
106107
| sql | <code>string</code> | The SQL statement string to execute. |
108+
| queryOptions | <code>object</code> | Optional per-query overrides (for example, `{ queryTimeout: 100 }`). |
107109

108110
### interrupt() ⇒ this
109111

@@ -119,39 +121,43 @@ Closes the database connection.
119121

120122
## Methods
121123

122-
### run([...bindParameters]) ⇒ object
124+
### run([...bindParameters][, queryOptions]) ⇒ object
123125

124126
Executes the SQL statement and returns an info object.
125127

126128
| Param | Type | Description |
127129
| -------------- | ----------------------------- | ------------------------------------------------ |
128130
| bindParameters | <code>array of objects</code> | The bind parameters for executing the statement. |
131+
| queryOptions | <code>object</code> | Optional per-query overrides (for example, `{ queryTimeout: 100 }`). |
129132

130133
The returned info object contains two properties: `changes` that describes the number of modified rows and `info.lastInsertRowid` that represents the `rowid` of the last inserted row.
131134

132-
### get([...bindParameters]) ⇒ row
135+
### get([...bindParameters][, queryOptions]) ⇒ row
133136

134137
Executes the SQL statement and returns the first row.
135138

136139
| Param | Type | Description |
137140
| -------------- | ----------------------------- | ------------------------------------------------ |
138141
| bindParameters | <code>array of objects</code> | The bind parameters for executing the statement. |
142+
| queryOptions | <code>object</code> | Optional per-query overrides (for example, `{ queryTimeout: 100 }`). |
139143

140-
### all([...bindParameters]) ⇒ array of rows
144+
### all([...bindParameters][, queryOptions]) ⇒ array of rows
141145

142146
Executes the SQL statement and returns an array of the resulting rows.
143147

144148
| Param | Type | Description |
145149
| -------------- | ----------------------------- | ------------------------------------------------ |
146150
| bindParameters | <code>array of objects</code> | The bind parameters for executing the statement. |
151+
| queryOptions | <code>object</code> | Optional per-query overrides (for example, `{ queryTimeout: 100 }`). |
147152

148-
### iterate([...bindParameters]) ⇒ iterator
153+
### iterate([...bindParameters][, queryOptions]) ⇒ iterator
149154

150155
Executes the SQL statement and returns an iterator to the resulting rows.
151156

152157
| Param | Type | Description |
153158
| -------------- | ----------------------------- | ------------------------------------------------ |
154159
| bindParameters | <code>array of objects</code> | The bind parameters for executing the statement. |
160+
| queryOptions | <code>object</code> | Optional per-query overrides (for example, `{ queryTimeout: 100 }`). |
155161

156162
### pluck([toggleState]) ⇒ this
157163

index.d.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ export interface Options {
1313
encryptionCipher?: string
1414
encryptionKey?: string
1515
remoteEncryptionKey?: string
16+
defaultQueryTimeout?: number
17+
}
18+
/** Per-query execution options. */
19+
export interface QueryOptions {
20+
queryTimeout?: number
1621
}
1722
export declare function connect(path: string, opts?: Options | undefined | null): Promise<Database>
1823
/** Result of a database sync operation. */
@@ -27,12 +32,12 @@ export declare function databasePrepareSync(db: Database, sql: string): Statemen
2732
/** Syncs the database in blocking mode. */
2833
export declare function databaseSyncSync(db: Database): SyncResult
2934
/** Executes SQL in blocking mode. */
30-
export declare function databaseExecSync(db: Database, sql: string): void
35+
export declare function databaseExecSync(db: Database, sql: string, queryOptions?: QueryOptions | undefined | null): void
3136
/** Gets first row from statement in blocking mode. */
32-
export declare function statementGetSync(stmt: Statement, params?: unknown | undefined | null): unknown
37+
export declare function statementGetSync(stmt: Statement, params?: unknown | undefined | null, queryOptions?: QueryOptions | undefined | null): unknown
3338
/** Runs a statement in blocking mode. */
34-
export declare function statementRunSync(stmt: Statement, params?: unknown | undefined | null): RunResult
35-
export declare function statementIterateSync(stmt: Statement, params?: unknown | undefined | null): RowsIterator
39+
export declare function statementRunSync(stmt: Statement, params?: unknown | undefined | null, queryOptions?: QueryOptions | undefined | null): RunResult
40+
export declare function statementIterateSync(stmt: Statement, params?: unknown | undefined | null, queryOptions?: QueryOptions | undefined | null): RowsIterator
3641
/** SQLite `run()` result object */
3742
export interface RunResult {
3843
changes: number
@@ -116,7 +121,7 @@ export declare class Database {
116121
* * `env` - The environment.
117122
* * `sql` - The SQL statement to execute.
118123
*/
119-
exec(sql: string): Promise<void>
124+
exec(sql: string, queryOptions?: QueryOptions | undefined | null): Promise<void>
120125
/**
121126
* Syncs the database.
122127
*
@@ -153,7 +158,7 @@ export declare class Statement {
153158
*
154159
* * `params` - The parameters to bind to the statement.
155160
*/
156-
run(params?: unknown | undefined | null): RunResult
161+
run(params?: unknown | undefined | null, queryOptions?: QueryOptions | undefined | null): object
157162
/**
158163
* Executes a SQL statement and returns the first row.
159164
*
@@ -162,7 +167,7 @@ export declare class Statement {
162167
* * `env` - The environment.
163168
* * `params` - The parameters to bind to the statement.
164169
*/
165-
get(params?: unknown | undefined | null): object
170+
get(params?: unknown | undefined | null, queryOptions?: QueryOptions | undefined | null): object
166171
/**
167172
* Create an iterator over the rows of a statement.
168173
*
@@ -171,7 +176,7 @@ export declare class Statement {
171176
* * `env` - The environment.
172177
* * `params` - The parameters to bind to the statement.
173178
*/
174-
iterate(params?: unknown | undefined | null): object
179+
iterate(params?: unknown | undefined | null, queryOptions?: QueryOptions | undefined | null): object
175180
raw(raw?: boolean | undefined | null): this
176181
pluck(pluck?: boolean | undefined | null): this
177182
timing(timing?: boolean | undefined | null): this

integration-tests/tests/async.test.js

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,60 @@ test.serial("Timeout option", async (t) => {
398398
fs.unlinkSync(path);
399399
});
400400

401+
test.serial("Query timeout option interrupts long-running query", async (t) => {
402+
const queryTimeout = 100;
403+
const path = genDatabaseFilename();
404+
const [db, errorType] = await connect(path, { defaultQueryTimeout: queryTimeout });
405+
const stmt = await db.prepare(
406+
"WITH RECURSIVE infinite_loop(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM infinite_loop) SELECT * FROM infinite_loop;"
407+
);
408+
409+
await t.throwsAsync(async () => {
410+
await stmt.all();
411+
}, {
412+
instanceOf: errorType,
413+
message: "interrupted",
414+
code: "SQLITE_INTERRUPT",
415+
});
416+
417+
db.close();
418+
fs.unlinkSync(path);
419+
});
420+
421+
test.serial("Query timeout option allows short-running query", async (t) => {
422+
const path = genDatabaseFilename();
423+
const [db] = await connect(path, { defaultQueryTimeout: 100 });
424+
const stmt = await db.prepare("SELECT 1 AS value");
425+
t.deepEqual(await stmt.get(), { value: 1 });
426+
db.close();
427+
fs.unlinkSync(path);
428+
});
429+
430+
test.serial("Per-query timeout option interrupts long-running Statement.all()", async (t) => {
431+
const [db, errorType] = await connect(":memory:");
432+
const stmt = await db.prepare(
433+
"WITH RECURSIVE infinite_loop(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM infinite_loop) SELECT * FROM infinite_loop;"
434+
);
435+
436+
await t.throwsAsync(async () => {
437+
await stmt.all(undefined, { queryTimeout: 100 });
438+
}, {
439+
instanceOf: errorType,
440+
message: "interrupted",
441+
code: "SQLITE_INTERRUPT",
442+
});
443+
444+
db.close();
445+
});
446+
447+
test.serial("Per-query timeout option is accepted by Database.exec()", async (t) => {
448+
const [db] = await connect(":memory:");
449+
await db.exec("SELECT 1", { queryTimeout: 100 });
450+
t.pass();
451+
452+
db.close();
453+
});
454+
401455
test.serial("Concurrent writes over same connection", async (t) => {
402456
const db = t.context.db;
403457
await db.exec(`

integration-tests/tests/sync.test.js

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,79 @@ test.serial("Timeout option", async (t) => {
457457
fs.unlinkSync(path);
458458
});
459459

460+
test.serial("Query timeout option interrupts long-running query", async (t) => {
461+
if (t.context.provider === "sqlite") {
462+
t.assert(true);
463+
return;
464+
}
465+
466+
const path = genDatabaseFilename();
467+
const [db, errorType] = await connect(path, { defaultQueryTimeout: 100 });
468+
const stmt = db.prepare(
469+
"WITH RECURSIVE infinite_loop(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM infinite_loop) SELECT * FROM infinite_loop;"
470+
);
471+
472+
t.throws(() => {
473+
stmt.all();
474+
}, {
475+
instanceOf: errorType,
476+
message: "interrupted",
477+
code: "SQLITE_INTERRUPT",
478+
});
479+
480+
db.close();
481+
fs.unlinkSync(path);
482+
});
483+
484+
test.serial("Query timeout option allows short-running query", async (t) => {
485+
if (t.context.provider === "sqlite") {
486+
t.assert(true);
487+
return;
488+
}
489+
490+
const path = genDatabaseFilename();
491+
const [db] = await connect(path, { defaultQueryTimeout: 100 });
492+
const stmt = db.prepare("SELECT 1 AS value");
493+
t.deepEqual(stmt.get(), { value: 1 });
494+
db.close();
495+
fs.unlinkSync(path);
496+
});
497+
498+
test.serial("Per-query timeout option interrupts long-running Statement.all()", async (t) => {
499+
if (t.context.provider === "sqlite") {
500+
t.assert(true);
501+
return;
502+
}
503+
504+
const [db, errorType] = await connect(":memory:");
505+
const stmt = db.prepare(
506+
"WITH RECURSIVE infinite_loop(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM infinite_loop) SELECT * FROM infinite_loop;"
507+
);
508+
509+
t.throws(() => {
510+
stmt.all(undefined, { queryTimeout: 100 });
511+
}, {
512+
instanceOf: errorType,
513+
message: "interrupted",
514+
code: "SQLITE_INTERRUPT",
515+
});
516+
517+
db.close();
518+
});
519+
520+
test.serial("Per-query timeout option is accepted by Database.exec()", async (t) => {
521+
if (t.context.provider === "sqlite") {
522+
t.assert(true);
523+
return;
524+
}
525+
526+
const [db] = await connect(":memory:");
527+
db.exec("SELECT 1", { queryTimeout: 100 });
528+
t.pass();
529+
530+
db.close();
531+
});
532+
460533
test.serial("Statement.reader [SELECT is true]", async (t) => {
461534
const db = t.context.db;
462535
const stmt = db.prepare("SELECT * FROM users WHERE id = ?");

0 commit comments

Comments
 (0)