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: TransactionManagementError

The 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 ran set_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 CreateModel first in a migration ran its DDL outside the transaction.
  • Caching the session on the connection crossed transactions between tasks. asgiref's thread_sensitive=True funnels 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_commit hooks were dropped or fired for rolled-back work.
  • AUTOCOMMIT: False was silently ignored; OPTIONS={"pool": False} now opts out (its private driver is torn down by close()).

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 SET GUCs do not cross.
  • Multi-alias is structurally supported but only default is 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 as s{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 switch
  • tests/test_unified_races.py — the two defects above
  • tests/test_unified_testcase.py — before/after on a plain TestCase, incl. leak measurement
  • tests/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.

Edited by David Burke

Merge request reports

Loading
Loading