Skip to content

Commit 2dcb08a

Browse files
committed
Add query timeout option to interrupt long-running queries
A single background task with a min-heap manages all query deadlines efficiently. When a query starts, a TimeoutGuard is acquired; if the deadline expires before the guard is dropped, the connection is interrupted via sqlite3_interrupt().
1 parent 99ff1f9 commit 2dcb08a

8 files changed

Lines changed: 469 additions & 35 deletions

File tree

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+
- `queryTimeout`: 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+
queryTimeout?: 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): RunResult
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: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,56 @@ 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 [db, errorType] = await connect(":memory:", { queryTimeout });
404+
const stmt = await db.prepare(
405+
"WITH RECURSIVE infinite_loop(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM infinite_loop) SELECT * FROM infinite_loop;"
406+
);
407+
408+
await t.throwsAsync(async () => {
409+
await stmt.all();
410+
}, {
411+
instanceOf: errorType,
412+
message: "interrupted",
413+
code: "SQLITE_INTERRUPT",
414+
});
415+
416+
db.close();
417+
});
418+
419+
test.serial("Query timeout option allows short-running query", async (t) => {
420+
const [db] = await connect(":memory:", { queryTimeout: 100 });
421+
const stmt = await db.prepare("SELECT 1 AS value");
422+
t.deepEqual(await stmt.get(), { value: 1 });
423+
db.close();
424+
});
425+
426+
test.serial("Per-query timeout option interrupts long-running Statement.all()", async (t) => {
427+
const [db, errorType] = await connect(":memory:");
428+
const stmt = await db.prepare(
429+
"WITH RECURSIVE infinite_loop(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM infinite_loop) SELECT * FROM infinite_loop;"
430+
);
431+
432+
await t.throwsAsync(async () => {
433+
await stmt.all(undefined, { queryTimeout: 100 });
434+
}, {
435+
instanceOf: errorType,
436+
message: "interrupted",
437+
code: "SQLITE_INTERRUPT",
438+
});
439+
440+
db.close();
441+
});
442+
443+
test.serial("Per-query timeout option is accepted by Database.exec()", async (t) => {
444+
const [db] = await connect(":memory:");
445+
await db.exec("SELECT 1", { queryTimeout: 100 });
446+
t.pass();
447+
448+
db.close();
449+
});
450+
401451
test.serial("Concurrent writes over same connection", async (t) => {
402452
const db = t.context.db;
403453
await db.exec(`

integration-tests/tests/sync.test.js

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,75 @@ 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 [db, errorType] = await connect(":memory:", { queryTimeout: 100 });
467+
const stmt = db.prepare(
468+
"WITH RECURSIVE infinite_loop(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM infinite_loop) SELECT * FROM infinite_loop;"
469+
);
470+
471+
t.throws(() => {
472+
stmt.all();
473+
}, {
474+
instanceOf: errorType,
475+
message: "interrupted",
476+
code: "SQLITE_INTERRUPT",
477+
});
478+
479+
db.close();
480+
});
481+
482+
test.serial("Query timeout option allows short-running query", async (t) => {
483+
if (t.context.provider === "sqlite") {
484+
t.assert(true);
485+
return;
486+
}
487+
488+
const [db] = await connect(":memory:", { queryTimeout: 100 });
489+
const stmt = db.prepare("SELECT 1 AS value");
490+
t.deepEqual(stmt.get(), { value: 1 });
491+
db.close();
492+
});
493+
494+
test.serial("Per-query timeout option interrupts long-running Statement.all()", async (t) => {
495+
if (t.context.provider === "sqlite") {
496+
t.assert(true);
497+
return;
498+
}
499+
500+
const [db, errorType] = await connect(":memory:");
501+
const stmt = db.prepare(
502+
"WITH RECURSIVE infinite_loop(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM infinite_loop) SELECT * FROM infinite_loop;"
503+
);
504+
505+
t.throws(() => {
506+
stmt.all(undefined, { queryTimeout: 100 });
507+
}, {
508+
instanceOf: errorType,
509+
message: "interrupted",
510+
code: "SQLITE_INTERRUPT",
511+
});
512+
513+
db.close();
514+
});
515+
516+
test.serial("Per-query timeout option is accepted by Database.exec()", async (t) => {
517+
if (t.context.provider === "sqlite") {
518+
t.assert(true);
519+
return;
520+
}
521+
522+
const [db] = await connect(":memory:");
523+
db.exec("SELECT 1", { queryTimeout: 100 });
524+
t.pass();
525+
526+
db.close();
527+
});
528+
460529
test.serial("Statement.reader [SELECT is true]", async (t) => {
461530
const db = t.context.db;
462531
const stmt = db.prepare("SELECT * FROM users WHERE id = ?");

0 commit comments

Comments
 (0)