]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
implement skip_autocommit_rollback
authorMike Bayer <mike_mp@zzzcomputing.com>
Tue, 5 Aug 2025 14:46:21 +0000 (10:46 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Wed, 6 Aug 2025 18:37:55 +0000 (14:37 -0400)
Added new parameter :paramref:`.create_engine.skip_autocommit_rollback`
which provides for a per-dialect feature of preventing the DBAPI
``.rollback()`` from being called under any circumstances, if the
connection is detected as being in "autocommit" mode.   This improves upon
a critical performance issue identified in MySQL dialects where the network
overhead of the ``.rollback()`` call remains prohibitive even if autocommit
mode is set.

Fixes: #12784
Change-Id: I22b45ab2fc396c5aadeff5cdc5ce895144d00098

22 files changed:
doc/build/changelog/unreleased_20/12784.rst [new file with mode: 0644]
doc/build/core/connections.rst
doc/build/core/pooling.rst
lib/sqlalchemy/connectors/pyodbc.py
lib/sqlalchemy/dialects/mysql/aiomysql.py
lib/sqlalchemy/dialects/mysql/asyncmy.py
lib/sqlalchemy/dialects/mysql/mariadbconnector.py
lib/sqlalchemy/dialects/mysql/mysqlconnector.py
lib/sqlalchemy/dialects/mysql/mysqldb.py
lib/sqlalchemy/dialects/oracle/cx_oracle.py
lib/sqlalchemy/dialects/postgresql/_psycopg_common.py
lib/sqlalchemy/dialects/postgresql/asyncpg.py
lib/sqlalchemy/dialects/postgresql/pg8000.py
lib/sqlalchemy/dialects/sqlite/pysqlite.py
lib/sqlalchemy/engine/base.py
lib/sqlalchemy/engine/create.py
lib/sqlalchemy/engine/default.py
lib/sqlalchemy/engine/interfaces.py
lib/sqlalchemy/testing/requirements.py
lib/sqlalchemy/testing/suite/test_dialect.py
test/engine/test_logging.py
test/requirements.py

diff --git a/doc/build/changelog/unreleased_20/12784.rst b/doc/build/changelog/unreleased_20/12784.rst
new file mode 100644 (file)
index 0000000..ee1eeb0
--- /dev/null
@@ -0,0 +1,15 @@
+.. change::
+    :tags: usecase, engine
+    :tickets: 12784
+
+    Added new parameter :paramref:`.create_engine.skip_autocommit_rollback`
+    which provides for a per-dialect feature of preventing the DBAPI
+    ``.rollback()`` from being called under any circumstances, if the
+    connection is detected as being in "autocommit" mode.   This improves upon
+    a critical performance issue identified in MySQL dialects where the network
+    overhead of the ``.rollback()`` call remains prohibitive even if autocommit
+    mode is set.
+
+    .. seealso::
+
+        :ref:`dbapi_autocommit_skip_rollback`
index 030d41cd3b3947db1eb0763385fa8a0b348c49a2..e1c25474e525348aad1925e2f0d3307fb5f1123c 100644 (file)
@@ -285,7 +285,7 @@ that loses not only "read committed" but also loses atomicity.
   :ref:`dbapi_autocommit_understanding`, that "autocommit" isolation level like
   any other isolation level does **not** affect the "transactional" behavior of
   the :class:`_engine.Connection` object, which continues to call upon DBAPI
-  ``.commit()`` and ``.rollback()`` methods (they just have no effect under
+  ``.commit()`` and ``.rollback()`` methods (they just have no net effect under
   autocommit), and for which the ``.begin()`` method assumes the DBAPI will
   start a transaction implicitly (which means that SQLAlchemy's "begin" **does
   not change autocommit mode**).
@@ -340,6 +340,8 @@ begin a transaction::
    set at this level. This because the option must be set on a DBAPI connection
    on a per-transaction basis.
 
+.. _dbapi_autocommit_engine:
+
 Setting Isolation Level or DBAPI Autocommit for an Engine
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
@@ -358,14 +360,20 @@ With the above setting, each new DBAPI connection the moment it's created will
 be set to use a ``"REPEATABLE READ"`` isolation level setting for all
 subsequent operations.
 
+.. tip::
+
+    Prefer to set frequently used isolation levels engine wide as illustrated
+    above compared to using per-engine or per-connection execution options for
+    maximum performance.
+
 .. _dbapi_autocommit_multiple:
 
 Maintaining Multiple Isolation Levels for a Single Engine
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
 The isolation level may also be set per engine, with a potentially greater
-level of flexibility, using either the
-:paramref:`_sa.create_engine.execution_options` parameter to
+level of flexibility but with a small per-connection performance overhead,
+using either the :paramref:`_sa.create_engine.execution_options` parameter to
 :func:`_sa.create_engine` or the :meth:`_engine.Engine.execution_options`
 method, the latter of which will create a copy of the :class:`.Engine` that
 shares the dialect and connection pool of the original engine, but has its own
@@ -408,6 +416,14 @@ copy of the original :class:`_engine.Engine`.  Both ``eng`` and
 The isolation level setting, regardless of which one it is, is unconditionally
 reverted when a connection is returned to the connection pool.
 
+.. note::
+
+    The execution options approach, whether used engine wide or per connection,
+    incurs a small performance penalty as isolation level instructions
+    are sent on connection acquire as well as connection release.   Consider
+    the engine-wide isolation setting at :ref:`dbapi_autocommit_engine` so
+    that connections are configured at the target isolation level permanently
+    as they are pooled.
 
 .. seealso::
 
@@ -457,8 +473,9 @@ committed, this rollback has no change on the state of the database.
 
 It is important to note that "autocommit" mode
 persists even when the :meth:`_engine.Connection.begin` method is called;
-the DBAPI will not emit any BEGIN to the database, nor will it emit
-COMMIT when :meth:`_engine.Connection.commit` is called.  This usage is also
+the DBAPI will not emit any BEGIN to the database.   When
+:meth:`_engine.Connection.commit` is called, the DBAPI may still emit the
+"COMMIT" instruction, but this is a no-op at the database level.  This usage is also
 not an error scenario, as it is expected that the "autocommit" isolation level
 may be applied to code that otherwise was written assuming a transactional context;
 the "isolation level" is, after all, a configurational detail of the transaction
@@ -483,7 +500,7 @@ it probably will have no effect due to autocommit mode:
 
     INFO sqlalchemy.engine.Engine BEGIN (implicit)
     ...
-    INFO sqlalchemy.engine.Engine COMMIT using DBAPI connection.commit(), DBAPI should ignore due to autocommit mode
+    INFO sqlalchemy.engine.Engine COMMIT using DBAPI connection.commit(), has no effect due to autocommit mode
 
 At the same time, even though we are using "DBAPI autocommit", SQLAlchemy's
 transactional semantics, that is, the in-Python behavior of :meth:`_engine.Connection.begin`
@@ -514,6 +531,43 @@ maintain a completely consistent usage pattern with the
 :class:`_engine.Connection` where DBAPI-autocommit mode can be changed
 independently without indicating any code changes elsewhere.
 
+.. _dbapi_autocommit_skip_rollback:
+
+Fully preventing ROLLBACK calls under autocommit
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+.. versionadded:: 2.0.43
+
+A common use case is to use AUTOCOMMIT isolation mode to improve performance,
+and this is a particularly common practice on MySQL / MariaDB databases.
+When seeking this pattern, it should be preferred to set AUTOCOMMIT engine
+wide using the :paramref:`.create_engine.isolation_level` so that pooled
+connections are permanently set in autocommit mode.   The SQLAlchemy connection
+pool as well as the :class:`.Connection` will still seek to invoke the DBAPI
+``.rollback()`` method upon connection :term:`reset`, as their behavior
+remains agonstic of the isolation level that's configured on the connection.
+As this rollback still incurs a network round trip under most if not all
+DBAPI drivers, this additional network trip may be disabled using the
+:paramref:`.create_engine.skip_autocommit_rollback` parameter, which will
+apply a rule at the basemost portion of the dialect that invokes DBAPI
+``.rollback()`` to first check if the connection is configured in autocommit,
+using a method of detection that does not itself incur network overhead::
+
+    autocommit_engine = create_engine(
+        "mysql+mysqldb://scott:tiger@mysql80/test",
+        skip_autocommit_rollback=True,
+        isolation_level="AUTOCOMMIT",
+    )
+
+When DBAPI connections are returned to the pool by the :class:`.Connection`,
+whether the :class:`.Connection` or the pool attempts to reset the
+"transaction", the underlying DBAPI ``.rollback()`` method will be blocked
+based on a positive test of "autocommit".
+
+If the dialect in use does not support a no-network means of detecting
+autocommit, the dialect will raise ``NotImplementedError`` when a connection
+release is attempted.
+
 Changing Between Isolation Levels
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
index 21ce165fe332d6066e1fcd8608b40f6ff0f8a98d..6b75ea9fcd5f79d1cd1ed57afb943f5fd30b9b15 100644 (file)
@@ -134,8 +134,14 @@ The pool includes "reset on return" behavior which will call the ``rollback()``
 method of the DBAPI connection when the connection is returned to the pool.
 This is so that any existing transactional state is removed from the
 connection, which includes not just uncommitted data but table and row locks as
-well. For most DBAPIs, the call to ``rollback()`` is inexpensive, and if the
-DBAPI has already completed a transaction, the method should be a no-op.
+well. For most DBAPIs, the call to ``rollback()`` is relatively inexpensive.
+
+The "reset on return" feature takes place when a connection is :term:`released`
+back to the connection pool.  In modern SQLAlchemy, this reset on return
+behavior is shared between the :class:`.Connection` and the :class:`.Pool`,
+where the :class:`.Connection` itself, if it releases its transaction upon close,
+considers ``.rollback()`` to have been called, and instructs the pool to skip
+this step.
 
 
 Disabling Reset on Return for non-transactional connections
@@ -146,24 +152,39 @@ using a connection that is configured for
 :ref:`autocommit <dbapi_autocommit_understanding>` or when using a database
 that has no ACID capabilities such as the MyISAM engine of MySQL, the
 reset-on-return behavior can be disabled, which is typically done for
-performance reasons. This can be affected by using the
+performance reasons.
+
+As of SQLAlchemy 2.0.43, the :paramref:`.create_engine.skip_autocommit_rollback`
+parameter of :func:`.create_engine` provides the most complete means of
+preventing ROLLBACK from being emitted while under autocommit mode, as it
+blocks the DBAPI ``.rollback()`` method from being called by the dialect
+completely::
+
+    autocommit_engine = create_engine(
+        "mysql+mysqldb://scott:tiger@mysql80/test",
+        skip_autocommit_rollback=True,
+        isolation_level="AUTOCOMMIT",
+    )
+
+Detail on this pattern is at :ref:`dbapi_autocommit_skip_rollback`.
+
+The :class:`_pool.Pool` itself also has a parameter that can control its
+"reset on return" behavior, noting that in modern SQLAlchemy this is not
+the only path by which the DBAPI transaction is released, which is the
 :paramref:`_pool.Pool.reset_on_return` parameter of :class:`_pool.Pool`, which
 is also available from :func:`_sa.create_engine` as
 :paramref:`_sa.create_engine.pool_reset_on_return`, passing a value of ``None``.
-This is illustrated in the example below, in conjunction with the
-:paramref:`.create_engine.isolation_level` parameter setting of
-``AUTOCOMMIT``::
+This pattern looks as below::
 
-    non_acid_engine = create_engine(
-        "mysql://scott:tiger@host/db",
+    autocommit_engine = create_engine(
+        "mysql+mysqldb://scott:tiger@mysql80/test",
         pool_reset_on_return=None,
         isolation_level="AUTOCOMMIT",
     )
 
-The above engine won't actually perform ROLLBACK when connections are returned
-to the pool; since AUTOCOMMIT is enabled, the driver will also not perform
-any BEGIN operation.
-
+The above pattern will still see ROLLBACKs occur however as the :class:`.Connection`
+object implicitly starts transaction blocks in the SQLAlchemy 2.0 series,
+which still emit ROLLBACK independently of the pool's reset sequence.
 
 Custom Reset-on-Return Schemes
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
index d66836e038ed5e87c87eceb5e02331c7fb9854a7..bcbea902473280770b81227715ad2f9653aa5ac5 100644 (file)
@@ -243,3 +243,8 @@ class PyODBCConnector(Connector):
         else:
             dbapi_connection.autocommit = False
             super().set_isolation_level(dbapi_connection, level)
+
+    def detect_autocommit_setting(
+        self, dbapi_conn: interfaces.DBAPIConnection
+    ) -> bool:
+        return bool(dbapi_conn.autocommit)
index 26b1424db29fd490393f653db25b913da4cd956c..b23dbcf8f6235034e23333c371b0ec4c2201ea38 100644 (file)
@@ -93,6 +93,9 @@ class AsyncAdapt_aiomysql_connection(AsyncAdapt_dbapi_connection):
     def autocommit(self, value: Any) -> None:
         await_(self._connection.autocommit(value))
 
+    def get_autocommit(self) -> bool:
+        return self._connection.get_autocommit()  # type: ignore
+
     def terminate(self) -> None:
         # it's not awaitable.
         self._connection.close()
index 061f48da7304d1f9d6450e70a6156db6a5285716..b183f11c553cd5e3ec7019d84ccbebc1c259ba4e 100644 (file)
@@ -103,6 +103,9 @@ class AsyncAdapt_asyncmy_connection(AsyncAdapt_dbapi_connection):
     def autocommit(self, value: Any) -> None:
         await_(self._connection.autocommit(value))
 
+    def get_autocommit(self) -> bool:
+        return self._connection.get_autocommit()  # type: ignore
+
     def terminate(self) -> None:
         # it's not awaitable.
         self._connection.close()
index 944549f9a5ea26bb821bdb04c66bec3566d989ba..a6c5dbd3f93c16675003dad04c8d470b6b54caee 100644 (file)
@@ -253,6 +253,9 @@ class MySQLDialect_mariadbconnector(MySQLDialect):
             "AUTOCOMMIT",
         )
 
+    def detect_autocommit_setting(self, dbapi_conn: DBAPIConnection) -> bool:
+        return bool(dbapi_conn.autocommit)
+
     def set_isolation_level(
         self, dbapi_connection: DBAPIConnection, level: IsolationLevel
     ) -> None:
index 02a961f548ac1c2b2b15aca15ec560cffb9d7feb..f8aa0b512d4b2597d0769ff806428b6a72ac2cb3 100644 (file)
@@ -278,6 +278,9 @@ class MySQLDialect_mysqlconnector(MySQLDialect):
             "AUTOCOMMIT",
         )
 
+    def detect_autocommit_setting(self, dbapi_conn: DBAPIConnection) -> bool:
+        return bool(dbapi_conn.autocommit)
+
     def set_isolation_level(
         self, dbapi_connection: DBAPIConnection, level: IsolationLevel
     ) -> None:
index 8621158823f398135d9ec6ceae2cf9a28cf38c64..3fc65e10e29cde44076e927b420642109a142794 100644 (file)
@@ -296,6 +296,9 @@ class MySQLDialect_mysqldb(MySQLDialect):
             "AUTOCOMMIT",
         )
 
+    def detect_autocommit_setting(self, dbapi_conn: DBAPIConnection) -> bool:
+        return dbapi_conn.get_autocommit()  # type: ignore[no-any-return]
+
     def set_isolation_level(
         self, dbapi_connection: DBAPIConnection, level: IsolationLevel
     ) -> None:
index 7ab48de4ff83bb0dab69dcb0defcec7d6423f7d0..1ef02fb5c40dd95c52817d4d17f041aad26da4ab 100644 (file)
@@ -1204,6 +1204,9 @@ class OracleDialect_cx_oracle(OracleDialect):
             with dbapi_connection.cursor() as cursor:
                 cursor.execute(f"ALTER SESSION SET ISOLATION_LEVEL={level}")
 
+    def detect_autocommit_setting(self, dbapi_conn) -> bool:
+        return bool(dbapi_conn.autocommit)
+
     def _detect_decimal_char(self, connection):
         # we have the option to change this setting upon connect,
         # or just look at what it is upon connect and convert.
index e5a8867c2161c9424346970295e9ce65f3883db3..6180bf1b613fd0f467497bd55fd27200759d042e 100644 (file)
@@ -174,6 +174,9 @@ class _PGDialect_common_psycopg(PGDialect):
     def _do_autocommit(self, connection, value):
         connection.autocommit = value
 
