Skip to content

Failed BEGIN leaves Connection._top_xact set; every later transaction becomes a SAVEPOINT and fails with NoActiveSQLTransactionError #1386

Description

@josumoreno-BP

Summary

Transaction.start() assigns con._top_xact = self before it sends BEGIN. If BEGIN raises, the transaction is marked FAILED, but _top_xact is never reset. From then on, every conn.transaction() on that connection is treated as nested and sends SAVEPOINT ..., which the server rejects:

asyncpg.exceptions.NoActiveSQLTransactionError: SAVEPOINT can only be used in transaction blocks

The connection is still open and idle (is_closed() == False, is_in_transaction() == False), but it cannot run a transaction again until it is closed or reset().

Reproduction

The concurrent query below is just a convenient way to make BEGIN raise. Any exception from BEGIN has the same effect (see "How we hit it").

import asyncio
import asyncpg


async def main():
    conn = await asyncpg.connect("postgresql://postgres:postgres@localhost:5432/postgres")

    # Make BEGIN fail: the connection is busy, so execute("BEGIN") raises before sending anything.
    busy = asyncio.create_task(conn.execute("SELECT pg_sleep(0.5)"))
    await asyncio.sleep(0.1)
    try:
        await conn.transaction().start()
    except asyncpg.InterfaceError as e:
        print("BEGIN failed:", e)
    await busy

    print("is_in_transaction:", conn.is_in_transaction(), "| _top_xact:", conn._top_xact)

    async with conn.transaction():  # sent as SAVEPOINT -> NoActiveSQLTransactionError
        await conn.execute("SELECT 1")


asyncio.run(main())
BEGIN failed: cannot perform operation: another operation is in progress
is_in_transaction: False | _top_xact: <asyncpg.Transaction state:failed 0x...>
asyncpg.exceptions.NoActiveSQLTransactionError: SAVEPOINT can only be used in transaction blocks

How we hit it

We run Aurora PostgreSQL behind Amazon RDS Proxy, with SQLAlchemy 2.0's asyncpg dialect. RDS Proxy could not borrow a backend connection in time, answered BEGIN with an error (SQLSTATE 08000) and kept the client socket open:

03:00:03  PostgresConnectionError: Timed-out waiting to acquire database connection.
03:00:05  NoActiveSQLTransactionError: SAVEPOINT can only be used in transaction blocks
03:00:07  NoActiveSQLTransactionError: SAVEPOINT can only be used in transaction blocks
...       (every write until the process was restarted)

asyncpg.Pool clears it on release(), which calls Connection._reset() and resets _top_xact. Until then, retries within the same acquire() fail the same way. Connections reused without reset(), such as SQLAlchemy's pool or a long-lived connection, stay broken until they are closed.

Is this intentional? We couldn't tell whether leaving _top_xact set after a failed BEGIN is by design. The tests in tests/test_transaction.py assert _top_xact is None after every error case they cover, but none covers BEGIN itself failing.

Proposed fix

In Transaction.start():

         try:
             await self._connection.execute(query)
         except BaseException:
             self._state = TransactionState.FAILED
+            if con._top_xact is self:
+                con._top_xact = None
             raise

This mirrors what __commit() and __rollback() already do. The is self guard leaves the outer transaction in place when a nested SAVEPOINT fails.

Why this is safe for any exception

Only __commit() and __rollback() clear _top_xact, and both raise for a FAILED transaction before reaching that line. So after a failed BEGIN, nothing can ever release it, whatever the exception was. Whether the server is actually in a transaction is tracked separately, through is_in_transaction(), and start() already checks that when _top_xact is None:

How BEGIN failed Server afterwards Today With the fix
Error response (e.g. the proxy's 08000) not in a transaction every later transaction fails with 25P01 next BEGIN works
Raised before sending (e.g. the repro above) not in a transaction same next BEGIN works
Cancelled after the server ran BEGIN in a transaction next transaction becomes a SAVEPOINT in that open transaction, reports success, but nothing is committed if the reply to the cancelled BEGIN has been read: InterfaceError: cannot use Connection.transaction() in a manually started transaction; if not yet: BEGIN is sent again (the server only warns) and the transaction commits normally

Environment

  • asyncpg 0.32.0 (also 0.31.0)
  • PostgreSQL 17 (repro); Aurora PostgreSQL behind RDS Proxy (production)
  • Python 3.14

Activity

  1. chenty2333 commented on Oct 21, 2026

    @chenty2333

    I reproduced the cancelled-BEGIN variant described above with asyncpg 0.32.0, Python 3.13 and PostgreSQL 18.4. The more subtle failure is a later transaction reporting success while its write remains uncommitted. Three consecutive runs produced the same result.

    Here is a standalone reproduction without SQLAlchemy, a pool or a proxy. Set DATABASE_URL to a disposable database with CREATE TABLE permission:

    """Run with: DATABASE_URL=postgresql://... python asyncpg_entry.py."""
    import asyncio
    import os
    import uuid
    
    async def main():
        dsn = os.environ['DATABASE_URL']
        import asyncpg
        conn = await asyncpg.connect(dsn)
        observer = await asyncpg.connect(dsn)
    
        async def scalar(c, sql):
            return await c.fetchval(sql)
        table = 'entry_cancel_' + uuid.uuid4().hex
        try:
            await observer.execute(f'CREATE TABLE {table} (v integer)')
    
            async def interrupted_entry():
                asyncio.get_running_loop().call_soon(asyncio.current_task().cancel)
                async with conn.transaction():
                    raise AssertionError('transaction body must not be entered')
            try:
                await asyncio.create_task(interrupted_entry())
            except asyncio.CancelledError:
                print('entry cancelled; body not entered')
            await asyncio.sleep(0.1)
            async with conn.transaction():
                await conn.execute(f'INSERT INTO {table} VALUES (42)')
            print('retry transaction exited normally')
            print('same connection:', await scalar(conn, f'SELECT count(*) FROM {table}'))
            print('independent observer:', await scalar(observer, f'SELECT count(*) FROM {table}'))
            await conn.reset()
            async with conn.transaction():
                await conn.execute(f'INSERT INTO {table} VALUES (43)')
            print('observer after reset + fresh transaction:', await scalar(observer, f'SELECT count(*) FROM {table}'))
        finally:
            await conn.close()
            await observer.execute(f'DROP TABLE IF EXISTS {table}')
            await observer.close()
    asyncio.run(asyncio.wait_for(main(), 15))

    Output:

    entry cancelled; body not entered
    retry transaction exited normally
    same connection: 1
    independent observer: 0
    observer after reset + fresh transaction: 1
    

    The final line is a recovery control: after reset(), a fresh transaction commits visibly. The earlier insert was not committed by its successful context exit. This seems worth covering in a regression test alongside the pre-send BEGIN failure case, since simply checking that a subsequent query succeeds misses it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions