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 frompg_stat_activity - an open block is rolled back
- the
ServerSideCursorfailed-DECLAREcase leaves no stranded block - the
Dropguard — 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.shgreen: 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._driver → django_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.