Skip to content

Rust #325: roll back a PostgreSQL transaction cancelled during BEGIN - #761

Merged
rmcdaniel merged 1 commit into
mainfrom
rust-325-pg-cancelled-begin
Oct 25, 2026
Merged

rmcdaniel merged 1 commit into
mainfrom
rust-325-pg-cancelled-begin

Conversation

@rmcdaniel

Copy link
Copy Markdown
Member

Native PostgreSQL nodes could lose their storage for the rest of a run after one request was dropped mid-BEGIN. This vendors sqlx-postgres 0.9.0 with upstream's unreleased fix, as rust/vendor/sqlx-mysql is vendored.

What failed

Server #673's fixtures run 40195442238, postgres-storage (existing-php-matrix, sdk-directions): native node A logged one runtime_storage_unavailable with SQLSTATE 25001, then runtime_scheduler_storage_unavailable, then 235 more with 25P02. PostgreSQL logged the scheduler's timer probe on the same backend every 250 ms:

ERROR:  current transaction is aborted, commands ignored until end of transaction block
STATEMENT:  SELECT t.*,r.queue,r.namespace AS run_namespace FROM workflow_run_timers t JOIN workflow_runs r ON r.id=t.workflow_run_id WHERE ...

That probe runs directly on the pool (fetch_optional(&self.pool)), not in a transaction. So a pooled connection was sitting in an aborted transaction.

Why

Published PgTransactionManager::begin raises transaction_depth only after the BEGIN round trip. start_rollback, which both its guard and Transaction's drop call, does nothing while the depth is 0. When a request future is dropped during that round trip (a client closing its connection mid-request), the server stays in the transaction, nothing queues a ROLLBACK, and the pool hands the connection to the next borrower:

  1. The scheduler's pooled probe runs inside the stray transaction and takes its snapshot.
  2. Native's read snapshot BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY on that connection fails with 25001 ("SET TRANSACTION ISOLATION LEVEL must be called before any query"), which aborts the transaction.
  3. sqlx still counts depth 0, so nothing rolls back. Every later statement on the connection fails with 25P02.

Upstream fixed exactly this in transact-rs/sqlx#4394 (commit f1e94ec34dd5, merged 2026-09-09), which isn't in a release yet (latest tag v0.9.0).

Change

  • rust/vendor/sqlx-postgres: the official 0.9.0 crate (archive SHA-256 87a2bdd6…dcd65e, the Cargo.lock checksum it replaces) with upstream's src/transaction.rs change applied unchanged. The depth is claimed before the round trip and unwound if the BEGIN did not take, so a cancelled or failed BEGIN queues a ROLLBACK. DURABLE-PATCH.md records the provenance and says when to remove the patch.
  • rust/Cargo.toml: [patch.crates-io] sqlx-postgres = { path = "vendor/sqlx-postgres" }. Cargo.lock drops the registry source and checksum lines, as for sqlx-mysql.
  • New qualification_a_begin_cancelled_in_flight_leaves_no_transaction_behind (PostgreSQL, #[ignore], run by the runtime::postgres::tests::qualification_ CI filter). A one-connection pool cancels pool.begin_with("BEGIN; SELECT pg_sleep(2)") after 300 ms and waits for the abandoned statement to answer. It then checks that pg_stat_activity shows the backend idle, and replays the scheduler's order on that same backend: a pooled probe, native's read snapshot BEGIN, then a pooled statement.

Qualification

  • Counterfactual, published driver (the vendored src/transaction.rs restored from the archive), against a disposable PostgreSQL. The new test fails: left: Some("idle in transaction"), right: Some("idle"). With the state assertion temporarily replaced by logging, the same run reproduces the CI signature on that backend: the pooled probe succeeds inside the stray transaction, the read snapshot BEGIN fails with 25001, and the next pooled statement fails with 25P02.
  • With this change: all 12 runtime::postgres::tests::qualification_ tests pass, the new one included (40 s).
  • Full SQLite suite: library 275 passed (36 ignored, the database qualifications), execution 492 passed.
  • The vendored crate differs from the checksum-verified published .crate archive only in src/transaction.rs and the added DURABLE-PATCH.md. Sonar already excludes **/vendor/**.
  • CI runs the PostgreSQL and MySQL execution suites and every fixture part.

No native runtime source changes beyond the test, no fixture changes, and no PHP changes.

🤖 Generated with Claude Code

Published sqlx-postgres 0.9.0 raises transaction_depth only after the BEGIN
round trip, and start_rollback does nothing at depth 0. A request dropped
during that round trip left the pooled connection inside a transaction. In
#673's fixtures (run 40195442238) the scheduler's pooled timer probe then
ran inside it, native's read snapshot BEGIN failed with 25001 and aborted
it, and every later statement on that connection failed with 25P02.

Vendor sqlx-postgres 0.9.0 with upstream's unreleased fix
(transact-rs/sqlx#4394, f1e94ec34dd5) applied unchanged, as sqlx-mysql is
vendored. The new PostgreSQL qualification test cancels a pooled BEGIN in
flight and checks that the backend is idle and that the scheduler's order
succeeds on it. With the published driver it is "idle in transaction", the
read snapshot BEGIN fails with 25001 and the next statement with 25P02.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@rmcdaniel

rmcdaniel commented Oct 25, 2026 •

Copy link
Copy Markdown
Member Author

Security source review of exact head 507b5515ababfc6ace2fd20875ee11ec2ebb9c76: no security findings.

Provenance and scope checks:

  • The downloaded official sqlx-postgres 0.9.0 crate hashes to 87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e, matching the displaced Cargo.lock checksum.
  • A recursive comparison shows the vendored tree is byte-for-byte identical to that archive except for DURABLE-PATCH.md and src/transaction.rs.
  • The vendored transaction.rs is byte-for-byte identical (SHA-256 79edc3dded768a5eea31ac9ae253b5cea605753ce3e32c82f48150be137e20b3) to upstream verified commit f1e94ec34dd5f11ad6488a3a8ac7988439a3f757. That upstream commit changes only this driver file plus its regression test.
  • The state transition is fail-safe: depth is claimed only after query queueing succeeds but before the cancellable round trip; an error/cancellation lets the guard queue rollback, a server-side failed BEGIN explicitly unwinds depth, and a successful BEGIN defuses the guard at depth one.
  • The local qualification reproduces upstream’s cancellation timing and additionally exercises the scheduler sequence that failed in Start every Server pip install from the patched, bundle-only pip (GHSA-6q48-6c7w-9gw2) #673.

The archive itself carries pre-existing executable modes and trailing whitespace on many source files; full-tree comparison confirms these are provenance-preserving archive properties, not additional code changes.

This is exact-head source/provenance acceptance only. It does not claim completed CI or a local Rust build (Rust builds belong on the dedicated VPS). Merge remains gated on the required fresh green checks and the owner’s PostgreSQL/SQLite qualification evidence.

@rmcdaniel

Copy link
Copy Markdown
Member Author

Exact-head CI follow-up for 507b5515ababfc6ace2fd20875ee11ec2ebb9c76 at 2026-10-25 14:09 UTC: GitHub reports merge state CLEAN and MERGEABLE, with 61 successful checks, one neutral check, zero pending checks, and zero failed checks.

Combined with the exact-head source/provenance acceptance in comment 6398515671 and the owner’s PostgreSQL/SQLite qualification evidence, the Security fresh-green merge gate is satisfied. This does not authorize publication or change the separate #673 issuer and Docker Hub credential gates.

@rmcdaniel

Copy link
Copy Markdown
Member Author

CI receipt: exact head 507b5515ababfc6ace2fd20875ee11ec2ebb9c76

All checks green on base e2cb605d5 (current main): PHPUnit Feature, Shared Server fixtures (run 40199217339), SonarQube (gate passed, 0 open issues), Public Boundary.

  • Hosted postgres-storage (postgres-17, native-tests), from its evidence postgres-tests.txt: test runtime::postgres::tests::qualification_a_begin_cancelled_in_flight_leaves_no_transaction_behind ... ok, and test result: ok. 12 passed; 0 failed.
  • Every PostgreSQL, MySQL and SQLite fixture part passed, including both PostgreSQL sdk-directions parts, where Start every Server pip install from the patched, bundle-only pip (GHSA-6q48-6c7w-9gw2) #673's failure occurred.

Awaiting Security's exact-head review (chat 1330) before merging.

@rmcdaniel
rmcdaniel merged commit 85d37df into main Oct 25, 2026
62 checks passed
@rmcdaniel
rmcdaniel deleted the rust-325-pg-cancelled-begin branch October 25, 2026 14:10
rmcdaniel added a commit that referenced this pull request Oct 25, 2026
…-pip

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rmcdaniel added a commit that referenced this pull request Oct 25, 2026
…age-pull-retry

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rmcdaniel added a commit that referenced this pull request Oct 25, 2026
…st-325-ci-codec-one-job

The comparator node test list keeps main's update-validation retry test
and this branch's codec deadline cell test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant