summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--doc/build/changelog/changelog_11.rst15
-rw-r--r--doc/build/changelog/migration_11.rst40
-rw-r--r--lib/sqlalchemy/orm/session.py43
-rw-r--r--test/orm/test_transaction.py33
4 files changed, 116 insertions, 15 deletions
diff --git a/doc/build/changelog/changelog_11.rst b/doc/build/changelog/changelog_11.rst
index 178383b85..0a5dc3ea0 100644
--- a/doc/build/changelog/changelog_11.rst
+++ b/doc/build/changelog/changelog_11.rst
@@ -22,6 +22,21 @@
:version: 1.1.0b1
.. change::
+ :tags: bug, orm, mysql
+ :tickets: 3680
+
+ Further continuing on the common MySQL exception case of
+ a savepoint being cancelled first covered in :ticket:`2696`,
+ the failure mode in which the :class:`.Session` is placed when a
+ SAVEPOINT vanishes before rollback has been improved to allow the
+ :class:`.Session` to still function outside of that savepoint.
+ It is assumed that the savepoint operation failed and was cancelled.
+
+ .. seealso::
+
+ :ref:`change_3680`
+
+ .. change::
:tags: feature, mssql
:tickets: 3534
diff --git a/doc/build/changelog/migration_11.rst b/doc/build/changelog/migration_11.rst
index 3e839af63..cca2b1ae8 100644
--- a/doc/build/changelog/migration_11.rst
+++ b/doc/build/changelog/migration_11.rst
@@ -16,7 +16,7 @@ What's New in SQLAlchemy 1.1?
some issues may be moved to later milestones in order to allow
for a timely release.
- Document last updated: Feburary 25, 2016
+ Document last updated: March 23, 2016
Introduction
============
@@ -290,6 +290,44 @@ time on the outside of the subquery.
:ticket:`3582`
+.. _change_3680:
+
+Improved Session state when a SAVEPOINT is cancelled by the database
+--------------------------------------------------------------------
+
+A common case with MySQL is that a SAVEPOINT is cancelled when a deadlock
+occurs within the transaction. The :class:`.Session` has been modfied
+to deal with this failure mode slightly more gracefully, such that the
+outer, non-savepoint transaction still remains usable::
+
+ s = Session()
+ s.begin_nested()
+
+ s.add(SomeObject())
+
+ try:
+ # assume the flush fails, flush goes to rollback to the
+ # savepoint and that also fails
+ s.flush()
+ except Exception as err:
+ print("Something broke, and our SAVEPOINT vanished too")
+
+ # this is the SAVEPOINT transaction, marked as
+ # DEACTIVE so the rollback() call succeeds
+ s.rollback()
+
+ # this is the outermost transaction, remains ACTIVE
+ # so rollback() or commit() can succeed
+ s.rollback()
+
+This issue is a continuation of :ticket:`2696` where we emit a warning
+so that the original error can be seen when running on Python 2, even though
+the SAVEPOINT exception takes precedence. On Python 3, exceptions are chained
+so both failures are reported individually.
+
+
+:ticket:`3680`
+
.. _change_3677:
Erroneous "new instance X conflicts with persistent instance Y" flush errors fixed
diff --git a/lib/sqlalchemy/orm/session.py b/lib/sqlalchemy/orm/session.py
index 48ff09b87..dc5de7ac6 100644
--- a/lib/sqlalchemy/orm/session.py
+++ b/lib/sqlalchemy/orm/session.py
@@ -235,7 +235,7 @@ class SessionTransaction(object):
return SessionTransaction(
self.session, self, nested=nested)
- def _iterate_parents(self, upto=None):
+ def _iterate_self_and_parents(self, upto=None):
current = self
result = ()
@@ -269,6 +269,11 @@ class SessionTransaction(object):
self._key_switches = weakref.WeakKeyDictionary()
def _restore_snapshot(self, dirty_only=False):
+ """Restore the restoration state taken before a transaction began.
+
+ Corresponds to a rollback.
+
+ """
assert self._is_transaction_boundary
self.session._expunge_states(
@@ -290,6 +295,11 @@ class SessionTransaction(object):
s._expire(s.dict, self.session.identity_map._modified)
def _remove_snapshot(self):
+ """Remove the restoration state taken before a transaction began.
+
+ Corresponds to a commit.
+
+ """
assert self._is_transaction_boundary
if not self.nested and self.session.expire_on_commit:
@@ -358,7 +368,7 @@ class SessionTransaction(object):
stx = self.session.transaction
if stx is not self:
- for subtransaction in stx._iterate_parents(upto=self):
+ for subtransaction in stx._iterate_self_and_parents(upto=self):
subtransaction.commit()
if not self.session._flushing:
@@ -405,14 +415,18 @@ class SessionTransaction(object):
stx = self.session.transaction
if stx is not self:
- for subtransaction in stx._iterate_parents(upto=self):
+ for subtransaction in stx._iterate_self_and_parents(upto=self):
subtransaction.close()
boundary = self
+ rollback_err = None
if self._state in (ACTIVE, PREPARED):
- for transaction in self._iterate_parents():
+ for transaction in self._iterate_self_and_parents():
if transaction._parent is None or transaction.nested:
- transaction._rollback_impl()
+ try:
+ transaction._rollback_impl()
+ except:
+ rollback_err = sys.exc_info()
transaction._state = DEACTIVE
boundary = transaction
break
@@ -421,7 +435,7 @@ class SessionTransaction(object):
sess = self.session
- if sess._enable_transaction_accounting and \
+ if not rollback_err and sess._enable_transaction_accounting and \
not sess._is_clean():
# if items were added, deleted, or mutated
@@ -433,19 +447,24 @@ class SessionTransaction(object):
boundary._restore_snapshot(dirty_only=boundary.nested)
self.close()
+
if self._parent and _capture_exception:
self._parent._rollback_exception = sys.exc_info()[1]
+ if rollback_err:
+ util.reraise(*rollback_err)
+
sess.dispatch.after_soft_rollback(sess, self)
return self._parent
def _rollback_impl(self):
- for t in set(self._connections.values()):
- t[1].rollback()
-
- if self.session._enable_transaction_accounting:
- self._restore_snapshot(dirty_only=self.nested)
+ try:
+ for t in set(self._connections.values()):
+ t[1].rollback()
+ finally:
+ if self.session._enable_transaction_accounting:
+ self._restore_snapshot(dirty_only=self.nested)
self.session.dispatch.after_rollback(self.session)
@@ -1078,7 +1097,7 @@ class Session(_SessionClassMethods):
def _close_impl(self, invalidate):
self.expunge_all()
if self.transaction is not None:
- for transaction in self.transaction._iterate_parents():
+ for transaction in self.transaction._iterate_self_and_parents():
transaction.close(invalidate)
def expunge_all(self):
diff --git a/test/orm/test_transaction.py b/test/orm/test_transaction.py
index c1662c9d1..d912c669f 100644
--- a/test/orm/test_transaction.py
+++ b/test/orm/test_transaction.py
@@ -3,10 +3,10 @@ from sqlalchemy import (
testing, exc as sa_exc, event, String, Column, Table, select, func)
from sqlalchemy.testing import (
fixtures, engines, eq_, assert_raises, assert_raises_message,
- assert_warnings, mock, expect_warnings)
+ assert_warnings, mock, expect_warnings, is_, is_not_)
from sqlalchemy.orm import (
exc as orm_exc, Session, mapper, sessionmaker, create_session,
- relationship, attributes)
+ relationship, attributes, session as _session)
from sqlalchemy.testing.util import gc_collect
from test.orm._fixtures import FixtureTest
from sqlalchemy import inspect
@@ -1290,6 +1290,35 @@ class SavepointTest(_LocalFixture):
assert u1 in s
assert u1 not in s.deleted
+ @testing.requires.savepoints
+ def test_savepoint_lost_still_runs(self):
+ User = self.classes.User
+ s = self.session(bind=self.bind)
+ trans = s.begin_nested()
+ s.connection()
+ u1 = User(name='ed')
+ s.add(u1)
+
+ # kill off the transaction
+ nested_trans = trans._connections[self.bind][1]
+ nested_trans._do_commit()
+
+ is_(s.transaction, trans)
+ assert_raises(
+ sa_exc.DBAPIError,
+ s.rollback
+ )
+
+ assert u1 not in s.new
+
+ is_(trans._state, _session.CLOSED)
+ is_not_(s.transaction, trans)
+ is_(s.transaction._state, _session.ACTIVE)
+
+ is_(s.transaction.nested, False)
+
+ is_(s.transaction._parent, None)
+
class AccountingFlagsTest(_LocalFixture):
__backend__ = True