-
-
Notifications
You must be signed in to change notification settings - Fork 0
🔒 Implement Query Timeout in WASM Worker #47
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -34,6 +34,7 @@ import { applyMergePatch } from './json-utils'; | |
| interface WasmDatabaseInstance { | ||
| exec(sql: string, params?: unknown[]): Array<{ columns: string[]; values: unknown[][] }>; | ||
| prepare(sql: string, params?: unknown[]): any; | ||
| iterateStatements(sql: string): IterableIterator<any>; | ||
| export(): Uint8Array; | ||
| close(): void; | ||
| } | ||
|
|
@@ -57,10 +58,12 @@ interface WasmEngineModule { | |
| */ | ||
| class WasmDatabaseEngine implements DatabaseOperations { | ||
| private readonly instance: WasmDatabaseInstance; | ||
| private readonly queryTimeout: number; | ||
| readonly engineKind = Promise.resolve('wasm' as const); | ||
|
|
||
| constructor(instance: WasmDatabaseInstance) { | ||
| constructor(instance: WasmDatabaseInstance, timeoutMs: number) { | ||
| this.instance = instance; | ||
| this.queryTimeout = timeoutMs; | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -74,20 +77,59 @@ class WasmDatabaseEngine implements DatabaseOperations { | |
| * @returns Array of result sets in sql.js format | ||
| */ | ||
| async executeQuery(sql: string, params?: CellValue[]): Promise<QueryResultSet[]> { | ||
| const startTime = Date.now(); | ||
| const results: QueryResultSet[] = []; | ||
| let currentStmt: any = null; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| try { | ||
| const rawResults = this.instance.exec(sql, params); | ||
| // Return in sql.js compatible format for webview compatibility | ||
| return rawResults.map(resultSet => ({ | ||
| columns: resultSet.columns, | ||
| values: resultSet.values as CellValue[][], | ||
| // Also provide our new format for internal use | ||
| headers: resultSet.columns, | ||
| rows: resultSet.values as CellValue[][] | ||
| })) as QueryResultSet[]; | ||
| const iterator = this.instance.iterateStatements(sql); | ||
| let isFirstStatement = true; | ||
|
|
||
| for (const stmt of iterator) { | ||
| currentStmt = stmt; | ||
|
|
||
| // Bind parameters only to the first statement to match exec behavior | ||
| if (isFirstStatement && params && params.length > 0) { | ||
| stmt.bind(params); | ||
| } | ||
| isFirstStatement = false; | ||
|
|
||
| const rows: CellValue[][] = []; | ||
|
|
||
| while (stmt.step()) { | ||
| // Check timeout | ||
| if (Date.now() - startTime > this.queryTimeout) { | ||
| stmt.free(); // Free current statement to prevent leaks | ||
| throw new Error(`Query execution timed out after ${this.queryTimeout}ms`); | ||
|
Comment on lines
+102
to
+103
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There's a potential for a double-free issue here. When a timeout occurs, By setting stmt.free(); // Free current statement to prevent leaks
currentStmt = null; // Avoid double-free in the catch block
throw new Error(`Query execution timed out after ${this.queryTimeout}ms`); |
||
| } | ||
| rows.push(stmt.get()); | ||
| } | ||
|
|
||
| const columns = stmt.getColumnNames(); | ||
| // Only include results that have columns (matching exec behavior) | ||
| if (columns.length > 0) { | ||
| results.push({ | ||
| columns, | ||
| values: rows, | ||
| headers: columns, | ||
| rows | ||
| }); | ||
| } | ||
|
|
||
| // iterateStatements handles freeing the statement when we proceed to next or finish | ||
| // but we clear our reference so we don't try to free it in catch block | ||
| currentStmt = null; | ||
| } | ||
| } catch (err) { | ||
| // Ensure current statement is freed if iteration was interrupted by error/timeout | ||
| if (currentStmt) { | ||
| try { currentStmt.free(); } catch {} | ||
| } | ||
| const errorDetail = err instanceof Error ? err.message : String(err); | ||
| throw new Error(`Query failed: ${errorDetail}`); | ||
| } | ||
|
|
||
| return results; | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -777,7 +819,7 @@ export async function createDatabaseEngine( | |
| wasmInstance = new SqlJsModule.Database(); | ||
| } | ||
|
|
||
| const engine = new WasmDatabaseEngine(wasmInstance); | ||
| const engine = new WasmDatabaseEngine(wasmInstance, config.queryTimeout || 50000); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The default query timeout const DEFAULT_QUERY_TIMEOUT_MS = 50000;
const engine = new WasmDatabaseEngine(wasmInstance, config.queryTimeout || DEFAULT_QUERY_TIMEOUT_MS); |
||
|
|
||
| return { | ||
| operations: engine, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
|
|
||
| import { test } from 'node:test'; | ||
| import assert from 'node:assert'; | ||
| import { createDatabaseEngine } from '../../src/core/sqlite-db'; | ||
|
|
||
| test('WasmDatabaseEngine timeout test', async (t) => { | ||
| await t.test('should timeout on long running query', async () => { | ||
| const { operations } = await createDatabaseEngine({ | ||
| content: null, | ||
| maxSize: 0, | ||
| queryTimeout: 100 // 100ms timeout | ||
| }); | ||
|
|
||
| // Recursive CTE that generates infinite rows | ||
| const sql = ` | ||
| WITH RECURSIVE cnt(x) AS ( | ||
| SELECT 1 | ||
| UNION ALL | ||
| SELECT x+1 FROM cnt | ||
| ) | ||
| SELECT x FROM cnt; | ||
| `; | ||
|
|
||
| await assert.rejects(async () => { | ||
| await operations.executeQuery(sql); | ||
| }, { | ||
| message: /Query execution timed out after 100ms/ | ||
| }); | ||
| }); | ||
|
|
||
| await t.test('should run fast query successfully', async () => { | ||
| const { operations } = await createDatabaseEngine({ | ||
| content: null, | ||
| maxSize: 0, | ||
| queryTimeout: 100 | ||
| }); | ||
|
|
||
| const result = await operations.executeQuery("SELECT 1 as val"); | ||
| assert.strictEqual(result.length, 1); | ||
| assert.strictEqual(result[0].rows[0][0], 1); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For better type safety and maintainability, you can use the
Statementtype fromsql.jsinstead ofany. This will allow TypeScript to correctly infer types for variables that use this iterator, such asstmtin theexecuteQuerymethod.