Skip to content

Commit eca9a72

Browse files
authored
Expose sqlite3_interrupt() as db.interrupt() — closes #406 (#407)
2 parents dff1c04 + 7ba0a9a commit eca9a72

7 files changed

Lines changed: 156 additions & 1 deletion

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Some of the big supported features:
2525
- Custom tokenizers
2626
- Load runtime extensions
2727
- JSONB support
28+
- Native query interruption via `db.interrupt()`
2829

2930
It also contains a simple [Key-Value store](https://op-engineering.github.io/op-sqlite/docs/key_value_storage) you can use without adding one more dependency to your app.
3031

cpp/DBHostObject.cpp

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,12 @@ void DBHostObject::create_jsi_functions(jsi::Runtime &rt) {
294294

295295
function_map["close"] = HFN(this) {
296296
invalidated = true;
297+
// Abort pending native SQLite work before waiting on the thread pool.
298+
#if !defined(OP_SQLITE_USE_LIBSQL) && !defined(OP_SQLITE_USE_TURSO)
299+
if (db != nullptr) {
300+
sqlite3_interrupt(db);
301+
}
302+
#endif
297303
// Drain any in-flight async queries before closing the db handle.
298304
// Without this, a queued/running execute() on the thread pool may
299305
// dereference the freed sqlite3* pointer → heap corruption / SIGABRT.
@@ -309,12 +315,39 @@ void DBHostObject::create_jsi_functions(jsi::Runtime &rt) {
309315
return {};
310316
});
311317

318+
function_map["interrupt"] = HFN(this) {
319+
if (invalidated) {
320+
throw std::runtime_error("[op-sqlite][interrupt] database is closed");
321+
}
322+
323+
#ifdef OP_SQLITE_USE_LIBSQL
324+
throw std::runtime_error("[op-sqlite][interrupt] sqlite3_interrupt is not "
325+
"supported with libsql");
326+
#elif defined(OP_SQLITE_USE_TURSO)
327+
throw std::runtime_error("[op-sqlite][interrupt] sqlite3_interrupt is not "
328+
"supported with Turso");
329+
#else
330+
if (db == nullptr) {
331+
throw std::runtime_error("[op-sqlite][interrupt] database is null");
332+
}
333+
334+
sqlite3_interrupt(db);
335+
return {};
336+
#endif
337+
});
338+
312339
function_map["delete"] = HFN(this) {
313340
if (count != 0) {
314341
throw std::runtime_error("[op-sqlite] Delete no longer takes arguments");
315342
}
316343

317344
invalidated = true;
345+
// Abort pending native SQLite work before waiting on the thread pool.
346+
#if !defined(OP_SQLITE_USE_LIBSQL) && !defined(OP_SQLITE_USE_TURSO)
347+
if (db != nullptr) {
348+
sqlite3_interrupt(db);
349+
}
350+
#endif
318351
// Drain any in-flight async queries before closing/removing the db handle.
319352
// Without this, queued/running work may dereference a freed sqlite handle.
320353
thread_pool->waitFinished();

docs/docs/api.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,28 @@ On web, `execute()` runs the full SQL string passed to it.
120120
On native, `execute()` currently runs only the first prepared statement.
121121
If you need identical behavior across platforms, avoid multi-statement SQL strings.
122122

123+
## Interrupting a Query
124+
125+
On native, `interrupt()` aborts any pending database operation on this connection. It is safe to call from a thread different from the one running the operation. The interrupted query returns `SQLITE_INTERRUPT`; any in-flight transaction is rolled back. This calls SQLite's native [`sqlite3_interrupt()`](https://sqlite.org/c3ref/interrupt.html).
126+
127+
`interrupt()` is not available when op-sqlite is built with the `libsql` or `turso` backend.
128+
129+
```tsx
130+
const query = db.execute(longRunningQuery);
131+
132+
setTimeout(() => {
133+
db.interrupt();
134+
}, 100);
135+
136+
try {
137+
await query;
138+
} catch (error) {
139+
// SQLITE_INTERRUPT
140+
}
141+
```
142+
143+
On web, `interrupt()` is not supported.
144+
123145
### Execute with Host Objects
124146

125147
It’s possible to return HostObjects when using a query. The benefit is that HostObjects are only created in C++ and only when you try to access a value inside of them a C++ value → JS value conversion happens. This means creation is fast, property access is slow. The use case is clear if you are returning **massive** amount of objects but only displaying/accessing a few of them at the time.

example/src/tests/queries.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
expect,
1515
it,
1616
} from "@op-engineering/op-test";
17-
import { chance } from "./utils";
17+
import { chance, sleep } from "./utils";
1818

1919
// import pkg from '../../package.json'
2020

@@ -107,6 +107,91 @@ describe("Queries tests", () => {
107107
}
108108
});
109109

110+
it("interrupt is safe to call with no in-flight query", () => {
111+
if (isLibsql() || isTurso()) {
112+
return;
113+
}
114+
115+
let threw = false;
116+
try {
117+
db.interrupt();
118+
} catch (_e) {
119+
threw = true;
120+
}
121+
122+
expect(threw).toEqual(false);
123+
});
124+
125+
it("interrupt aborts an in-flight query and rolls back the transaction", async () => {
126+
if (isLibsql() || isTurso()) {
127+
return;
128+
}
129+
130+
await db.execute("DROP TABLE IF EXISTS InterruptTest;");
131+
await db.execute("CREATE TABLE InterruptTest (n INTEGER);");
132+
133+
const longQuery = `
134+
WITH RECURSIVE seq(n) AS (
135+
SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 100000000
136+
)
137+
INSERT INTO InterruptTest SELECT n FROM seq;
138+
`;
139+
140+
const queryPromise = db.execute(longQuery);
141+
142+
await sleep(50);
143+
db.interrupt();
144+
145+
let interrupted = false;
146+
try {
147+
await queryPromise;
148+
} catch (e: any) {
149+
interrupted = /interrupt|interrupted|abort|code 9|SQLITE_INTERRUPT/i.test(
150+
String(e?.message ?? e),
151+
);
152+
}
153+
154+
expect(interrupted).toEqual(true);
155+
156+
const count = await db.execute("SELECT COUNT(*) AS n FROM InterruptTest;");
157+
expect(count.rows[0]!.n).toEqual(0);
158+
});
159+
160+
it("close interrupts an in-flight query before teardown", async () => {
161+
if (isLibsql() || isTurso()) {
162+
return;
163+
}
164+
165+
await db.execute("DROP TABLE IF EXISTS CloseInterruptTest;");
166+
await db.execute("CREATE TABLE CloseInterruptTest (n INTEGER);");
167+
168+
const longQuery = `
169+
WITH RECURSIVE seq(n) AS (
170+
SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 100000000
171+
)
172+
INSERT INTO CloseInterruptTest SELECT n FROM seq;
173+
`;
174+
175+
const queryPromise = db.execute(longQuery);
176+
177+
await sleep(50);
178+
const startedAt = Date.now();
179+
db.close();
180+
const elapsedMs = Date.now() - startedAt;
181+
182+
await queryPromise.catch(() => undefined);
183+
expect(elapsedMs < 2000).toEqual(true);
184+
185+
const cleanupDb = open({
186+
name: "queries.sqlite",
187+
encryptionKey: "test",
188+
});
189+
cleanupDb.delete();
190+
191+
// @ts-expect-error Prevent afterEach from deleting a closed handle.
192+
db = null;
193+
});
194+
110195
it("executeSync", () => {
111196
const res = db.executeSync("SELECT 1");
112197
expect(res.rowsAffected).toEqual(0);

src/functions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ function enhanceDB(db: _InternalDB, options: DBParams): DB {
8585
setReservedBytes: db.setReservedBytes,
8686
getReservedBytes: db.getReservedBytes,
8787
close: db.close,
88+
interrupt: db.interrupt,
8889
closeAsync: async () => {
8990
db.close();
9091
},

src/functions.web.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ function enhanceWebDb(
188188
closeAsync: async () => {
189189
await db.closeAsync?.();
190190
},
191+
interrupt: unsupported("interrupt"),
191192
delete: unsupported("delete"),
192193
attach: unsupported("attach"),
193194
detach: unsupported("detach"),
@@ -352,6 +353,9 @@ async function createWebDb(params: {
352353
dbId,
353354
});
354355
},
356+
interrupt: () => {
357+
throwSyncApiError("interrupt");
358+
},
355359
delete: () => {
356360
throwSyncApiError("delete");
357361
},

src/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ export type PreparedStatement = {
102102
export type _InternalDB = {
103103
close: () => void;
104104
closeAsync?: () => Promise<void>;
105+
interrupt: () => void;
105106
delete: () => void;
106107
attach: (params: {
107108
secondaryDbFileName: string;
@@ -153,6 +154,14 @@ export type _InternalDB = {
153154
export type DB = {
154155
close: () => void;
155156
closeAsync: () => Promise<void>;
157+
/**
158+
* Aborts any pending database operation on this connection.
159+
*
160+
* Calls SQLite's native sqlite3_interrupt(). Safe to call from a thread
161+
* different from the one running the operation. An interrupted operation
162+
* returns SQLITE_INTERRUPT and any in-flight transaction is rolled back.
163+
*/
164+
interrupt: () => void;
156165
delete: () => void;
157166
attach: (params: {
158167
secondaryDbFileName: string;

0 commit comments

Comments
 (0)