]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
establish explicit join transaction modes
authorMike Bayer <mike_mp@zzzcomputing.com>
Tue, 20 Dec 2022 17:48:08 +0000 (12:48 -0500)
committerMike Bayer <mike_mp@zzzcomputing.com>
Tue, 27 Dec 2022 21:18:18 +0000 (16:18 -0500)
The behavior of "joining an external transaction into a Session" has been
revised and improved, allowing explicit control over how the
:class:`_orm.Session` will accommodate an incoming
:class:`_engine.Connection` that already has a transaction and possibly a
savepoint already established. The new parameter
:paramref:`_orm.Session.join_transaction_mode` includes a series of option
values which can accommodate the existing transaction in several ways, most
importantly allowing a :class:`_orm.Session` to operate in a fully
transactional style using savepoints exclusively, while leaving the
externally initiated transaction non-committed and active under all
circumstances, allowing test suites to rollback all changes that take place
within tests.

Additionally, revised the :meth:`_orm.Session.close` method to fully close
out savepoints that may still be present, which also allows the
"external transaction" recipe to proceed without warnings if the
:class:`_orm.Session` did not explicitly end its own SAVEPOINT
transactions.

Fixes: #9015
Change-Id: I31c22ee0fd9372fa0eddfe057e76544aee627107

doc/build/changelog/unreleased_20/9015.rst [new file with mode: 0644]
doc/build/changelog/whatsnew_20.rst
doc/build/orm/session_transaction.rst
lib/sqlalchemy/orm/session.py
test/orm/test_session.py
test/orm/test_transaction.py

diff --git a/doc/build/changelog/unreleased_20/9015.rst b/doc/build/changelog/unreleased_20/9015.rst
new file mode 100644 (file)
index 0000000..1d53a2c
--- /dev/null
@@ -0,0 +1,27 @@
+.. change::
+    :tags: usecase, orm
+    :tickets: 9015
+
+    The behavior of "joining an external transaction into a Session" has been
+    revised and improved, allowing explicit control over how the
+    :class:`_orm.Session` will accommodate an incoming
+    :class:`_engine.Connection` that already has a transaction and possibly a
+    savepoint already established. The new parameter
+    :paramref:`_orm.Session.join_transaction_mode` includes a series of option
+    values which can accommodate the existing transaction in several ways, most
+    importantly allowing a :class:`_orm.Session` to operate in a fully
+    transactional style using savepoints exclusively, while leaving the
+    externally initiated transaction non-committed and active under all
+    circumstances, allowing test suites to rollback all changes that take place
+    within tests.
+
+    Additionally, revised the :meth:`_orm.Session.close` method to fully close
+    out savepoints that may still be present, which also allows the
+    "external transaction" recipe to proceed without warnings if the
+    :class:`_orm.Session` did not explicitly end its own SAVEPOINT
+    transactions.
+
+    .. seealso::
+
+        :ref:`change_9015`
+
index 8c23c24cd989d359edaafcb819998e407668854d..d94250d71252ee96db6cfd9538941a604c5865f3 100644 (file)
@@ -1632,6 +1632,112 @@ not otherwise part of the major 1.4->2.0 migration path; changes here are
 not expected to have significant effects on backwards compatibility.
 
 
+.. _change_9015:
+
+New transaction join modes for ``Session``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+The behavior of "joining an external transaction into a Session" has been
+revised and improved, allowing explicit control over how the
+:class:`_orm.Session` will accommodate an incoming :class:`_engine.Connection`
+that already has a transaction and possibly a savepoint already established.
+The new parameter :paramref:`_orm.Session.join_transaction_mode` includes a
+series of option values which can accommodate the existing transaction in
+several ways, most importantly allowing a :class:`_orm.Session` to operate in a
+fully transactional style using savepoints exclusively, while leaving the
+externally initiated transaction non-committed and active under all
+circumstances, allowing test suites to rollback all changes that take place
+within tests.
+
+The primary improvement this allows is that the recipe documented at
+:ref:`session_external_transaction`, which also changed from SQLAlchemy 1.3
+to 1.4, is now simplified to no longer require explicit use of an event
+handler or any mention of an explicit savepoint; by using
+``join_transaction_mode="create_savepoint"``, the :class:`_orm.Session` will
+never affect the state of an incoming transaction, and will instead create a
+savepoint (i.e. "nested transaction") as its root transaction.
+
+The following illustrates part of the example given at
+:ref:`session_external_transaction`; see that section for a full example::
+
+    class SomeTest(TestCase):
+        def setUp(self):
+            # connect to the database
+            self.connection = engine.connect()
+
+            # begin a non-ORM transaction
+            self.trans = self.connection.begin()
+
+            # bind an individual Session to the connection, selecting
+            # "create_savepoint" join_transaction_mode
+            self.session = Session(
+                bind=self.connection, join_transaction_mode="create_savepoint"
+            )
+
+        def tearDown(self):
+            self.session.close()
+
+            # rollback non-ORM transaction
+            self.trans.rollback()
+
+            # return connection to the Engine
+            self.connection.close()
+
+The default mode selected for :paramref:`_orm.Session.join_transaction_mode`
+is ``"conditional_savepoint"``, which uses ``"create_savepoint"`` behavior
+if the given :class:`_engine.Connection` is itself already on a savepoint.
+If the given :class:`_engine.Connection` is in a transaction but not a
+savepoint, the :class:`_orm.Session` will propagate "rollback" calls
+but not "commit" calls, but will not begin a new savepoint on its own.  This
+behavior is chosen by default for its maximum compatibility with
+older SQLAlchemy versions as well as that it does not start a new SAVEPOINT
+unless the given driver is already making use of SAVEPOINT, as support
+for SAVEPOINT varies not only with specific backend and driver but also
+configurationally.
+
+The following illustrates a case that worked in SQLAlchemy 1.3, stopped working
+in SQLAlchemy 1.4, and is now restored in SQLAlchemy 2.0::
+
+    engine = create_engine("...")
+
+    # setup outer connection with a transaction and a SAVEPOINT
+    conn = engine.connect()
+    trans = conn.begin()
+    nested = conn.begin_nested()
+
+    # bind a Session to that connection and operate upon it, including
+    # a commit
+    session = Session(conn)
+    session.connection()
+    session.commit()
+    session.close()
+
+    # assert both SAVEPOINT and transaction remain active
+    assert nested.is_active
+    nested.rollback()
+    trans.rollback()
+
+Where above, a :class:`_orm.Session` is joined to a :class:`_engine.Connection`
+that has a savepoint started on it; the state of these two units remains
+unchanged after the :class:`_orm.Session` has worked with the transaction. In
+SQLAlchemy 1.3, the above case worked because the :class:`_orm.Session` would
+begin a "subtransaction" upon the :class:`_engine.Connection`, which would
+allow the outer savepoint / transaction to remain unaffected for simple cases
+as above. Since subtransactions were deprecated in 1.4 and are now removed in
+2.0, this behavior was no longer available. The new default behavior improves
+upon the behavior of "subtransactions" by using a real, second SAVEPOINT
+instead, so that even calls to :meth:`_orm.Session.rollback` prevent the
+:class:`_orm.Session` from "breaking out" into the externally initiated
+SAVEPOINT or transaction.
+
+New code that is joining a transaction-started :class:`_engine.Connection` into
+a :class:`_orm.Session` should however select a
+:paramref:`_orm.Session.join_transaction_mode` explicitly, so that the desired
+behavior is explicitly defined.
+
+:ticket:`9015`
+
+
 .. _Cython: https://cython.org/
 
 .. _change_8567:
index aca558d6ff5f0aaaa9e7b7af287f857e0ae594ad..153d4ea0545e02eca487e7619ada07ac6fda34eb 100644 (file)
@@ -642,20 +642,21 @@ is a test suite that allows ORM code to work freely with a :class:`.Session`,
 including the ability to call :meth:`.Session.commit`, where afterwards the
 entire database interaction is rolled back.
 
-.. versionchanged:: 1.4  This section introduces a new version of the
-   "join into an external transaction" recipe that will work equally well
-   for both :term:`2.0 style` and :term:`1.x style` engines and sessions.
-   The recipe here from previous versions such as 1.3 will also continue to
-   work for 1.x engines and sessions.
-
+.. versionchanged:: 2.0 The "join into an external transaction" recipe is
+   newly improved again in 2.0; event handlers to "reset" the nested
+   transaction are no longer required.
 
 The recipe works by establishing a :class:`_engine.Connection` within a
-transaction and optionally a SAVEPOINT, then passing it to a :class:`_orm.Session` as the
-"bind".   The :class:`_orm.Session` detects that the given :class:`_engine.Connection`
-is already in a transaction and will not run COMMIT on it if the transaction
-is in fact an outermost transaction.   Then when the test tears down, the
-transaction is rolled back so that any data changes throughout the test
-are reverted::
+transaction and optionally a SAVEPOINT, then passing it to a
+:class:`_orm.Session` as the "bind"; the
+:paramref:`_orm.Session.join_transaction_mode` parameter is passed with the
+setting ``"create_savepoint"``, which indicates that new SAVEPOINTs should be
+created in order to implement BEGIN/COMMIT/ROLLBACK for the
+:class:`_orm.Session`, which will leave the external transaction in the same
+state in which it was passed.
+
+When the test tears down, the external transaction is rolled back so that any
+data changes throughout the test are reverted::
 
     from sqlalchemy.orm import sessionmaker
     from sqlalchemy import create_engine
@@ -675,23 +676,11 @@ are reverted::
             # begin a non-ORM transaction
             self.trans = self.connection.begin()
 
-            # bind an individual Session to the connection
-            self.session = Session(bind=self.connection)
-
-            ###    optional     ###
-
-            # if the database supports SAVEPOINT (SQLite needs special
-            # config for this to work), starting a savepoint
-            # will allow tests to also use rollback within tests
-
-            self.nested = self.connection.begin_nested()
-
-            @event.listens_for(self.session, "after_transaction_end")
-            def end_savepoint(session, transaction):
-                if not self.nested.is_active:
-                    self.nested = self.connection.begin_nested()
-
-            ### ^^^ optional ^^^ ###
+            # bind an individual Session to the connection, selecting
+            # "create_savepoint" join_transaction_mode
+            self.session = Session(
+                bind=self.connection, join_transaction_mode="create_savepoint"
+            )
 
         def test_something(self):
             # use the session in tests.
@@ -700,8 +689,6 @@ are reverted::
             self.session.commit()
 
         def test_something_with_rollbacks(self):
-            # if the SAVEPOINT steps are taken, then a test can also
-            # use session.rollback() and continue working with the database
 
             self.session.add(Bar())
             self.session.flush()
index 8b5f7c88ab8866dbcbef22cb3e6ab700dce0302f..f1ff5663433f813a752ec2a576836ce0c5967168 100644 (file)
@@ -155,6 +155,13 @@ _EntityBindKey = Union[Type[_O], "Mapper[_O]"]
 _SessionBindKey = Union[Type[Any], "Mapper[Any]", "Table", str]
 _SessionBind = Union["Engine", "Connection"]
 
