When tpc_prepare() raised during SessionTransaction._prepare_impl(),
the error handler's call to self.rollback() was blocked by the
@declare_states decorator, which had set _next_state to
CHANGE_IN_PROGRESS. This caused IllegalStateChangeError to be raised
instead of the original database exception, masking the real error
and preventing proper cleanup.
Used _expect_state(SessionTransactionState.CLOSED) to temporarily
allow the rollback state transition, matching the existing pattern
used in commit() for the close() call.
Fixes: #13356
Change-Id: Ie8212d5b6f8515340cf9d83c56dcbfa5a7415812
--- /dev/null
+.. change::
+ :tags: bug, orm
+ :tickets: 13356
+
+ Fixed bug where a failure during ``tpc_prepare()`` within
+ :meth:`_orm.Session.commit` for a two-phase session would raise
+ :class:`.IllegalStateChangeError` instead of the original database
+ exception. The internal ``_prepare_impl()`` method's error handler
+ was unable to invoke :meth:`_orm.SessionTransaction.rollback` due
+ to a state-change guard, preventing proper cleanup and masking the
+ underlying error.
cast("TwoPhaseTransaction", t[1]).prepare()
except:
with util.safe_reraise():
- self.rollback()
+ with self._expect_state(SessionTransactionState.CLOSED):
+ self.rollback()
self._state = SessionTransactionState.PREPARED
assert u not in s
+ @testing.requires.two_phase_transactions
+ def test_rollback_on_prepare_failure(self):
+ """test #13356"""
+
+ User = self.classes.User
+ s = fixture_session(twophase=True)
+
+ u = User(name="ed")
+ s.add(u)
+
+ with mock.patch.object(
+ testing.db.dialect,
+ "do_prepare_twophase",
+ side_effect=Exception("simulated prepare failure"),
+ ):
+ with expect_raises_message(Exception, "simulated prepare failure"):
+ s.commit()
+
+ assert u not in s
+
class RollbackRecoverTest(_LocalFixture):
__sparse_driver_backend__ = True