This is an automated email from the ASF dual-hosted git repository.

dstandish pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new b3cffc448ac Use atomic upsert for asset run queue on MySQL to avoid 
deadlocks (#69977)
b3cffc448ac is described below

commit b3cffc448ac90f0cc36b63e45ee181784ae6b6a7
Author: Daniel Standish <[email protected]>
AuthorDate: Fri Jul 17 10:24:52 2026 -0700

    Use atomic upsert for asset run queue on MySQL to avoid deadlocks (#69977)
    
    Under concurrent asset-event fan-out, the per-row SAVEPOINT loop used for
    non-PostgreSQL backends deadlocks on MySQL/InnoDB and surfaces confusing
    "SAVEPOINT ... does not exist" errors, and the deadlock is not retried.
    Route MySQL to a single-statement INSERT ... ON DUPLICATE KEY UPDATE,
    mirroring the existing PostgreSQL path, so the enqueue is atomic and holds
    locks far more briefly.
    
    closes: #69938
---
 airflow-core/src/airflow/assets/manager.py     | 29 ++++++++++++++----
 airflow-core/tests/unit/assets/test_manager.py | 41 ++++++++++++++++++++++++++
 2 files changed, 65 insertions(+), 5 deletions(-)

diff --git a/airflow-core/src/airflow/assets/manager.py 
b/airflow-core/src/airflow/assets/manager.py
index c3491574e91..1dd26379b9d 100644
--- a/airflow-core/src/airflow/assets/manager.py
+++ b/airflow-core/src/airflow/assets/manager.py
@@ -518,12 +518,17 @@ class AssetManager(LoggingMixin):
         # mapped) tasks update the same asset, this can fail with a unique
         # constraint violation.
         #
-        # If we support it, use ON CONFLICT to do nothing, otherwise
-        # "fallback" to running this in a nested transaction. This is needed
-        # so that the adding of these rows happens in the same transaction
-        # where `ti.state` is changed.
-        if get_dialect_name(session) == "postgresql":
+        # Where the dialect supports a single-statement "insert, ignore on
+        # conflict" we use it; it is atomic, avoids the per-row SAVEPOINT 
churn,
+        # and holds locks for far less time (which on MySQL/InnoDB also makes 
the
+        # concurrent fan-out much less deadlock-prone). Otherwise we 
"fallback" to
+        # a nested transaction per row. Either way the rows are added in the 
same
+        # transaction where `ti.state` is changed.
+        dialect_name = get_dialect_name(session)
+        if dialect_name == "postgresql":
             return cls._queue_dagruns_nonpartitioned_postgres(asset_id, 
non_partitioned_dags, session)
+        if dialect_name == "mysql":
+            return cls._queue_dagruns_nonpartitioned_mysql(asset_id, 
non_partitioned_dags, session)
         return cls._queue_dagruns_nonpartitioned_slow_path(asset_id, 
non_partitioned_dags, session)
 
     @classmethod
@@ -817,6 +822,20 @@ class AssetManager(LoggingMixin):
         stmt = 
insert(AssetDagRunQueue).values(asset_id=asset_id).on_conflict_do_nothing()
         session.execute(stmt, values)
 
+    @classmethod
+    def _queue_dagruns_nonpartitioned_mysql(
+        cls, asset_id: int, dags_to_queue: set[DagModel], session: Session
+    ) -> None:
+        from sqlalchemy.dialects.mysql import insert
+
+        values = [{"target_dag_id": dag.dag_id} for dag in dags_to_queue]
+        stmt = insert(AssetDagRunQueue).values(asset_id=asset_id)
+        # MySQL has no "ON CONFLICT DO NOTHING"; a no-op ON DUPLICATE KEY 
UPDATE turns a
+        # conflicting (asset_id, target_dag_id) row into a no-op rather than 
an error,
+        # matching the Postgres path.
+        stmt = 
stmt.on_duplicate_key_update(target_dag_id=stmt.inserted.target_dag_id)
+        session.execute(stmt, values)
+
 
 def resolve_asset_manager() -> AssetManager:
     """Retrieve the asset manager."""
diff --git a/airflow-core/tests/unit/assets/test_manager.py 
b/airflow-core/tests/unit/assets/test_manager.py
index b788b9ab286..c290d7f5a63 100644
--- a/airflow-core/tests/unit/assets/test_manager.py
+++ b/airflow-core/tests/unit/assets/test_manager.py
@@ -26,6 +26,7 @@ from unittest import mock
 
 import pytest
 from sqlalchemy import delete, func, select
+from sqlalchemy.dialects import mysql
 from sqlalchemy.orm import Session
 
 from airflow import settings
@@ -211,6 +212,46 @@ class TestAssetManager:
         )
         assert 
session.scalar(select(func.count()).select_from(AssetDagRunQueue)) == 0
 
+    @pytest.mark.parametrize(
+        ("dialect_name", "expected_helper"),
+        [
+            ("postgresql", "_queue_dagruns_nonpartitioned_postgres"),
+            ("mysql", "_queue_dagruns_nonpartitioned_mysql"),
+            ("sqlite", "_queue_dagruns_nonpartitioned_slow_path"),
+        ],
+    )
+    def test_queue_dagruns_routes_by_dialect(self, dialect_name, 
expected_helper):
+        """Test that _queue_dagruns routes to the dialect-appropriate queue 
helper."""
+        dag = DagModel(dag_id="dag1")
+        session = mock.MagicMock(spec=Session)
+        with (
+            mock.patch("airflow.assets.manager.get_dialect_name", 
return_value=dialect_name),
+            mock.patch.object(AssetManager, "_queue_partitioned_dags"),
+            mock.patch.object(AssetManager, expected_helper) as mock_helper,
+        ):
+            AssetManager._queue_dagruns(
+                asset_id=1,
+                dags_to_queue={dag},
+                partition_key=None,
+                partition_date=None,
+                event=mock.MagicMock(),
+                task_instance=None,
+                session=session,
+            )
+        mock_helper.assert_called_once_with(1, {dag}, session)
+
+    def test_queue_dagruns_nonpartitioned_mysql_builds_upsert(self):
+        """Test that the MySQL queue path emits an INSERT ... ON DUPLICATE KEY 
UPDATE."""
+        dag = DagModel(dag_id="dag1")
+        session = mock.MagicMock(spec=Session)
+
+        AssetManager._queue_dagruns_nonpartitioned_mysql(asset_id=1, 
dags_to_queue={dag}, session=session)
+
+        stmt, values = session.execute.call_args.args
+        compiled = str(stmt.compile(dialect=mysql.dialect())).upper()
+        assert "ON DUPLICATE KEY UPDATE" in compiled
+        assert values == [{"target_dag_id": "dag1"}]
+
     def test_register_asset_change_notifies_asset_listener(
         self, session, mock_task_instance, testing_dag_bundle, listener_manager
     ):

Reply via email to