+    def detect_autocommit_setting(self, dbapi_connection):
+        return bool(dbapi_connection.autocommit)
+
     def do_ping(self, dbapi_connection):
         before_autocommit = dbapi_connection.autocommit
 
index fb35595016cb5516865b5dc049f2f46f7d676936..09ede9a7e74ffd5e51ce242f1492d795ff6d0d89 100644 (file)
@@ -1136,6 +1136,9 @@ class PGDialect_asyncpg(PGDialect):
     def set_isolation_level(self, dbapi_connection, level):
         dbapi_connection.set_isolation_level(self._isolation_lookup[level])
 
+    def detect_autocommit_setting(self, dbapi_conn) -> bool:
+        return bool(dbapi_conn.autocommit)
+
     def set_readonly(self, connection, value):
         connection.readonly = value
 
index e36709433c7439d3aa13ab458fcbb664ce94e8fc..7562276c25bf988fe3a18fde8dd9d79ded41696f 100644 (file)
@@ -543,6 +543,9 @@ class PGDialect_pg8000(PGDialect):
             cursor.execute("COMMIT")
             cursor.close()
 
+    def detect_autocommit_setting(self, dbapi_conn) -> bool:
+        return bool(dbapi_conn.autocommit)
+
     def set_readonly(self, connection, value):
         cursor = connection.cursor()
         try:
