From 9a33f253f49a3fb0229f2027210379aa726b0b41 Mon Sep 17 00:00:00 2001 From: Soujas K Date: Tue, 27 Oct 2026 19:36:28 +0530 Subject: [PATCH] Raise when COMMIT is answered with ROLLBACK When a statement fails inside a transaction and the error is caught without rolling back to a savepoint, PostgreSQL answers the following COMMIT with the ROLLBACK command tag and discards the transaction. Transaction.commit() and `async with con.transaction()` ignored the tag, so they returned normally and marked the transaction as committed even though its changes were lost. Check the command tag and raise InFailedSQLTransactionError (25P02) in that case, and mark the transaction as rolled back. Fixes #1400. --- asyncpg/transaction.py | 19 ++++++++++++++++--- tests/test_transaction.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/asyncpg/transaction.py b/asyncpg/transaction.py index 562811e6..42edb0f5 100644 --- a/asyncpg/transaction.py +++ b/asyncpg/transaction.py @@ -184,12 +184,25 @@ async def __commit(self): query = 'COMMIT;' try: - await self._connection.execute(query) + status = await self._connection.execute(query) except BaseException: self._state = TransactionState.FAILED raise - else: - self._state = TransactionState.COMMITTED + + if not self._nested and status == 'ROLLBACK': + # PostgreSQL answers COMMIT of an aborted transaction with + # a ROLLBACK, so the changes are lost and must not be reported + # as committed. + self._state = TransactionState.ROLLEDBACK + raise apg_errors.InFailedSQLTransactionError.new({ + 'C': '25P02', + 'S': 'ERROR', + 'M': 'the transaction was rolled back by the server ' + 'instead of being committed', + 'H': 'An earlier statement in the transaction failed.', + }) + + self._state = TransactionState.COMMITTED async def __rollback(self): self.__check_state('rollback') diff --git a/tests/test_transaction.py b/tests/test_transaction.py index f84cf7c0..62b64f04 100644 --- a/tests/test_transaction.py +++ b/tests/test_transaction.py @@ -159,6 +159,41 @@ async def test_transaction_interface_errors(self): async with tr: pass + async def test_transaction_commit_rolled_back_by_server(self): + await self.con.execute('CREATE TABLE mytab (a int)') + try: + tr = self.con.transaction() + await tr.start() + await self.con.execute('INSERT INTO mytab (a) VALUES (1)') + with self.assertRaises(asyncpg.DivisionByZeroError): + await self.con.execute('SELECT 1 / 0') + + with self.assertRaisesRegex(asyncpg.InFailedSQLTransactionError, + 'rolled back by the server'): + await tr.commit() + + self.assertTrue(repr(tr).startswith( + '