Skip to content

fix: close the 25P02 cascade for query_typed/execute_typed runtime errors inside an open transaction (#181) - #184

Merged
aesslinger merged 4 commits into
mainfrom
fix/runtime-error-cascade-181
Oct 29, 2026
Merged

aesslinger merged 4 commits into
mainfrom
fix/runtime-error-cascade-181

Conversation

@aesslinger

Copy link
Copy Markdown
Collaborator

Summary

Fixes #181 — the sibling of #170 (merged). #170 closed the 25P02 transaction-abort cascade for the prepare-failure path. This PR closes it for the runtime-error path: a query_typed/execute_typed error at execute time inside an open transaction also aborts the transaction, but #170 deliberately left those two error arms returning txn_aborted: false (documented as a scope boundary by the QueryOutcome struct doc). The result: the aborted connection was re-pinned, and the next batch's first statement 25P02-cascaded until an explicit ROLLBACK — the same cascade from a different source. Three independent reviewers on PR #180's pre-handoff ensemble flagged it.

The fix

The #126 cancel contract and the recently-shipped pooler work (#168/#172/#174) are untouched — the fix changes only the runtime-error path; the prepare-success path is byte-for-byte unchanged (the rows restructure is type-level only).

Tests

  • Unit (src/handlers/query_tests.rs): error_aborts_open_txn_inner_truth_table (the 4-case truth table), error_aborted_open_txn_timeout_error_conservative (exercises the real outer fn on a constructible non-DbError via __private_api_timeout).
  • Live (src/client_live_tests.rs, #[ignore], PG_PLUGIN_TEST_HOST): the headline query_typed_runtime_error_in_open_transaction_does_not_cascade (BEGIN → SELECT 1/0 → SELECT 1; written RED first — confirmed it failed with a 25P02 cascade on the follow-up against current main, then green), execute_typed_failure_in_open_transaction_does_not_cascade (BEGIN → INSERT INTO nonexistent → SELECT 1), and a direct-connection variant.

Verification (all local — client_live_tests isn't in CI)

  • TDD RED→GREEN on the headline test
  • query_tests 47/47 (45 prior + 2 new), client_live_tests 19/19 (17 prior + 2 new)
  • lib+bins 445, live_db 39/39 against the pgvector DB, the #126 cancel contract, the failed-COMMIT Closes-gate guard, worker_starvation 2/2, shutdown 3/3, clippy, fmt — all green

Notes

…rors inside an open transaction (#181)

Follow-up to #170. #170 closed the 25P02 transaction-abort cascade for the
prepare-failure path: a prepare Parse-error inside an open transaction aborts
it, and #170's short-circuit surfaces the real error + ROLLBACKs + signals
`txn_aborted=true` so the caller forces `in_transaction=false` and releases the
connection instead of re-pinning it. But a *runtime* error at execute time
(division by zero, a constraint violation at fetch, a 42P01 on a relation
dropped between prepare and fetch) — or a failed DDL/write on the
`execute_typed` path — also aborts the transaction, and #170 deliberately left
those two error arms returning `txn_aborted: false` (documented as a scope
boundary by the `QueryOutcome` struct doc). The result: the aborted connection
is re-pinned, and the next batch's first statement 25P02s until an explicit
ROLLBACK — the same cascade from a different source. Three independent
reviewers on PR #180's pre-handoff ensemble flagged it.

Fix:

* New pure helper `error_aborted_open_txn` (with inner `error_aborts_open_txn_
  inner` for unit-testability — a `DbError` can't be constructed without a real
  server response, the same constraint that drove `pooler_error_lead`'s
  extraction). A server `DbError` (any ERROR-class response — WARNING/NOTICE
  never arrive as a query `Error`, they're delivered out-of-band as
  `AsyncMessage::Notice`) always aborts an open transaction. Non-`DbError`
  errors (transport/timeout) are conservatively assumed to have aborted it;
  the rare `FromSql` over-fire (the txn is untouched, but `Kind` is private in
  tokio-postgres so it's indistinguishable) is the accepted cost of not
  poisoning the pool on the more common Timeout/Closed/Io cases. Returns
  `false` when not `in_transaction`.

* The `execute_typed` early-return and the `query_typed` error arm classify on
  the raw `tokio_postgres::Error` (captured before `map_err`), ROLLBACK the
  now-dead transaction, append the "rolled back" notice (matching #170's
  wording), and signal `txn_aborted=true` so the caller forces
  `in_transaction=false`.

* Gate on `transaction_effect(query) != Closes`: a failed `COMMIT`/`ROLLBACK`/
  `END` already ends the transaction server-side and is handled by the existing
  `Closes` path in `in_transaction_after_outcome` — without the gate, #181 would
  issue a spurious no-op ROLLBACK and a misleading "rolled back" notice for a
  transaction COMMIT already rolled back. The gate preserves
  `commit_that_fails_on_a_deferred_constraint_releases_the_session` exactly.

* Keep `should_short_circuit_on_prepare_err` (#170) and
  `error_aborted_open_txn` (#181) as separate helpers — they answer different
  questions (short-circuit-before vs classify-after) and take different inputs;
  cross-referenced in their doc comments as the two faces of the 25P02 cascade.

The `#126` cancel contract and the recently-shipped pooler work (#168/#172/#174)
are untouched — the fix changes only the runtime-error path; the prepare-success
path is byte-for-byte unchanged (the `rows` restructure is type-level only).

Tests:

* Unit: `error_aborts_open_txn_inner_truth_table` (the 4-case truth table) and
  `error_aborted_open_txn_timeout_error_conservative` (exercises the real outer
  fn on a constructible non-DbError via `__private_api_timeout`).
* Live (`#[ignore]`, `PG_PLUGIN_TEST_HOST`): the headline
  `query_typed_runtime_error_in_open_transaction_does_not_cascade` (BEGIN →
  SELECT 1/0 → SELECT 1; written RED first — confirmed it failed with a 25P02
  cascade on the follow-up against current main, then green),
  `execute_typed_failure_in_open_transaction_does_not_cascade` (BEGIN → INSERT
  into nonexistent → SELECT 1), and a direct-connection variant.

Verified: lib+bins 445, query_tests 47/47 (45 prior + 2 new), client_live_tests
19/19 (17 prior + 2 new), live_db 39/39 against the pgvector DB, the #126 cancel
contract, the failed-COMMIT `Closes`-gate guard, worker_starvation 2/2,
shutdown 3/3, clippy, fmt — all green.
@aesslinger aesslinger added the prerelease:rc Version suggestion targets a release candidate label Oct 29, 2026
@aesslinger aesslinger self-assigned this Oct 29, 2026
@github-actions

Copy link
Copy Markdown

Version suggestion

Based on this PR's title (fix) and the prerelease:rc label:

Current 1.0.0-rc.12
Suggested next tag v1.0.0-rc.13

This is informational only — no tag or release is created automatically yet.

Six findings from the 12-agent pre-handoff ensemble, all fixed:

1. (HIGH, validation-coverage-gaps + docs-vs-code-drift) The `debug_assert!`
   in `run_pinned_body` panicked in debug builds on a #181 runtime error. The
   assert was written for #170 and reasoned only about the prepare-failure
   short-circuit, but #181's `query_typed` runtime-error classification sets
   `txn_aborted=true` on the same call (`SELECT 1/0` on a transaction pooler:
   `run_pinned_body`'s own `prepare` succeeds, `query_typed` 22012s at execute
   → `txn_aborted=true` → `debug_assert!(!outcome.txn_aborted)` panics). Removed
   the stale assert and updated the comment to explain #181's `txn_aborted=true`
   is intentionally ignored on the one-shot path — `pin_pooled_txn` owns the
   transaction lifecycle and ROLLBACKs on `Err` regardless.

2. (LOW, 6 corroboring sources — correctness-logic, backward-compat, docs x2,
   code-review, boundary) The `transaction_effect(query) != Closes` gate missed
   `Chains` (`COMMIT AND CHAIN`/`ROLLBACK AND CHAIN`/`ABORT AND CHAIN`). A
   failed `Chains` verb ends the transaction server-side just like a failed
   `Closes` (per `in_transaction_after`'s `(Chains, false) => false` arm), yet
   passed the gate and got a spurious no-op ROLLBACK + a misleading "rolled
   back" notice. Widened both gates to
   `!matches!(transaction_effect(query), Closes | Chains)`.

3. (docs-vs-code-drift) The `QueryOutcome` struct doc was stale after #181 —
   said `txn_aborted` is `true` "only" for the #170 prepare-failure short-circuit
   and listed "a `query_typed`/extraction error" as a path where it's `false`.
   After #181 both are false. Rewrote the doc to enumerate the two `true` cases
   (#170 prepare + #181 runtime) and the gate, plus a note on the one-shot
   path's intentional ignore.

4. (docs-vs-code-drift) `exec_query_on_client`'s `in_transaction` param doc
   mentioned only #170; updated to mention both #170 and #181.

5. (docs-vs-code-drift) `in_transaction_after_outcome`'s attribution updated
   from "(#170)" to "(#170 prepare-failure + #181 runtime-error)".

6. (docs-vs-code-drift) Stale line-number refs in two test comments replaced
   with symbolic references (the `else` branch of `if prepare_err.is_some()`;
   the `if !returns_result_set` early-return) so they don't rot on future
   shifts.

No behavior change beyond widening the gate to exclude `Chains` (the spurious
ROLLBACK + misleading notice on a failed `COMMIT AND CHAIN` is now correctly
suppressed). The release-mode behavior of finding 1 was already functionally
correct (the assert is a no-op in release); removing it only fixes the debug
crash.

Verified: lib+bins 445, query_tests 47/47, client_live_tests 19/19 (debug
build — exercises the formerly-panicking `run_pinned_body` path), live_db
39/39 against the pgvector DB, the #126 cancel contract, the failed-COMMIT
`Closes`-gate guard, worker_starvation 2/2, shutdown 3/3, clippy, fmt — all
green.
@aesslinger
aesslinger marked this pull request as ready for review October 29, 2026 17:58
Comment thread src/handlers/query.rs Resolved
Comment thread src/handlers/query.rs Resolved
@kilo-code-bot

kilo-code-bot Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Code Review Summary

Status: 1 Issue Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 0
Issue Details (click to expand)

WARNING

File Line Issue
src/handlers/query.rs 624 is_db_error || true is still a tautology; is_db_error is dead. The newly added comment explains the intent but the code still reads like a typo for &&/! and the truth-table test only exercises the in_transaction gate. Simplify to true (e.g. let _ = is_db_error;) rather than documenting around it.
Files Reviewed (1 file in incremental diff)
  • src/handlers/query.rs - 1 issue (the duplicated comment block in the query_typed error arm from the previous review is now fixed)

Fix these issues in Kilo Cloud

Previous Review Summaries (2 snapshots, latest commit c411641)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit c411641)

Status: 2 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 1
Issue Details (click to expand)

WARNING

File Line Issue
src/handlers/query.rs 612 is_db_error || true is a tautology; is_db_error is dead and clippy will likely flag it. The inner helper collapses to in_transaction, so the "truth table" test does not exercise the is_db_error input.

SUGGESTION

File Line Issue
src/handlers/query.rs 1343 Duplicated/garbled comment block in the query_typed error arm: stale != Closes lines 1340-1342 were left in beside the new Closes | Chains wording.
Files Reviewed (3 files)
  • src/handlers/query.rs - 2 issues
  • src/handlers/query_tests.rs - 0 issues
  • src/client_live_tests.rs - 0 issues (new live test failed_commit_in_batch_does_not_get_spurious_rolled_back_notice added since last review)

Fix these issues in Kilo Cloud

Previous review (commit 719ab5f)

Status: 2 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 1
Issue Details (click to expand)

WARNING

File Line Issue
src/handlers/query.rs 612 is_db_error || true is a tautology; is_db_error is dead and clippy will likely flag it. The inner helper collapses to in_transaction, so the "truth table" test does not exercise the is_db_error input.

SUGGESTION

File Line Issue
src/handlers/query.rs 1343 Duplicated/garbled comment block in the query_typed error arm: stale != Closes lines 1340-1342 were left in beside the new Closes | Chains wording.
Files Reviewed (3 files)
  • src/handlers/query.rs - 2 issues
  • src/handlers/query_tests.rs - 0 issues
  • src/client_live_tests.rs - 0 issues

Fix these issues in Kilo Cloud


Reviewed by claude-sonnet-5.5 · Input: 258 · Output: 1.3K · Cached: 137.4K

…#181)

Closes the test-coverage gap the pre-handoff ensemble's test-coverage-of-new-
branches agent flagged: the Closes/Chains gate's *negative* behavior (a failed
COMMIT/ROLLBACK/END/COMMIT AND CHAIN issued via the batch path while
in_transaction=true must NOT receive the spurious ROLLBACK + "rolled back"
notice) was uncovered by any test. The existing
`commit_that_fails_on_a_deferred_constraint_releases_the_session` test uses the
single-statement `execute_query` RPC, which passes `in_transaction=false` —
so the gate is never evaluated and its assertion (`commit["error"].is_string()`
only) would still pass if #181 wrongly appended "rolled back" to a failed
COMMIT.

The new `failed_commit_in_batch_does_not_get_spurious_rolled_back_notice`
test exercises the gate via the batch path: `["BEGIN", "INSERT (violating)",
"COMMIT"]` as a session-pinned batch on a transaction pooler, so the COMMIT
fails with a deferred-FK violation while `in_transaction=true`. It asserts:
  - the COMMIT's error carries the real FK-violation SQLSTATE, not a 25P02
    cascade;
  - the COMMIT's error does NOT contain "rolled back" (the load-bearing
    negative assertion — fails if the gate's `Closes | Chains` conjunct is
    deleted);
  - the follow-up `SELECT 1` succeeds (the Closes path released the
    connection, no re-pin of an aborted connection).

If someone deletes the `&& !matches!(transaction_effect(query),
Closes | Chains)` conjunct from either #181 error arm, this test fails —
catching the regression.

Verified: client_live_tests 20/20 (19 prior + this), query_tests 47/47,
lib+bins 445, clippy, fmt — all green.
…r || true` tautology (#181)

Two kilo-code-bot findings on PR #184:

1. (real bug) The `query_typed`-arm gate comment was garbled — my edit to
   widen the gate from `!= Closes` to `!matches!(…, Closes | Chains)` left
   the old `!= Closes` wording's trailing lines in place, producing a
   self-contradicting comment that repeated "batch's first statement). Gate
   on `transaction_effect(query)`" and stated both `!= Closes` and
   `neither Closes nor Chains`. Removed the stale fragment; the comment now
   reads cleanly with only the `Closes | Chains` wording.

2. (cosmetic, no behavior change) `error_aborts_open_txn_inner`'s
   `is_db_error || true` collapses to `true`, making `is_db_error` a no-op
   at the call site. The bot flagged it reads as a typo for `&&`/`!`. It is
   deliberate — the param is kept so the `error_aborts_open_txn_inner_truth_
   table` unit test can assert the intent for all 4 `(is_db_error,
   in_transaction)` cases (both error classes abort when `in_transaction`,
   not just one). Expanded the comment to state the tautology is intentional,
   not a typo, and that the outer `error_aborted_open_txn` still threads
   `as_db_error().is_some()` so the discriminator is exercised end-to-end.
   No code change for this finding — the tautology is the cost of the
   testable truth table.

Verified: client_live_tests 20/20, query_tests 47/47, clippy, fmt — all green.
@aesslinger
aesslinger merged commit c67bf7a into main Oct 29, 2026
8 checks passed
@aesslinger
aesslinger deleted the fix/runtime-error-cascade-181 branch October 29, 2026 18:10
aesslinger added a commit that referenced this pull request Oct 29, 2026
Bumps version to 1.0.0-rc.13 in Cargo.toml, Cargo.lock, .tabularium, and
CHANGELOG.md.

### Included since rc.12

**Fixed:**
- A `prepare` failure inside an open transaction no longer cascades to a
  confusing 25P02 on every subsequent statement in the same session (#170,
  #180). The fix gates the `.ok()` prepare-swallow on `in_transaction`:
  inside an open transaction it short-circuits (ROLLBACK + surface the
  prepare's real SQLSTATE + signal `txn_aborted=true`); outside a transaction
  the recoverable swallow is preserved (a 42P05 on a real PgBouncer bare
  batch). The error message tells the user the transaction was rolled back.
- A `query_typed`/`execute_typed` runtime error inside an open transaction
  no longer re-pins the aborted connection and 25P02-cascades the next
  statement (#181, #184). The sibling of #170 from a different source:
  classifies on the error's `as_db_error()` (a server `DbError` always
  aborts an open transaction; non-`DbError` conservatively assumed aborted).
  Both error arms ROLLBACK + surface the real error + signal
  `txn_aborted=true`, gated on `transaction_effect(query)` being neither
  `Closes` nor `Chains` so a failed `COMMIT`/`COMMIT AND CHAIN` doesn't get a
  spurious ROLLBACK + notice. Closes the 25P02 cascade end-to-end (#170
  prepare-failure + #181 runtime-error = the two faces).

**Dependencies:**
- `uuid` 1.28.0 → 1.29.0 (#182). Additive only (versioned `NonNilUuid`
  constructors); the existing `Uuid` API is untouched.
- `tokio` 1.53.3 → 1.54.1 (#183). The 1.54.1 patch reverts the only
  `time`-subsystem change from 1.54.0, so `tokio::time::timeout` behavior is
  back to 1.53.3-equivalent — the bound the #170/#181 fixes rely on. MSRV
  bumped to 1.85.

**Docs:**
- Centered the PostgreSQL banner, converted to PNG; fixed a stale Tabularis
  logo URL (#179).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

prerelease:rc Version suggestion targets a release candidate

Projects

None yet

1 participant