+JoinTransactionMode = Literal[
+    "conditional_savepoint",
+    "rollback_only",
+    "control_fully",
+    "create_savepoint",
+]
+
 
 class _ConnectionCallableProto(Protocol):
     """a callable that returns a :class:`.Connection` given an instance.
@@ -1097,17 +1104,36 @@ class SessionTransaction(_StateChange, TransactionalContext):
 
             transaction: Transaction
             if self.session.twophase and self._parent is None:
+                # TODO: shouldn't we only be here if not
+                # conn.in_transaction() ?
+                # if twophase is set and conn.in_transaction(), validate
+                # that it is in fact twophase.
                 transaction = conn.begin_twophase()
             elif self.nested:
                 transaction = conn.begin_nested()
             elif conn.in_transaction():
-                # if given a future connection already in a transaction, don't
-                # commit that transaction unless it is a savepoint
-                if conn.in_nested_transaction():
-                    transaction = conn._get_required_nested_transaction()
+                join_transaction_mode = self.session.join_transaction_mode
+
+                if join_transaction_mode == "conditional_savepoint":
+                    if conn.in_nested_transaction():
+                        join_transaction_mode = "create_savepoint"
+                    else:
+                        join_transaction_mode = "rollback_only"
+
+                if join_transaction_mode in (
+                    "control_fully",
+                    "rollback_only",
+                ):
+                    if conn.in_nested_transaction():
+                        transaction = conn._get_required_nested_transaction()
+                    else:
+                        transaction = conn._get_required_transaction()
+                    if join_transaction_mode == "rollback_only":
+                        should_commit = False
+                elif join_transaction_mode == "create_savepoint":
+                    transaction = conn.begin_nested()
                 else:
-                    transaction = conn._get_required_transaction()
-                    should_commit = False
+                    assert False, join_transaction_mode
             else:
                 transaction = conn.begin()
         except:
@@ -1274,6 +1300,7 @@ class SessionTransaction(_StateChange, TransactionalContext):
         _StateChangeStates.ANY, SessionTransactionState.CLOSED
     )
     def close(self, invalidate: bool = False) -> None:
+
         if self.nested:
             self.session._nested_transaction = (
                 self._previous_nested_transaction
@@ -1281,16 +1308,15 @@ class SessionTransaction(_StateChange, TransactionalContext):
 
         self.session._transaction = self._parent
 
-        if self._parent is None:
-            for connection, transaction, should_commit, autoclose in set(
-                self._connections.values()
-            ):
-                if invalidate:
-                    connection.invalidate()
-                if should_commit and transaction.is_active:
-                    transaction.close()
-                if autoclose:
-                    connection.close()
+        for connection, transaction, should_commit, autoclose in set(
+            self._connections.values()
+        ):
+            if invalidate and self._parent is None:
+                connection.invalidate()
+            if should_commit and transaction.is_active:
+                transaction.close()
+            if autoclose and self._parent is None:
+                connection.close()
 
         self._state = SessionTransactionState.CLOSED
         sess = self.session
@@ -1357,6 +1383,7 @@ class Session(_SessionClassMethods, EventTarget):
     expire_on_commit: bool
     enable_baked_queries: bool
     twophase: bool
+    join_transaction_mode: JoinTransactionMode
     _query_cls: Type[Query[Any]]
 
     def __init__(
@@ -1373,6 +1400,7 @@ class Session(_SessionClassMethods, EventTarget):
         info: Optional[_InfoType] = None,
         query_cls: Optional[Type[Query[Any]]] = None,
         autocommit: Literal[False] = False,
+        join_transaction_mode: JoinTransactionMode = "conditional_savepoint",
     ):
         r"""Construct a new Session.
 
@@ -1502,6 +1530,85 @@ class Session(_SessionClassMethods, EventTarget):
         :param autocommit: the "autocommit" keyword is present for backwards
             compatibility but must remain at its default value of ``False``.
 
+        :param join_transaction_mode: Describes the transactional behavior to
+          take when a given bind is a :class:`_engine.Connection` that
+          has already begun a transaction outside the scope of this
+          :class:`_orm.Session`; in other words the
+          :meth:`_engine.Connection.in_transaction()` method returns True.
+
+          The following behaviors only take effect when the :class:`_orm.Session`
+          **actually makes use of the connection given**; that is, a method
+          such as :meth:`_orm.Session.execute`, :meth:`_orm.Session.connection`,
+          etc. are actually invoked:
+
+          * ``"conditional_savepoint"`` - this is the default.  if the given
+            :class:`_engine.Connection` is begun within a transaction but
+            does not have a SAVEPOINT, then ``"rollback_only"`` is used.
+            If the :class:`_engine.Connection` is additionally within
+            a SAVEPOINT, in other words
+            :meth:`_engine.Connection.in_nested_transaction()` method returns
+            True, then ``"create_savepoint"`` is used.
+
+            ``"conditional_savepoint"`` behavior attempts to make use of
+            savepoints in order to keep the state of the existing transaction
+            unchanged, but only if there is already a savepoint in progress;
+            otherwise, it is not assumed that the backend in use has adequate
+            support for SAVEPOINT, as availability of this feature varies.
+            ``"conditional_savepoint"`` also seeks to establish approximate
+            backwards compatibility with previous :class:`_orm.Session`
+            behavior, for applications that are not setting a specific mode. It
+            is recommended that one of the explicit settings be used.
+
+          * ``"create_savepoint"`` - the :class:`_orm.Session` will use
+            :meth:`_engine.Connection.begin_nested()` in all cases to create
+            its own transaction.  This transaction by its nature rides
+            "on top" of any existing transaction that's opened on the given
+            :class:`_engine.Connection`; if the underlying database and
+            the driver in use has full, non-broken support for SAVEPOINT, the
+            external transaction will remain unaffected throughout the
+            lifespan of the :class:`_orm.Session`.
+
+            The ``"create_savepoint"`` mode is the most useful for integrating
+            a :class:`_orm.Session` into a test suite where an externally
+            initiated transaction should remain unaffected; however, it relies
+            on proper SAVEPOINT support from the underlying driver and
+            database.
+
+            .. tip:: When using SQLite, the SQLite driver included through
+               Python 3.11 does not handle SAVEPOINTs correctly in all cases
+               without workarounds. See the section
+               :ref:`pysqlite_serializable` for details on current workarounds.
+
+          * ``"control_fully"`` - the :class:`_orm.Session` will take
+            control of the given transaction as its own;
+            :meth:`_orm.Session.commit` will call ``.commit()`` on the
+            transaction, :meth:`_orm.Session.rollback` will call
+            ``.rollback()`` on the transaction, :meth:`_orm.Session.close` will
+            call ``.rollback`` on the transaction.
+
+            .. tip:: This mode of use is equivalent to how SQLAlchemy 1.4 would
+               handle a :class:`_engine.Connection` given with an existing
+               SAVEPOINT (i.e. :meth:`_engine.Connection.begin_nested`); the
+               :class:`_orm.Session` would take full control of the existing
+               SAVEPOINT.
+
+          * ``"rollback_only"`` - the :class:`_orm.Session` will take control
+            of the given transaction for ``.rollback()`` calls only;
+            ``.commit()`` calls will not be propagated to the given
+            transaction.  ``.close()`` calls will have no effect on the
+            given transaction.
+
+            .. tip:: This mode of use is equivalent to how SQLAlchemy 1.4 would
+               handle a :class:`_engine.Connection` given with an existing
+               regular database transaction (i.e.
+               :meth:`_engine.Connection.begin`); the :class:`_orm.Session`
+               would propagate :meth:`_orm.Session.rollback` calls to the
+               underlying transaction, but not :meth:`_orm.Session.commit` or
+               :meth:`_orm.Session.close` calls.
+
+          .. versionadded:: 2.0.0b5
+
+
         """  # noqa
 
         # considering allowing the "autocommit" keyword to still be accepted
@@ -1533,6 +1640,16 @@ class Session(_SessionClassMethods, EventTarget):
         self.autoflush = autoflush
         self.expire_on_commit = expire_on_commit
         self.enable_baked_queries = enable_baked_queries
+        if (
+            join_transaction_mode
+            and join_transaction_mode
+            not in JoinTransactionMode.__args__  # type: ignore
+        ):
+            raise sa_exc.ArgumentError(
+                f"invalid selection for join_transaction_mode: "
+                f'"{join_transaction_mode}"'
+            )
+        self.join_transaction_mode = join_transaction_mode
 
         self.twophase = twophase
         self._query_cls = query_cls if query_cls else query.Query
index 921c55f74a74efb6d0694d773f4dd42f3b494f47..5a0431788dd060276162bf74eb08b4a224d13c64 100644 (file)
@@ -2057,6 +2057,13 @@ class SessionInterface(fixtures.MappedTest):
             watchdog.symmetric_difference(self._class_methods),
         )
 
+    def test_join_transaction_mode(self):
+        with expect_raises_message(
+            sa.exc.ArgumentError,
+            'invalid selection for join_transaction_mode: "bogus"',
+        ):
+            Session(join_transaction_mode="bogus")
+
     def test_unmapped_instance(self):
         class Unmapped:
             pass
index f66908fc9c9d73bdb1954148a38c25dfc2cc72d2..c7df66e9d5604c17c63f2ff702c4fd50b012e9ee 100644 (file)
@@ -1,4 +1,9 @@
+from __future__ import annotations
+
 import contextlib
+import random
+from typing import Optional
+from typing import TYPE_CHECKING
 
 from sqlalchemy import Column
 from sqlalchemy import event
@@ -32,10 +37,15 @@ from sqlalchemy.testing import fixtures
 from sqlalchemy.testing import is_
 from sqlalchemy.testing import is_not
 from sqlalchemy.testing import mock
+from sqlalchemy.testing.config import Variation
 from sqlalchemy.testing.fixtures import fixture_session
 from sqlalchemy.testing.util import gc_collect
 from test.orm._fixtures import FixtureTest
 
+if TYPE_CHECKING:
+    from sqlalchemy import NestedTransaction
+    from sqlalchemy import Transaction
+
 
 class SessionTransactionTest(fixtures.RemovesEvents, FixtureTest):
     run_inserts = None
@@ -98,6 +108,142 @@ class SessionTransactionTest(fixtures.RemovesEvents, FixtureTest):
         trans.commit()
         assert len(sess.query(User).all()) == 1
 
+    @testing.variation(
+        "join_transaction_mode",
+        [
+            "none",
+            "conditional_savepoint",
+            "create_savepoint",
+            "control_fully",
+            "rollback_only",
+        ],
+    )
+    @testing.variation("operation", ["commit", "close", "rollback", "nothing"])
+    @testing.variation("external_state", ["none", "transaction", "savepoint"])
+    def test_join_transaction_modes(
+        self,
+        connection_no_trans,
+        join_transaction_mode,
+        operation,
+        external_state: testing.Variation,
+    ):
+        """test new join_transaction modes added in #9015"""
+
+        connection = connection_no_trans
+
+        t1: Optional[Transaction]
+        s1: Optional[NestedTransaction]
+
+        if external_state.none:
+            t1 = s1 = None
+        elif external_state.transaction:
+            t1 = connection.begin()
+            s1 = None
+        elif external_state.savepoint:
+            t1 = connection.begin()
+            s1 = connection.begin_nested()
+        else:
+            external_state.fail()
+
+        if join_transaction_mode.none:
+            sess = Session(connection)
+        else:
+            sess = Session(
+                connection, join_transaction_mode=join_transaction_mode.name
+            )
+
+        sess.connection()
+
+        if operation.close:
+            sess.close()
+        elif operation.commit:
+            sess.commit()
+        elif operation.rollback:
+            sess.rollback()
+        elif operation.nothing:
+            pass
+        else:
+            operation.fail()
+
+        if external_state.none:
+            if operation.nothing:
+                assert connection.in_transaction()
+            else:
+                assert not connection.in_transaction()
+
+        elif external_state.transaction:
+
+            assert t1 is not None
+
+            if (
+                join_transaction_mode.none
+                or join_transaction_mode.conditional_savepoint
+                or join_transaction_mode.rollback_only
+            ):
+                if operation.rollback:
+                    assert t1._deactivated_from_connection
+                    assert not t1.is_active
+                else:
+                    assert not t1._deactivated_from_connection
+                    assert t1.is_active
+            elif join_transaction_mode.create_savepoint:
+                assert not t1._deactivated_from_connection
+                assert t1.is_active
+            elif join_transaction_mode.control_fully:
+                if operation.nothing:
+                    assert not t1._deactivated_from_connection
+                    assert t1.is_active
+                else:
+                    assert t1._deactivated_from_connection
+                    assert not t1.is_active
+            else:
+                join_transaction_mode.fail()
+
+            if t1.is_active:
+                t1.rollback()
+        elif external_state.savepoint:
+            assert s1 is not None
+            assert t1 is not None
+
+            assert not t1._deactivated_from_connection
+            assert t1.is_active
+
+            if join_transaction_mode.rollback_only:
+                if operation.rollback:
+                    assert s1._deactivated_from_connection
+                    assert not s1.is_active
+                else:
+                    assert not s1._deactivated_from_connection
+                    assert s1.is_active
+            elif join_transaction_mode.control_fully:
+                if operation.nothing:
+                    assert not s1._deactivated_from_connection
+                    assert s1.is_active
+                else:
+                    assert s1._deactivated_from_connection
+                    assert not s1.is_active
+            else:
+                if operation.nothing:
+                    # session is still open in the sub-savepoint,
+                    # so we are not activated on connection
+                    assert s1._deactivated_from_connection
+
+                    # but we are still an active savepoint
+                    assert s1.is_active
+
+                    # close session, then we're good
+                    sess.close()
+
+                assert not s1._deactivated_from_connection
+                assert s1.is_active
+
+            if s1.is_active:
+                s1.rollback()
+            if t1.is_active:
+                t1.rollback()
+        else:
+            external_state.fail()
+
     def test_subtransaction_on_external_commit(self, connection_no_trans):
         users, User = self.tables.users, self.classes.User
 
@@ -2351,11 +2497,68 @@ class JoinIntoAnExternalTransactionFixture:
         eq_(result, count)
 
 
+class CtxManagerJoinIntoAnExternalTransactionFixture(
+    JoinIntoAnExternalTransactionFixture
+):
+    @testing.requires.compat_savepoints
+    def test_something_with_context_managers(self):
+        A = self.A
+
+        a1 = A()
+
+        with self.session.begin():
+            self.session.add(a1)
+            self.session.flush()
+
+            self._assert_count(1)
+            self.session.rollback()
+
+        self._assert_count(0)
+
+        a1 = A()
+        with self.session.begin():
+            self.session.add(a1)
+
+        self._assert_count(1)
+
+        a2 = A()
+
+        with self.session.begin():
+            self.session.add(a2)
+            self.session.flush()
+            self._assert_count(2)
+
+            self.session.rollback()
+        self._assert_count(1)
+
+    @testing.requires.compat_savepoints
+    def test_super_abusive_nesting(self):
+        session = self.session
+
+        for i in range(random.randint(5, 30)):
+            choice = random.randint(1, 3)
+            if choice == 1:
+                if session.in_transaction():
+                    session.begin_nested()
+                else:
+                    session.begin()
+            elif choice == 2:
+                session.rollback()
+            elif choice == 3:
+                session.commit()
+
+            session.connection()
+
+        # remaining nested / etc. are cleanly cleared out
+        session.close()
+
+
 class NewStyleJoinIntoAnExternalTransactionTest(
-    JoinIntoAnExternalTransactionFixture, fixtures.MappedTest
+    CtxManagerJoinIntoAnExternalTransactionFixture, fixtures.MappedTest
 ):
