feat: per-query timeout that cancels over-running queries (#22)#177
feat: per-query timeout that cancels over-running queries (#22)#177eugene-mobile wants to merge 1 commit into
Conversation
Long-running queries previously couldn't be cancelled. This adds a
user-configurable per-query timeout, surfaced in the editor toolbar and
persisted in localStorage (default 30s, "No timeout" available).
The client sends `timeoutMs` with each /api/query request. When the
deadline passes, each driver cancels the statement on the server using
its idiomatic mechanism and rejects with a `transient` DriverError whose
message ("Query cancelled: exceeded the Ns timeout.") shows up in the
results error banner:
- MySQL: KILL QUERY <threadId> on a separate pooled connection
- Postgres: SELECT pg_cancel_backend(<pid>) on a separate pooled client
- MongoDB: maxTimeMS on the read operations (find/findOne/aggregate/count)
On timeout the SQL drivers discard the connection (mysql2 destroy /
pg release(true)) instead of returning it to the pool, since the KILLed
statement is still draining on that socket and a reused connection could
hand the next caller a half-read result set.
A shared withQueryTimeout helper races the work promise against the
deadline; Promise.race keeps the late statement rejection consumed so it
never surfaces as an unhandled rejection.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@claude review this PR |
|
Claude finished @eugene-mobile's task in 2m 21s —— View job PR Review: feat/query-timeout
Overall, the timeout mechanism itself is solid and well-thought-out. However, the diff contains several unintended changes that appear to be rebase artifacts — most critically, the deletion of all MongoDB MCP tools and their 305-line test suite. These need to be addressed before this merges. Critical: Unintended MCP regression
This looks like a bad rebase — the PR branch appears not to include commits from the existing Major: Two unrelated behavioral regressions
Single-click on a table now fires
- const lineNumsRef = useRef<HTMLDivElement>(null);
...
- onScroll={e => {
- if (lineNumsRef.current) lineNumsRef.current.scrollTop = e.currentTarget.scrollTop;
- }}Removing Version downgrade ( Timeout implementation: correctness reviewThe core timeout logic is well-designed. A few notes:
return Promise.race([work, deadline]).finally(() => clearTimeout(timer));The
await conn.query(`KILL QUERY ${threadId}`); // killQuery:181
const pid = (client as { processID?: number }).processID;The
const timeoutMs = typeof rawTimeout === 'number' && Number.isFinite(rawTimeout) && rawTimeout > 0
? rawTimeout
: undefined;Correctly rejects Frontend: minor notes
useState<number>(readStoredQueryTimeout) // no () — this is `useState(fn)`, lazy initializerActually this is correct — React's
Summary
Recommendation: Fix the rebase (likely |
Problem
Closes #22. Long-running queries couldn't be cancelled — once a slow query was running there was no way to abort it from the UI.
What this does
Adds a user-configurable per-query timeout, shown as a compact picker in the query editor toolbar (No timeout / 10s / 30s / 1m / 5m) and persisted in
localStorage(default 30s).The client sends
timeoutMswith each/api/queryrequest. When the deadline passes, the driver cancels the statement on the server using its idiomatic mechanism, and rejects with atransientDriverErrorwhose message —Query cancelled: exceeded the Ns timeout.— surfaces in the existing results error banner (HTTP 503).KILL QUERY <threadId>on a separate pooled connectionSELECT pg_cancel_backend(<pid>)on a separate pooled clientmaxTimeMSon the read ops (find/findOne/aggregate/count)A separate connection is used for the SQL cancel because the one running the timed-out query is busy.
Connection hygiene on timeout
On timeout the SQL drivers discard the connection (
mysql2destroy()/pgrelease(true)) rather than returning it to the pool: theKILLed/cancelled statement is still draining on that socket, and a reused connection could hand the next caller a half-read result set. The pool opens a fresh connection on the next request.No unhandled rejections
A shared
withQueryTimeouthelper races the work promise against the deadline.Promise.racekeeps a handler attached to the work promise, so the statement's eventual rejection (arriving just after the cancel) is consumed and never surfaces as an unhandled rejection. A falsy/zero/negative timeout disables the deadline entirely, so internal callers (insert/update/delete/explain) are unaffected.Scope
The timeout applies only to the user-facing
/api/querypath. Internal CRUD/DDL routes call the drivers without a timeout (unchanged 3-arg behavior).Testing
server/src/drivers/timeout.test.ts(13 tests): label/error formatting, work-wins, no-timeout passthrough, work-rejection passthrough, deadline-wins firesonTimeout+ transient error, and the late-rejection-is-not-unhandled case (fake timers).timeoutMsforwarding toquery/queryAll, disarming on absent/0/negative/non-numeric, and 503 mapping for a transient timeout error.tsc+ production build clean.Note
I could not run a full live end-to-end check (slow query → cancel) — the backend on this machine had no active DB connection and the browser-automation extension wasn't connected. The logic is covered by unit tests; a manual
SELECT SLEEP(10)with a 5s timeout against a real MySQL/Postgres is worth a smoke test before release.🤖 Generated with Claude Code