feat(unified): one PostgreSQL session for the sync and async ORM
Summary
Django's sync ORM and django-async-backend's async ORM run on two different
PostgreSQL connections. This puts them on one, so a transaction opened in
either world covers work done in both — atomic() and async_atomic() nest
through each other as savepoints, and a plain django.test.TestCase can test
an async code path.
That last one is the reason I want it. Today a sync test seeding fixtures
cannot test an async API: the seed data is uncommitted on the sync connection
and invisible to the async one. The half that looks like it works is worse —
an async write commits on its own connection, READ COMMITTED shows it back to
the sync side, the assertion passes, and the row survives the rollback into
the next test. The longer-term goal is to stop caring which colour a call is,
including eventually pointing objects.a* at the real async path.
Needs the Django 6.1 floor from !24 (merged) (merged), so it is based on current
main.
Draft: the two gaps under "Not covered" below are things I want to decide on before this merges.
How it works
A RustTransaction is one pinned pooled backend exposing both a sync and an
async method family over a mutex, so both worlds can drive it natively — the
thing psycopg cannot do with two sockets. The missing piece was only that the
two Python layers each built their own transaction. They now point their
transaction state (in_atomic_block, savepoint_ids, savepoint_state,
needs_rollback, atomic_blocks, autocommit) at one object, which is
enough for Atomic and AsyncAtomic — a near-verbatim fork operating on
those same names — to compose without patching either.
autocommit has to be in that set. _savepoint_allowed() is
uses_savepoints and not get_autocommit(), so a sync atomic() nested in an
async one silently degrades to no savepoint at all, and an inner rollback then
takes the whole outer transaction with it.
Two rules that keep it honest
Both are regression tests in tests/test_unified_races.py, and both were real
failures reproduced before being fixed.
Concurrent tasks stay separate. gather(work(), work()) gives each task
its own session, as it does today. Without that, both push savepoints onto one
stack and the first to commit destroys the other's — observed as
[25P01] SAVEPOINT can only be used in transaction blocks, with the first
task's rows committing anyway. Note this defeats async-backend's own
connection._task is not asyncio.current_task() guard, which checks wrapper
ownership; moving state off the wrapper walks straight past it. Sync code may
always join, because an asgiref worker is the blocked holder's own unit of
work. A finished holder releases implicitly, so sequential async_to_sync
calls hand the session on with no bookkeeping.
Connection lifecycle calls are detached. close_old_connections() inside
a shared transaction used to see autocommit off, read that as "the application
forgot to restore it", close, and then — seeing in_atomic_block — set
closed_in_transaction/needs_rollback and never clear self.connection:
before: in_atomic_block=True closed_in_transaction=False needs_rollback=False
after: True True True
write after close: TransactionManagementErrorThe damage outlived the transaction — every later query on that thread's sync
wrapper raised InterfaceError: connection is closed. Recycling is now
refused outright while a transaction is open.
Kill switch
On by default. VPG_UNIFIED_SESSIONS = False, or VPG_UNIFIED_SESSIONS=0 in
the environment, restores the previous behaviour with no code change. It is
tested as a real off switch, not a no-op:
TestKillSwitchRestoresTheOldBreakage asserts the old breakage comes back.
What adversarial review changed
Three fresh-context reviewers found six more defects, four of them silent (a write lands outside the transaction and survives its rollback, no error). All are reproduced against a live PostgreSQL and pinned by tests:
- Statements before the pin escaped.
atomic()emits no SQL, so there is a window where the session is in a transaction and nothing is pinned. The joining connection never ranset_autocommit(False)itself, so routing on its own flag sent that first statement to the autocommit pin. This broke the headline case:with transaction.atomic(): async_to_sync(...). - COPY, server-side cursors and multi-statement DDL never routed at all —
they escaped even after the transaction was pinned. Migrations too: a
CreateModelfirst in a migration ran its DDL outside the transaction. - Caching the session on the connection crossed transactions between
tasks. asgiref's
thread_sensitive=Truefunnels every task's sync ORM work onto one thread, and Django's registry is thread-local, so one connection serves all of them. Produced a real cross-task dirty read. on_commithooks were dropped or fired for rolled-back work.AUTOCOMMIT: Falsewas silently ignored;OPTIONS={"pool": False}now opts out (its private driver is torn down byclose()).
Three tests were repaired rather than kept green — one was a false green of exactly the kind this feature exists to eliminate.
Not covered
- Autocommit-mode sharing is deliberately out of scope — outside a transaction
each world keeps its own pin, so TEMP tables and
SETGUCs do not cross. - Multi-alias is structurally supported but only
defaultis tested. - Two threads driving one session concurrently — only reachable through an
explicit
sync_to_async(..., thread_sensitive=False)— now raise instead of silently discarding one another's work. Django builds savepoint ids ass{thread_ident}_x{n}, so releasing one you did not create is provable. Without the check, releases nest correctly by luck until one unit rolls back and takes the other's work with it.
Testing
tests/test_unified_session.py— sharing, cross-visibility, nesting, kill switchtests/test_unified_races.py— the two defects abovetests/test_unified_testcase.py— before/after on a plainTestCase, incl. leak measurementtests/test_unified_unit.py— ownership and flag logic, no database
397 passed with the async extra, 335 passed / 28 skipped without it,
./scripts/check.sh green. Key fixes are mutation-tested: reverting the
connection cache makes the concurrency tests fail with a real dirty read, and
removing the savepoint check makes the interleaving test fail.
AI disclosure: Claude Code (Opus 5) wrote the implementation, tests and docs from my direction, and reproduced both defects against a live PostgreSQL before and after the fix. Reviewed by me.