-    """A new recipe for "join into an external transaction" that works
-    for both legacy and future engines/sessions
+    """test the 1.4 join to an external transaction fixture.
+
+    In 1.4, this works for both legacy and future engines/sessions
 
     """
 
@@ -2390,42 +2593,75 @@ class NewStyleJoinIntoAnExternalTransactionTest(
         if self.trans.is_active:
             self.trans.rollback()
 
-    @testing.requires.compat_savepoints
-    def test_something_with_context_managers(self):
-        A = self.A
 
-        a1 = A()
+@testing.combinations(
+    *Variation.generate_cases(
+        "join_mode",
+        [
+            "create_savepoint",
+            "conditional_w_savepoint",
+            "create_savepoint_w_savepoint",
+        ],
+    ),
+    argnames="join_mode",
+    id_="s",
+)
+class ReallyNewJoinIntoAnExternalTransactionTest(
+    CtxManagerJoinIntoAnExternalTransactionFixture, fixtures.MappedTest
+):
+    """2.0 only recipe for "join into an external transaction" that works
+    without event handlers
 
-        with self.session.begin():
-            self.session.add(a1)
-            self.session.flush()
+    """
 
-            self._assert_count(1)
-            self.session.rollback()
+    def setup_session(self):
+        self.trans = self.connection.begin()
 
-        self._assert_count(0)
+        if (
+            self.join_mode.conditional_w_savepoint
+            or self.join_mode.create_savepoint_w_savepoint
+        ):
+            self.nested = self.connection.begin_nested()
 
-        a1 = A()
-        with self.session.begin():
-            self.session.add(a1)
+        class A:
+            pass
 
-        self._assert_count(1)
+        clear_mappers()
+        self.mapper_registry.map_imperatively(A, self.table)
+        self.A = A
 
-        a2 = A()
+        self.session = Session(
+            self.connection,
+            join_transaction_mode="create_savepoint"
+            if (
+                self.join_mode.create_savepoint
+                or self.join_mode.create_savepoint_w_savepoint
+            )
+            else "conditional_savepoint",
+        )
 
-        with self.session.begin():
-            self.session.add(a2)
-            self.session.flush()
-            self._assert_count(2)
+    def teardown_session(self):
+        self.session.close()
 
-            self.session.rollback()
-        self._assert_count(1)
+        if (
+            self.join_mode.conditional_w_savepoint
+            or self.join_mode.create_savepoint_w_savepoint
+        ):
+            assert not self.nested._deactivated_from_connection
+            assert self.nested.is_active
+            self.nested.rollback()
+
+        assert not self.trans._deactivated_from_connection
+        assert self.trans.is_active
+        self.trans.rollback()
 
 
 class LegacyJoinIntoAnExternalTransactionTest(
     JoinIntoAnExternalTransactionFixture,
     fixtures.MappedTest,
 ):
+    """test the 1.3 join to an external transaction fixture"""
+
     def setup_session(self):
         # begin a non-ORM transaction
         self.trans = self.connection.begin()