Hacktoberfest 2026:維護者為十月標記出來的 issue,仍然開放、適合新手。 瀏覽 Hacktoberfest issue

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

未關閉
#1,386 0 則留言 0 個 reaction 已指派 0 人 在 GitHub 檢視

維護者通常 1 天內回覆

@devtechedge 已經在處理了。

開始於 2026年10月7日。

  • #1387 來自 @devtechedge —— 未關閉

評估

難度
2/5
預估耗時
1-3 小時
新手友好度
35/100
Issue 類型
缺陷
描述清晰度
描述清楚
活躍度
停滯
技術堆疊
postgresql, python
領域
backend, databases

研究方向

Read Transaction.start() and the transaction tests in tests/test_transaction.py, focusing on the path where BEGIN raises. Check the existing cleanup in __commit() and __rollback() and add coverage for a failed BEGIN, including the nested-savepoint case. Done means the connection can start a later transaction after a failed BEGIN without leaving an outer transaction’s state corrupted; linked pull request #1387 is already open.

由索引模型根據 Issue 內容生成。

描述

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
主要語言
Python
星號
8.1k
分支
474
平均合併
1 天 17 小時
30 天內合併 PR
24

環境準備

這個專案沒有提供開發容器、Dockerfile 或貢獻指南,環境需要你自己搭建:先看它的 README,通用步驟見我們的新手貢獻指南。

從這裡開始

  1. 先讀完整個 Issue,再讀專案的貢獻指南。
  2. 在 Issue 下留言說明你要接手 —— 這能避免兩個人做同樣的事。
  3. Fork 儲存庫,在一個分支上完成修改。
  4. 送出 Pull Request,並在描述裡引用這個 Issue 編號。

MagicStack/asyncpg 的其他 Issue

查看 MagicStack/asyncpg 的全部 Issue

相似的 Issue

更多 Python Issue

把新 issue 寄到你的電子郵件信箱

精選適合新手參與的 GitHub issue 摘要。