feat(server): add --statement-timeout to bound wedged statements - #1194
Conversation
Nothing in the stack bounds a single statement today. DuckDB has no statement timeout, a client `SET statement_timeout` is an ignored SET (see transform.SetShowTransform's IgnoredParams), the cnpg shards run idle_in_transaction_session_timeout=0, and pgwire clients wait forever. On 2026-09-16 one DuckLake worker deadlocked in-process mid-INSERT (duckdb/duckdb#24961 class: all threads park in futex, CPU->0, interrupt is never honored). The statement ran 19,994,187 ms -- 5h33m -- and ended only when the tenant's CI job hit its own 6h wall. Because their refresh workflow uses a concurrency group with cancel-in-progress:false, five consecutive hourly refreshes queued behind it and were cancelled. Add an opt-in server-side bound. `--statement-timeout` / `DUCKGRES_STATEMENT_TIMEOUT` / `statement_timeout:` in YAML; 0 (the default) keeps today's unbounded behaviour, so this changes nothing until a deployment sets it. The deadline is applied in queryContextInner, which is the single point every execution path -- simple, batched, extended, COPY, cursor -- passes through on its way to an engine. It rides on the STATEMENT context, never the connection context: a timeout must end one statement, not the session. That placement is also why the classification needed care. The connection context stays healthy when a statement deadline fires, so c.ctx.Err() is nil and isCallerCancellation alone would have misclassified a timeout as an infra failure and surfaced it as a bare 42000 carrying "context deadline exceeded". statementTimedOut() answers from the statement context and is checked first at every 57014 site. The message is PostgreSQL's exact wording, "canceling statement due to statement timeout", because drivers string-match it to decide whether a failure is retryable; a user cancel keeps "due to user request". isQueryCancelled also had to learn "context deadline exceeded" -- it previously matched only "context canceled". This is blast-radius containment, not a cure. An engine that ignores cancellation will not stop working when this fires: the client is freed with a clear error, but the worker can stay wedged. Retiring a timed-out worker is a follow-up and has to respect the destroy-before-reuse ordering in SessionManager.DestroySession. Covered by server/statement_timeout_test.go. Not asserted in the mw-dev e2e -- the knob is server-global, so an in-Job assertion would either kill the harness's own legitimately slow assertions or burn minutes on a deliberately slow query; reasoned in tests/mw-dev/README.md. Also adds docs/plans/2026-09-16-warehouse-reliability-plan.md recording the three root causes found this session and what remains for each. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Test Impact PlanDeterministic summary of how this PR changes tests, CI runners, and coverage-risk signals. Summary
Signals
Coverage risk: neutral or increased No coverage-reduction warnings detected. |
…flag TestRegisterCLIInputsFlagsCoversEveryCLIBackedField is a two-way tripwire: every CLIInputs field must have a registered flag, and every registered flag must map back to a field. The field->flag derivation is an explicit rewrite table, so a multi-word field without an entry resolves to the squashed form (StatementTimeout -> --statementtimeout) and fails both directions at once. Add the entry next to SessionInitTimeout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ABdifVHfx9awhA9PjmFjjC
Self-review catch. queryContextForCursor takes ONE context at DECLARE and holds it across every FETCH, so the timeout bounds a cursor's whole lifetime rather than each FETCH -- stricter than PostgreSQL, where each FETCH is its own statement. That is the desirable containment (an unbounded cursor also pins a worker), but it is a real semantic difference an operator must know before choosing a value, so document it and pin it with a test rather than leave it to be rediscovered. Also wrap the README flag line like the other long flags. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ABdifVHfx9awhA9PjmFjjC
bill-ph
left a comment
There was a problem hiding this comment.
Found one P0 blocker: the new timeout context is not applied to extended-protocol non-cursor statements. In server/conn_extended_query.go, handleExecute creates queryCtx only for tracing and then calls c.executor.Exec(...) / c.executor.Query(...) (the executor implementations use background contexts); there is no queryContext()/queryContextInner() call on this path. Thus prepared/bound statements can still run indefinitely, even though the PR promises the timeout covers simple, batched, extended, COPY, and cursor execution. Please create/use a statement context for extended Execute (and pass it through the context-aware executor methods), then classify the resulting timeout with the PostgreSQL timeout wording.
— Robo Bill
benben
left a comment
There was a problem hiding this comment.
Automated review generated on behalf of @benben.
Approve. Adds an opt-in server-global per-statement deadline on the context built in queryContextInner and teaches the 57014 classification to recognize it. The knob is correctly wired through config for standalone and control plane, but the extended-protocol Execute/Describe paths never go through queryContextInner and use context-less executor calls, so the timeout does not reach them, and the sticky stmtCtx plus many hardcoded "due to user request" strings make the classification claims only partially true.
- major
server/conn_extended_query.go:926Extended-protocol Execute/Describe never get the statement deadline. handleExecute and the Describe probes call context-less executor.Query/Exec and never call queryContext(), so JDBC/pgx/psycopg3 statements stay unbounded despite the PR's every-path claim. - major
server/conn.go:538Sticky timed-out stmtCtx misclassifies later unrelated errors as timeouts. A simple-protocol statement times out, then an extended Execute (which never overwrites stmtCtx) hits any rows.Err(); line 1085/1171 and conn_results.go report 57014 timeout and skip logging. - major
server/conn.go:1885Most 57014 sites still hardcode 'due to user request' on timeout. A timed-out simple-protocol INSERT (the incident shape) surfaces the user-request wording here and at conn_query_exec.go:73/249/365/868/920, conn_extended_query.go:902/970, conn_cursor.go:309/384/450/509. - minor
server/conn_cursor.go:382Cursor lifetime timeout classifies as 42000 after another statement ran. DECLARE ctx expires but stmtCtx points at a later statement, so isCallerCancellation is false and the client gets a bare 'context deadline exceeded'. - minor
server/conn_errors.go:26isQueryCancelled now maps every deadline error to 57014 even with timeout=0. Internal execTimeout/attach deadlines or worker-side gRPC DeadlineExceeded now surface as 57014 and count as cancelled in query_metrics.go:187. - minor
server/statement_timeout_test.go:20Tests never drive a real execution path; extended gap passes green. All tests call queryContextInner directly; none executes a statement through handleExecute or executeSelectQuery with a deadline. - nit
tests/mw-dev/README.md:529New bullet inserted mid-sentence inside the PostHog analytics bullet. The preceding bullet's text 'to read them back, so ingestion cannot...' now dangles after the new paragraph.
Model: fable, PR authored with opus.
…y error Addresses the review on #1194. Two of the findings were design errors, not gaps. P0 -- extended-protocol Execute never received the deadline. handleExecute called the context-LESS executor.Query/Exec, which run on context.Background() (see PinnedExecutor), so prepared statements -- what pgx, psycopg3 and JDBC send, and almost certainly the sqlmesh INSERT that motivated this work -- were unbounded. The PR claimed queryContextInner was "the single point every execution path passes through"; that came from an existing comment about cancellation registration, and I did not check the call graph. It was wrong. The gap was wider than the timeout: RegisterQuery is ONLY called from queryContextInner, so extended-protocol statements were not cancellable by a pgwire CancelRequest either. Routing this path through queryContextInner fixes both. Regression test drives a real Parse/Bind/Execute and asserts the executor saw a deadline; it fails without the fix. Two consequences handled rather than discovered later: - queryContextInner(false), no disconnect monitor. The monitor's bufio Peek would race the message loop's own reads -- the extended protocol pipelines Parse/Bind/Execute/Sync, so the next message's bytes are often already buffered. Same reasoning the cursor path documents. - A SUSPENDED portal keeps its RowSet open for the next Execute, so cancelling at the opening Execute's return tore down the rowset the next one resumes from (caught by TestPortalSuspensionPaging). Ownership of the release moves to the portal and closeExec runs it, mirroring how finishProfiling is already retained across legs. Classification is now driven by the ERROR and gated on the timeout being configured, replacing the stored statement context. The stored context was sticky: once any statement timed out, a later unrelated failure on a path that did not overwrite the field was reported as a timeout and its logging skipped. The gate also restores byte-for-byte behaviour when the feature is off -- the previous version widened isQueryCancelled so every deadline (attach, exec, worker gRPC) became 57014 even for deployments that never enable this. Also: 13 remaining 57014 sites hardcoded "due to user request", so a timed-out simple INSERT -- the incident shape -- reported the wrong wording; they now use cancellationMessage(err). And the tests/mw-dev README bullet was inserted mid-sentence, orphaning the preceding bullet's text. Extended Execute now emits a QueryStart query-log entry (markExecStarted), which it did not before. That is the intended consequence of becoming cancellable; three fixtures that read the first entry off the channel, and three that recorded on the context-less methods, were updated to match. Full go test ./... matches the clean-tree baseline exactly (the three remaining failures -- cache-proxy, opa, tests/integration -- reproduce with these changes stashed). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ABdifVHfx9awhA9PjmFjjC
|
Thanks — the P0 was real, and two of the findings were design errors on my part rather than gaps. Pushed P0 — extended Execute never got the deadline. Confirmed: The gap was wider than the timeout — prepared statements weren't reachable by a pgwire Two consequences I handled rather than leave to be found later:
Sticky
Hardcoded wording (major) — fixed at all 13 sites via Tests never drove a real path (minor) — agreed, and it's exactly why the extended gap passed green. The new test goes through the real protocol handlers. README nit — fixed, the bullet had orphaned the preceding sentence. One behaviour change worth flagging: extended Execute now emits a Full |
bill-ph
left a comment
There was a problem hiding this comment.
Re-reviewed at commit f59661c. The follow-up correctly routes extended Execute through queryContextInner(false) and ExecContext/QueryContext, keeps suspended portal ownership of the statement cleanup, and adds a regression test for the previously unbounded prepared-statement path. No remaining P0 blockers found.
Non-blocking note: the extended Describe metadata probes still use context-less executor calls, so a pathological Describe can outlive the configured statement timeout; consider covering those probes if the timeout is intended to bound planning/metadata work too.
— Robo Bill
benben
left a comment
There was a problem hiding this comment.
Automated review generated on behalf of @benben.
Approve (re-review of new commits). The new commit routes extended-protocol Execute through queryContextInner with portal-owned release, and rewrites timeout classification to be error-driven and feature-gated; both hold up, but the newly documented "every path" invariant is still violated by the writable-CTE rewrite and COPY paths, and the suspended-portal ownership collides with the single-slot cancel registry.
Extended Execute deadline, sticky stmtCtx, hardcoded wording and the README bullet are fixed; the Describe probes (conn_extended_query.go:402/552) still call context-less executor.Query.
- major
server/conn_query_exec.go:1180Writable-CTE rewrite and COPY paths still run context-less, timeout unreachable. An extended-protocol INSERT rewritten via WITH-CTE (executeMultiStatementExtended) or a COPY TO (conn_copy.go:298/377) calls executor.Exec/Query without stmtCtx, contradicting the new CLAUDE.md invariant. - minor
server/conn_extended_query.go:880closeCursorsAtTxEnd unregisters the in-flight statement's cancel entry. Extended COMMIT/ROLLBACK with a suspended portal open: closeExec→releaseStmtCtx→UnregisterQuery deletes the COMMIT's own single-slot registration, so a CancelRequest during it finds nothing; likewise any later statement clobbers a suspended portal's registration. - minor
server/conn_extended_query.go:871Suspended-portal deadline spans all Execute legs; undocumented like cursors. JDBC/pgx fetchSize paging whose client-side processing exceeds the timeout gets 57014 on the next Execute while the server is idle; CLAUDE.md documents this only for DECLARE cursors. - nit
server/conn_errors.go:85Duplicate statementTimedOut check is dead code. Already returned true at line 77. - nit
server/conn_extended_query.go:865Extended Execute now emits QueryStart entries; query-log volume change undocumented. markExecStarted inside queryContextInner adds a QueryStart row per prepared statement and moves post-start failures to ExceptionWhileProcessing; not mentioned in PR description.
Model: fable, PR authored with opus.
… rewrite paths (#1197) Follow-up to #1194, which wired the timeout through extended-protocol Execute. Wire-level tests driving the real handlers with a wedging fake executor (one that fails fast on any context-less call, so a bypassing path can never pass green by hanging) found the remaining gaps: - A timed-out statement on the main Exec/Query error paths got the timeout MESSAGE but SQLSTATE XX000, not 57014: those sites computed errCode from classifyErrorCode (cancel-only after #1194, so a deadline maps to XX000) and overrode only the message. Drivers match on the SQLSTATE, so the wedge containment worked but reported the wrong class. Every cancellation branch now forces 57014. Same fix at the secret-DDL site. - The extended-protocol Describe probes execute the statement at LIMIT 0; they now run under a statement context (deadline + CancelRequest registration, no disconnect monitor, matching Execute). - The writable-CTE rewrite paths on BOTH protocols (executeMultiStatement, executeMultiStatementExtended — the shape an incremental INSERT with CTEs compiles to) ran context-less; they now share/own a statement context. Cleanup statements deliberately stay context-less, like ROLLBACK: they must run after the deadline fires. - COPY in all variants (TO STDOUT driving query, FROM STDIN probe + spool load + remote streaming + batch-insert fallbacks, passthrough file COPY) ran context-less; each now runs under one statement context. COPY FROM STDIN uses the no-monitor variant because it reads the wire inline. Side effect: COPY now registers for CancelRequest. - Resolved.StatementTimeout was parsed but never populated. - RegisterQuery lazily initializes its map for bare test fixtures. - validateWithDuckDB's EXPLAIN probe is bounded by the timeout when set. - startDisconnectMonitor no-ops without a wire conn/reader (fixtures). - tests/mw-dev README bullet still claimed isQueryCancelled matches "context deadline exceeded"; the merged code is cancel-only. Fixed, and the coverage list updated. CLAUDE.md bullet updated likewise. Tests: server/statement_timeout_test.go now drives handleExecute, handleDescribe, executeMultiStatement(Extended), handleFetchCursor, and handleCopyIn/Out end to end, asserting wire-level 57014 + wording, that a suspended portal's context survives suspension and is torn down at completion, and that a cursor lifetime timeout classifies correctly after intervening statements. Full go test ./... green; integration suite green (TestCatalogPsqlCommands/psql_dn is order-sensitive against a reused local compose stack; passes fresh, unrelated to this change). Co-authored-by: Shelley <shelley@exe.dev>
Why
Nothing in the stack bounds a single statement today. DuckDB has no statement timeout, a client
SET statement_timeoutis an ignored SET (transform.SetShowTransform.IgnoredParams), the cnpg shards runidle_in_transaction_session_timeout=0, and pgwire clients wait forever.On 2026-09-16 a DuckLake worker deadlocked in-process mid-
INSERT— duckdb/duckdb#24961 class: all 62 threads parked in futex/nanosleep, CPU→0, ~380 B/s (TCP keepalive only) for five hours, andinterrupt()never honored. The statement ran 19,994,187 ms (5h33m) and ended only when the tenant's CI job hit its own 6h wall. Their refresh workflow uses a concurrency group withcancel-in-progress: false, so five consecutive hourly refreshes queued behind it and were cancelled.A server-side bound would have turned that into a bounded failure and freed the concurrency group.
What
--statement-timeout/DUCKGRES_STATEMENT_TIMEOUT/statement_timeout:in YAML.0is the default and preserves today's unbounded behaviour — this changes nothing until a deployment opts in.The deadline is applied in
queryContextInner, the single point every execution path (simple, batched, extended, COPY, cursor) passes through on its way to an engine. It rides on the statement context, never the connection context: a timeout ends one statement, not the session.The subtle part
That placement is exactly why classification needed care. When a statement deadline fires the connection context stays healthy, so
c.ctx.Err()is nil andisCallerCancellationalone would have misclassified the timeout as an infra failure — surfacing a bare42000carryingcontext deadline exceededinstead of a clean cancellation.statementTimedOut()answers from the statement context and is checked first at every57014site.canceling statement due to statement timeout, because drivers string-match it to decide retryability. A user cancel keepsdue to user request.isQueryCancelledhad to learncontext deadline exceeded— it previously matched onlycontext canceled.Honest limits
This is blast-radius containment, not a cure. An engine that ignores cancellation will not stop working when this fires: the client is freed with a clear error, but the worker can stay wedged. Retiring a timed-out worker is a named follow-up — it has to respect the destroy-before-reuse ordering in
SessionManager.DestroySession.Honoring a client-supplied
statement_timeoutGUC is also a follow-up; this PR leaves it an ignored SET so behaviour is unchanged for clients.Testing
server/statement_timeout_test.go: deadline lands on the statement context;0leaves statements unbounded; an expired deadline classifies as57014with the timeout wording while the connection context stays healthy; a user cancel keeps the user-request wording;isQueryCancelledmatches both sentinel and wrapped forms of deadline/cancel.go test ./...green.tests/mw-dev/README.mdper the testing contract; it becomes cheaply assertable if the knob ever goes per-connection.Suggested rollout
Set it well above the slowest legitimate statement and well below whatever external wall is otherwise the only bound. The affected tenant's real
INSERTs peak near 10 minutes, so something like60min mw-prod-us bounds a wedge at ~1h instead of 6h while leaving real work untouched.Also adds
docs/plans/2026-09-16-warehouse-reliability-plan.mdrecording the three root causes found this session and what remains for each.🤖 Generated with Claude Code