Skip to content

feat(server): add --statement-timeout to bound wedged statements - #1194

Merged
fuziontech merged 4 commits into
mainfrom
fix/statement-timeout
Sep 17, 2026
Merged

fuziontech merged 4 commits into
mainfrom
fix/statement-timeout

Conversation

@fuziontech

Copy link
Copy Markdown
Member

Why

Nothing in the stack bounds a single statement today. DuckDB has no statement timeout, a client SET statement_timeout is an ignored SET (transform.SetShowTransform.IgnoredParams), the cnpg shards run idle_in_transaction_session_timeout=0, and pgwire clients wait forever.

On 2026-09-16 a DuckLake worker deadlocked in-process mid-INSERTduckdb/duckdb#24961 class: all 62 threads parked in futex/nanosleep, CPU→0, ~380 B/s (TCP keepalive only) for five hours, and interrupt() 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 with cancel-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.

0 is 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 and isCallerCancellation alone would have misclassified the timeout as an infra failure — surfacing a bare 42000 carrying context deadline exceeded instead of a clean cancellation.

  • 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 retryability. A user cancel keeps due to user request.
  • isQueryCancelled had to learn context deadline exceeded — it previously matched only context 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_timeout GUC 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; 0 leaves statements unbounded; an expired deadline classifies as 57014 with the timeout wording while the connection context stays healthy; a user cancel keeps the user-request wording; isQueryCancelled matches both sentinel and wrapped forms of deadline/cancel.
  • Full go test ./... green.
  • Not asserted in the mw-dev e2e, deliberately — the knob is server-global, so an in-Job assertion would either start killing the harness's own legitimately slow assertions (reshard copies, DuckLake round-trips, concurrent writers) or burn minutes on a deliberately slow query. Reasoned in tests/mw-dev/README.md per 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 like 60m in 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.md recording the three root causes found this session and what remains for each.

🤖 Generated with Claude Code

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>
@github-actions

github-actions Bot commented Sep 16, 2026

Copy link
Copy Markdown

Test Impact Plan

Deterministic summary of how this PR changes tests, CI runners, and coverage-risk signals.

Summary

Area Added Changed Deleted
Test files 1 5 0
E2E/journey files 0 0 0
Workflow files 0 0 0

Signals

  • Test cases: +6 / -0
  • Assertions: +27 / -10
  • Skips or known failures added: 0
  • Workflow continue-on-error added: 0
  • Workflow path filters added: 0
  • Test commands removed from justfile: 0
  • E2E/journey retry lines added: 0

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
@fuziontech
fuziontech requested a review from a team September 17, 2026 15:08
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 bill-ph left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 benben left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:926 Extended-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:538 Sticky 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:1885 Most 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:382 Cursor 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:26 isQueryCancelled 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:20 Tests 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:529 New 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
@fuziontech

Copy link
Copy Markdown
Member Author

Thanks — the P0 was real, and two of the findings were design errors on my part rather than gaps. Pushed f59661c5.

P0 — extended Execute never got the deadline. Confirmed: RegisterQuery is only ever called from queryContextInner, and server/conn_extended_query.go had zero call sites, so handleExecute ran on the context-less executor.Query/Exec (i.e. context.Background() in PinnedExecutor). My "single point every execution path passes through" claim came from an existing comment about cancellation registration; I didn't verify the call graph, and it was wrong.

The gap was wider than the timeout — prepared statements weren't reachable 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; verified it fails without the fix (extended Execute never reached the executor's context-aware path).

Two consequences I handled rather than leave to be found later:

  • queryContextInner(false) — no disconnect monitor. Its bufio Peek would race the message loop's reads, since the extended protocol pipelines Parse/Bind/Execute/Sync. Same reasoning the cursor path documents. (It also panicked on nil-reader test conns, which is how I noticed.)
  • 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. Release ownership now moves to the portal and closeExec runs it, mirroring how finishProfiling is already retained across legs.

Sticky stmtCtx (major) — agreed, and removed entirely. Classification is now driven by the error and gated on the timeout being configured, so there's no stored state to go stale.

isQueryCancelled widening (minor) — agreed, reverted. That gate also restores byte-for-byte behaviour when the feature is off, so internal deadlines (attach, exec, worker gRPC) don't become 57014 for deployments that never enable this.

Hardcoded wording (major) — fixed at all 13 sites via cancellationMessage(err).

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 QueryStart query-log entry (via markExecStarted) where it didn't before — the intended consequence of becoming cancellable. Six fixtures encoded the old behaviour (three read the first channel entry, three recorded on the context-less methods) and were updated.

Full go test ./... matches the clean-tree baseline exactly; the three remaining failures (cache-proxy, opa, tests/integration) reproduce with my changes stashed.

@fuziontech
fuziontech requested a review from a team September 17, 2026 16:05

@bill-ph bill-ph left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 benben left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:1180 Writable-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:880 closeCursorsAtTxEnd 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:871 Suspended-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:85 Duplicate statementTimedOut check is dead code. Already returned true at line 77.
  • nit server/conn_extended_query.go:865 Extended 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.

@fuziontech
fuziontech merged commit b072849 into main Sep 17, 2026
35 checks passed
@fuziontech
fuziontech deleted the fix/statement-timeout branch September 17, 2026 16:34
fuziontech added a commit that referenced this pull request Sep 17, 2026
… 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants