feat: take transaction state from the wire, not from parsing SQL

Ports glitchtip-rust !19 into django-vpg. The driver was extracted at glitchtip-rust v0.6.1, before that work, so this package still carries the bug it fixes.

What was wrong

Releasing a pooled connection sent an unconditional cleanup ROLLBACK. Outside a transaction PostgreSQL answers that with WARNING: there is no transaction in progress and logs it by default — measured on a stock GlitchTip install at exactly 60/minute, ~86k lines/day, on an idle system. And it still didn't fully protect the pool: a connection left inside a transaction by any path that bypassed the release helper went straight back to the next caller.

What replaces it

psycopg doesn't guess: libpq caches the ReadyForQuery status byte, psycopg exposes it as Connection.info.transaction_status, and psycopg_pool's putconn switches on it. This does the same.

vendor/tokio-postgres gains a second patch, txn-status (GT_PATCH.md entry + regenerated diff; the tree stays byte-identical to glitchtip-rust's copy). One Arc<AtomicU8> per connection, created in connect_raw, written by the driver task as each request completes (codec::BackendMessages::ready_status), read through Client::transaction_status().

Captured in the driver task, not the consumer — worth knowing if you review only the diff: a consumer-side capture in Responses::poll_next looks equivalent and is not. prepare() never drains to ReadyForQuery, and one-shot statement Close traffic re-stamps the connection, so that version goes stale exactly when it matters. It was tried first, and live tests caught it.

vpg-core gains TransactionStatus (psycopg's five states, same ordinals) and release_conn as psycopg_pool's _reset_connection:

status action
idle recycle, no SQL at all
in transaction / failed transaction ROLLBACK; discard if even that fails
indeterminate discard — deadpool's recycler only checks is_closed()

A pre_recycle hook applies the same rule at checkout, so paths that never call release_conn — notably the async backend's autocommit statements, which check a connection out and drop it inline — cannot hand on a poisoned connection either. One relaxed atomic load when the connection is clean; no extra round trips anywhere.

django_vpg.dbapi gains Connection.info.transaction_status and a TransactionStatus IntEnum matching psycopg.pq's ordinals.

Tests

Four live-postgres regression tests, each verified in this repo to fail when the behaviour it guards is disarmed (flipping release_conn's rollback arm off fails three of them; the idle test fails under the opposite mutation):

  • an idle release sends no ROLLBACK — read back from pg_stat_activity
  • an open block is rolled back
  • the ServerSideCursor failed-DECLARE case leaves no stranded block
  • the Drop guard — the path that fires on GC, cancellation and exceptions, where the release path never runs

Positive controls throughout so none can pass vacuously, and a pytest.skip rather than a misleading failure when the server runs track_activities=off. The wire harness learned simple-query ('Q') support and now scripts the status byte, so every branch is asserted directly: idle sends no SQL, both T and E roll back, a failed cleanup ROLLBACK detaches, and a connection dropped inline while inside a block is discarded at the next checkout.

Docs

README gains Pooling and session semantics, stating both psycopg-reference rules for users porting an app: the backend decides transaction state, and session state is deliberately not reset between checkouts (as in psycopg_pool), with the caveat that DISCARD ALL is the wrong tool because it deallocates prepared statements the per-connection cache still refers to. It also documents the async-autocommit session difference — statements may land on different backend sessions, so pg_backend_pid(), TEMP tables, SET and advisory locks don't persist — noted as a deliberate throughput trade that should become configurable before 1.0. backend/async_base.py's docstring says the same where a reader will actually hit it.

Verification

  • ./scripts/check.sh green: fmt, clippy (-D warnings), cargo test -p vpg-core (89 unit + 22 wire), ruff.
  • Full Python suite against live PostgreSQL 17: 210 passed (206 before, +4 new).

Also here: the extension module is renamed

django_vpg._driverdjango_vpg._vpg_driver. A pymodule's init symbol is PyInit_<module basename>, so _driver exported PyInit__driver — the same symbol django-vcache's extension exports. Independently installed wheels never noticed (CPython dlopens extensions RTLD_LOCAL), but the embedding path this package documents — link both rlibs into one cdylib, which glitchtip-rust does for Valkey + Postgres — was a duplicate-symbol link error.

Two commits, and both are kept because they do different jobs: the unique symbol is what guarantees the link (Cargo features unify across a dependency graph, so a feature alone can be silently re-enabled by any other crate depending on vpg-pyo3 with default features), while the default-on standalone-module feature just avoids compiling an entry point an embedder never uses.

Breaking for anyone importing django_vpg._driver directly — a private module on an experimental 0.1.0 package, and the cheapest moment to fix it. The public surface (django_vpg.dbapi, django_vpg.backend) is unchanged.

Follow-up

glitchtip-rust will then link vpg-pyo3 and delete its own copy of the driver, sharing one runtime via vpg_core::set_runtime_provider — this MR is what makes that a straight deletion rather than a merge.

🤖 Generated with Claude Code

Edited by David Burke

Merge request reports

Loading
Loading