index c6fd69225c6d135f635e03bfbd7a561d1b6513f6..ea2c6a876578fd75b5c67bd66a71f530b72da58b 100644 (file)
@@ -500,6 +500,9 @@ class SQLiteDialect_pysqlite(SQLiteDialect):
             dbapi_connection.isolation_level = ""
             return super().set_isolation_level(dbapi_connection, level)
 
+    def detect_autocommit_setting(self, dbapi_connection):
+        return dbapi_connection.isolation_level is None
+
     def on_connect(self):
         def regexp(a, b):
             if b is None:
index 49da1083a8a2344b691c8a11f25d8193e53d28df..c7439b57be4f2aa86c8ac1ee8d206d0a74332f24 100644 (file)
@@ -1127,10 +1127,16 @@ class Connection(ConnectionEventsTarget, inspection.Inspectable["Inspector"]):
         if self._still_open_and_dbapi_connection_is_valid:
             if self._echo:
                 if self._is_autocommit_isolation():
-                    self._log_info(
-                        "ROLLBACK using DBAPI connection.rollback(), "
-                        "DBAPI should ignore due to autocommit mode"
-                    )
+                    if self.dialect.skip_autocommit_rollback:
+                        self._log_info(
+                            "ROLLBACK will be skipped by "
+                            "skip_autocommit_rollback"
+                        )
+                    else:
+                        self._log_info(
+                            "ROLLBACK using DBAPI connection.rollback(); "
+                            "set skip_autocommit_rollback to prevent fully"
+                        )
                 else:
                     self._log_info("ROLLBACK")
             try:
