Repository navigation
Reset Connection._top_xact when BEGIN fails in Transaction.start() - #1387
devtechedge wants to merge 3 commits into
Conversation
| # Make BEGIN fail by running it while another | ||
| # operation is in progress on the connection. | ||
| busy = asyncio.ensure_future(self.con.execute('SELECT pg_sleep(0.5)')) | ||
| await asyncio.sleep(0.1) | ||
| with self.assertRaisesRegex(asyncpg.InterfaceError, | ||
| 'another operation is in progress'): | ||
| await self.con.transaction().start() | ||
| await busy |
There was a problem hiding this comment.
Can we do something like this to avoid relying on flaky loop timing?
| # Make BEGIN fail by running it while another | |
| # operation is in progress on the connection. | |
| busy = asyncio.ensure_future(self.con.execute('SELECT pg_sleep(0.5)')) | |
| await asyncio.sleep(0.1) | |
| with self.assertRaisesRegex(asyncpg.InterfaceError, | |
| 'another operation is in progress'): | |
| await self.con.transaction().start() | |
| await busy | |
| tr = self.con.transaction() | |
| error = asyncpg.PostgresConnectionError( | |
| 'Timed-out waiting to acquire database connection.' | |
| ) | |
| with mock.patch.object( | |
| type(self.con), 'execute', side_effect=error | |
| ) as execute: | |
| with self.assertRaises(asyncpg.PostgresConnectionError) as caught: | |
| await tr.start() | |
| self.assertIs(caught.exception, error) | |
| execute.assert_awaited_once_with('BEGIN;') |
There was a problem hiding this comment.
Thanks for catching that, Elvis.
The sleep was a bad way to force BEGIN to fail, and a slow loop could miss the window.
I switched the test to patch execute with a PostgresConnectionError, the way you suggested.
It still checks that _top_xact is cleared and that the next transaction() is a real top-level one, because those are the checks that fail without the fix.
The mock only wraps start(), so that follow-up runs on the live connection.
Happy to trim it if you'd rather the test stop where your suggestion did.
|
Hello, just floating this one back up in case it slipped off the list. Thank you, Elvis, for the suggestion on the test, and more broadly for how carefully asyncpg's transaction code is looked after, because that care is what made the faulty state so easy to reason about once it was pointed out. I have switched the test over to patching Connection.execute the way you proposed, so it no longer depends on winning a timing race against the loop. The change itself is two lines in asyncpg/transaction.py, clearing Connection._top_xact in the except branch of Transaction.start() when it still points at the transaction whose BEGIN just failed. Without that clear, nothing ever releases _top_xact after a failed BEGIN, since commit and rollback both refuse to run on a failed transaction, so every later transaction on the connection gets sent as a SAVEPOINT and the server rejects it, which is the behaviour reported in issue #1386. The is self check is there so a failed nested SAVEPOINT leaves the outer transaction untouched, and test_transaction_failed_begin in tests/test_transaction.py checks that _top_xact ends up as None and that the next transaction is a real top level one. The workflow runs on this branch are still waiting for approval to start, so they have not had a chance to go green yet. I would be glad of a second look whenever it suits, and I am happy to trim the test back to exactly your suggestion or reshape it any other way you would prefer. |
Transaction.start()setscon._top_xact = selfbefore it sendsBEGIN.If
BEGINraises, the transaction is markedFAILED, but_top_xactstays pointed at it.Only
__commit()and__rollback()ever clear it, and both refuse to run on a failed transaction, so nothing releases it after that.Every later
conn.transaction()on the connection is then treated as nested and sent asSAVEPOINT, which the server rejects withNoActiveSQLTransactionErroruntil the connection is reset or closed.This change clears
_top_xactin theexceptbranch ofstart()when it still points at the failed transaction, the same way__commit()and__rollback()do.The
is selfcheck leaves the outer transaction alone when a nestedSAVEPOINTfails.I added
test_transaction_failed_begintotests/test_transaction.py. It patchesConnection.executesoBEGINraisesPostgresConnectionError, then checks that the same error comes back, thatexecutewas awaited once withBEGIN;, that_top_xactisNone, and that the nextconn.transaction()opens a real top-level transaction.The test fails if that clear is removed, and passes with the fix. I re-ran
tests.test_transactionon Python 3.12 against Postgres 16, andflake8on the touched files was clean.Fixes #1386