@@ -1146,7 +1152,7 @@ class Connection(ConnectionEventsTarget, inspection.Inspectable["Inspector"]):
             if self._is_autocommit_isolation():
                 self._log_info(
                     "COMMIT using DBAPI connection.commit(), "
-                    "DBAPI should ignore due to autocommit mode"
+                    "has no effect due to autocommit mode"
                 )
             else:
                 self._log_info("COMMIT")
index da312ab68384a78e2dc00488fa1c966eee6445cf..874af149d7cb53e19512c9b3aef4b774d0a06eaa 100644 (file)
@@ -171,6 +171,18 @@ def create_engine(url: Union[str, _url.URL], **kwargs: Any) -> Engine:
 
         :ref:`connections_toplevel`
 
+    :param skip_autocommit_rollback: When True, the dialect will
+       unconditionally skip all calls to the DBAPI ``connection.rollback()``
+       method if the DBAPI connection is confirmed to be in "autocommit" mode.
+       The availability of this feature is dialect specific; if not available,
+       a ``NotImplementedError`` is raised by the dialect when rollback occurs.
+
+       .. seealso::
+
+            :ref:`dbapi_autocommit_skip_rollback`
+
+       .. versionadded:: 2.0.43
+
     :param connect_args: a dictionary of options which will be
         passed directly to the DBAPI's ``connect()`` method as
         additional keyword arguments.  See the example
@@ -455,6 +467,9 @@ def create_engine(url: Union[str, _url.URL], **kwargs: Any) -> Engine:
 
             :ref:`pool_reset_on_return`
 
+            :ref:`dbapi_autocommit_skip_rollback` - a more modern approach
+            to using connections with no transactional instructions
+
     :param pool_timeout=30: number of seconds to wait before giving
         up on getting a connection from the pool. This is only used
         with :class:`~sqlalchemy.pool.QueuePool`. This can be a float but is
index c8bdb566356960146afe5dc3f4feaf010cbcdb9b..c624cece19dd44abaa2dbdc17bd6adf983b3937e 100644 (file)
@@ -310,6 +310,7 @@ class DefaultDialect(Dialect):
         # Linting.NO_LINTING constant
         compiler_linting: Linting = int(compiler.NO_LINTING),  # type: ignore
         server_side_cursors: bool = False,
+        skip_autocommit_rollback: bool = False,
         **kwargs: Any,
     ):
         if server_side_cursors:
@@ -334,6 +335,8 @@ class DefaultDialect(Dialect):
 
         self.dbapi = dbapi
 
+        self.skip_autocommit_rollback = skip_autocommit_rollback
+
         if paramstyle is not None:
             self.paramstyle = paramstyle
         elif self.dbapi is not None:
@@ -706,6 +709,10 @@ class DefaultDialect(Dialect):
         pass
 
     def do_rollback(self, dbapi_connection):
+        if self.skip_autocommit_rollback and self.detect_autocommit_setting(
+            dbapi_connection
+        ):
+            return
         dbapi_connection.rollback()
 
     def do_commit(self, dbapi_connection):
index 966904ba5e551a81f451ec7c0735653270adfaf4..c437a4f79b0a9ff94f0a77bcd97db165c9892cf8 100644 (file)
@@ -774,6 +774,14 @@ class Dialect(EventTarget):
     default_isolation_level: Optional[IsolationLevel]
     """the isolation that is implicitly present on new connections"""
 
+    skip_autocommit_rollback: bool
+    """Whether or not the :paramref:`.create_engine.skip_autocommit_rollback
+    parameter was set.
+
+    .. versionadded:: 2.0.43
+
+    """
+
     # create_engine()  -> isolation_level  currently goes here
     _on_connect_isolation_level: Optional[IsolationLevel]
 
@@ -2474,6 +2482,30 @@ class Dialect(EventTarget):
 
         raise NotImplementedError()
 
+    def detect_autocommit_setting(self, dbapi_conn: DBAPIConnection) -> bool:
+        """Detect the current autocommit setting for a DBAPI connection.
+
+        :param dbapi_connection: a DBAPI connection object
+        :return: True if autocommit is enabled, False if disabled
+        :rtype: bool
+
+        This method inspects the given DBAPI connection to determine
+        whether autocommit mode is currently enabled. The specific
+        mechanism for detecting autocommit varies by database dialect
+        and DBAPI driver, however it should be done **without** network
+        round trips.
+
+        .. note::
+
+            Not all dialects support autocommit detection. Dialects
+            that do not support this feature will raise
+            :exc:`NotImplementedError`.
+
+        """
+        raise NotImplementedError(
+            "This dialect cannot detect autocommit on a DBAPI connection"
+        )
+
     def get_default_isolation_level(
         self, dbapi_conn: DBAPIConnection
     ) -> IsolationLevel:
index 199120888384482ee2f84c4e0fde0b465495d0c8..3d109be3084bc66cdd5ac3a99083cef69e9c255c 100644 (file)
@@ -1033,6 +1033,13 @@ class SuiteRequirements(Requirements):
         """target dialect supports 'AUTOCOMMIT' as an isolation_level"""
         return exclusions.closed()
 
+    @property
+    def skip_autocommit_rollback(self):
+        """target dialect supports the detect_autocommit_setting() method and
+        uses the default implementation of do_rollback()"""
+
+        return exclusions.closed()
+
     @property
     def isolation_level(self):
         """target dialect supports general isolation level settings.
index ebbb9e435a07de0a83bfc41a54cf5e55a470f90f..36b474d53ebd79e71fa0ac56fcd858c3401fed0c 100644 (file)
@@ -17,6 +17,7 @@ from .. import eq_
 from .. import fixtures
 from .. import is_not_none
 from .. import is_true
+from .. import mock
 from .. import ne_
 from .. import provide_metadata
 from ..assertions import expect_raises
@@ -293,7 +294,11 @@ class AutocommitIsolationTest(fixtures.TablesTest):
             test_needs_acid=True,
         )
 
-    def _test_conn_autocommits(self, conn, autocommit):
+    def _test_conn_autocommits(self, conn, autocommit, ensure_table=False):
+        if ensure_table:
+            self.tables.some_table.create(conn, checkfirst=True)
+            conn.commit()
+
         trans = conn.begin()
         conn.execute(
             self.tables.some_table.insert(), {"id": 1, "data": "some data"}
@@ -336,6 +341,37 @@ class AutocommitIsolationTest(fixtures.TablesTest):
         )
         self._test_conn_autocommits(conn, False)
 
+    @testing.requires.skip_autocommit_rollback
+    @testing.variation("autocommit_setting", ["false", "engine", "option"])
+    @testing.variation("block_rollback", [True, False])
+    def test_autocommit_block(
+        self, testing_engine, autocommit_setting, block_rollback
+    ):
+        kw = {}
+        if bool(block_rollback):
+            kw["skip_autocommit_rollback"] = True
+        if autocommit_setting.engine:
+            kw["isolation_level"] = "AUTOCOMMIT"
+
+        engine = testing_engine(options=kw)
+
+        conn = engine.connect()
+        if autocommit_setting.option:
+            conn.execution_options(isolation_level="AUTOCOMMIT")
+        self._test_conn_autocommits(
+            conn,
+            autocommit_setting.engine or autocommit_setting.option,
+            ensure_table=True,
+        )
+        with mock.patch.object(
+            conn.connection, "rollback", wraps=conn.connection.rollback
+        ) as check_rollback:
+            conn.close()
+        if autocommit_setting.false or not block_rollback:
+            eq_(check_rollback.mock_calls, [mock.call()])
+        else:
+            eq_(check_rollback.mock_calls, [])
+
     @testing.requires.independent_readonly_connections
     @testing.variation("use_dialect_setting", [True, False])
     def test_dialect_autocommit_is_restored(
index 337a9f16a34dcba580fce01f713bf8b27b243c0c..ae528d12558118a1add73b7ddf67af622435e7fc 100644 (file)
@@ -753,14 +753,14 @@ class TransactionContextLoggingTest(fixtures.TestBase):
 
     @testing.fixture()
     def logging_engine(self, testing_engine):
-        kw = {"echo": True, "future": True}
+        kw = {"echo": True}
         e = testing_engine(options=kw)
         e.connect().close()
         return e
 
     @testing.fixture()
     def autocommit_iso_logging_engine(self, testing_engine):
-        kw = {"echo": True, "future": True, "isolation_level": "AUTOCOMMIT"}
+        kw = {"echo": True, "isolation_level": "AUTOCOMMIT"}
         e = testing_engine(options=kw)
         e.connect().close()
         return e
@@ -811,8 +811,8 @@ class TransactionContextLoggingTest(fixtures.TestBase):
             [
                 "BEGIN (implicit; DBAPI should not "
                 "BEGIN due to autocommit mode)",
-                "COMMIT using DBAPI connection.commit(), DBAPI "
-                "should ignore due to autocommit mode",
+                "COMMIT using DBAPI connection.commit(), "
+                "has no effect due to autocommit mode",
             ]
         )
 
@@ -845,28 +845,45 @@ class TransactionContextLoggingTest(fixtures.TestBase):
             [
                 "BEGIN (implicit; DBAPI should not "
                 "BEGIN due to autocommit mode)",
-                "COMMIT using DBAPI connection.commit(), DBAPI "
-                "should ignore due to autocommit mode",
+                "COMMIT using DBAPI connection.commit(), "
+                "has no effect due to autocommit mode",
             ]
         )
 
+    @testing.variation("block_rollback", [True, False])
     def test_commit_as_you_go_block_rollback_autocommit(
-        self, logging_engine, assert_buf
+        self, testing_engine, assert_buf, block_rollback
     ):
-        with logging_engine.connect().execution_options(
-            isolation_level="AUTOCOMMIT"
-        ) as conn:
+
+        kw = {
+            "echo": True,
+            "isolation_level": "AUTOCOMMIT",
+            "skip_autocommit_rollback": bool(block_rollback),
+        }
+        logging_engine = testing_engine(options=kw)
+        logging_engine.connect().close()
+
+        with logging_engine.connect() as conn:
             conn.begin()
             conn.rollback()
 
-        assert_buf(
-            [
-                "BEGIN (implicit; DBAPI should not "
-                "BEGIN due to autocommit mode)",
-                "ROLLBACK using DBAPI connection.rollback(), DBAPI "
-                "should ignore due to autocommit mode",
-            ]
-        )
+        if block_rollback:
+            assert_buf(
+                [
+                    "BEGIN (implicit; DBAPI should not "
+                    "BEGIN due to autocommit mode)",
+                    "ROLLBACK will be skipped by skip_autocommit_rollback",
+                ]
+            )
+        else:
+            assert_buf(
+                [
+                    "BEGIN (implicit; DBAPI should not "
+                    "BEGIN due to autocommit mode)",
+                    "ROLLBACK using DBAPI connection.rollback(); "
+                    "set skip_autocommit_rollback to prevent fully",
+                ]
+            )
 
     def test_logging_compatibility(
         self, plain_assert_buf, plain_logging_engine
index 1e6faded32cdb8d69f584caa06bcf4b8d8f6b3a9..34bd3386494fcc85b3bca74acd58980738e773fa 100644 (file)
@@ -447,6 +447,14 @@ class DefaultRequirements(SuiteRequirements):
             in self.get_isolation_levels(config)["supported"]
         )
 
+    @property
+    def skip_autocommit_rollback(self):
+        return exclusions.skip_if(
+            ["mssql+pymssql"],
+            "DBAPI has no means of testing the autocommit status of a "
+            "connection",
+        )
+
     @property
     def row_triggers(self):
         """Target must support standard statement-running EACH ROW triggers."""