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

Lee-W 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 9e5b7701f8d Fix asset event scheduling race condition and consistency 
(#62501)
9e5b7701f8d is described below

commit 9e5b7701f8ded5bca18d665ecc23ae1294a4f1b1
Author: Stanley Law <[email protected]>
AuthorDate: Wed Jul 29 16:00:44 2026 +0800

    Fix asset event scheduling race condition and consistency (#62501)
    
    Co-authored-by: Kaxil Naik <[email protected]>
    Co-authored-by: Copilot <[email protected]>
---
 airflow-core/newsfragments/62501.bugfix.rst        |   1 +
 airflow-core/src/airflow/assets/manager.py         | 107 +++++++---
 .../src/airflow/jobs/scheduler_job_runner.py       |  80 +++++---
 airflow-core/tests/unit/assets/test_manager.py     |  20 +-
 airflow-core/tests/unit/jobs/test_scheduler_job.py | 220 ++++++++++++++++++++-
 .../tests/unit/models/test_taskinstance.py         |  33 ++++
 6 files changed, 391 insertions(+), 70 deletions(-)

diff --git a/airflow-core/newsfragments/62501.bugfix.rst 
b/airflow-core/newsfragments/62501.bugfix.rst
new file mode 100644
index 00000000000..bb2a30511d4
--- /dev/null
+++ b/airflow-core/newsfragments/62501.bugfix.rst
@@ -0,0 +1 @@
+Fix a race condition that could cause asset-triggered Dags to be skipped when 
asset events were produced concurrently. The scheduler could miss a newly 
produced asset event because there was a visibility gap between when the record 
was flushed and when the producing task's transaction committed. The asset 
event is now committed ahead of the producing task's remaining bookkeeping to 
close that gap; in rare cases this can surface as a duplicate or orphaned asset 
event if that task fails im [...]
diff --git a/airflow-core/src/airflow/assets/manager.py 
b/airflow-core/src/airflow/assets/manager.py
index 1dd26379b9d..d0f8470b17c 100644
--- a/airflow-core/src/airflow/assets/manager.py
+++ b/airflow-core/src/airflow/assets/manager.py
@@ -46,6 +46,7 @@ from airflow.models.log import Log
 from airflow.timetables.base import compute_rollup_fingerprint
 from airflow.utils.helpers import is_container, prune_dict
 from airflow.utils.log.logging_mixin import LoggingMixin
+from airflow.utils.session import create_session
 from airflow.utils.sqlalchemy import get_dialect_name, with_row_locks
 
 if TYPE_CHECKING:
@@ -119,6 +120,38 @@ def _lock_asset_model(
         yield
 
 
+def _create_asset_event(*, session: Session, **event_kwargs) -> AssetEvent:
+    """
+    Persist an :class:`AssetEvent` row and return it, bound to *session*.
+
+    On SQLite the event is added directly to the caller's *session* and
+    flushed. SQLite serialises writes at the database-file level: opening
+    a second connection here would compete with any write locks the
+    caller's transaction already holds (for example, an UPDATE on
+    ``dag_run`` flushed earlier in ``register_asset_changes_in_db``) and
+    deadlock with ``database is locked``.
+
+    On Postgres/MySQL a short-lived independent session is used so the
+    row is committed — and therefore visible to the scheduler's session
+    via MVCC — before the caller continues. The committed row is then
+    re-loaded into the caller's *session* so subsequent relationship
+    operations work correctly.
+    """
+    if get_dialect_name(session) == "sqlite":
+        asset_event = AssetEvent(**event_kwargs)
+        session.add(asset_event)
+        session.flush()
+        return asset_event
+
+    with create_session(scoped=False) as ae_session:
+        asset_event = AssetEvent(**event_kwargs)
+        ae_session.add(asset_event)
+        ae_session.flush()
+        asset_event_id = asset_event.id
+
+    return session.get_one(AssetEvent, asset_event_id)
+
+
 class AssetManager(LoggingMixin):
     """
     A pluggable class that manages operations for assets.
@@ -341,10 +374,7 @@ class AssetManager(LoggingMixin):
                 source_run_id=task_instance.run_id,
                 source_map_index=task_instance.map_index,
             )
-
-        asset_event = AssetEvent(**event_kwargs)
-        session.add(asset_event)
-        session.flush()  # Ensure the event is written earlier than ADRQ 
entries below.
+        asset_event = _create_asset_event(session=session, **event_kwargs)
 
         dags_to_queue_from_asset = {ref.dag for ref in 
asset_model.scheduled_dags if not ref.dag.is_paused}
 
@@ -518,18 +548,21 @@ class AssetManager(LoggingMixin):
         # mapped) tasks update the same asset, this can fail with a unique
         # constraint violation.
         #
-        # Where the dialect supports a single-statement "insert, ignore on
+        # Where the dialect supports a single-statement "insert, update 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 TYPE_CHECKING:
+            assert dialect_name is not None
         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)
+            return cls._queue_dagruns_nonpartitioned_mysql(asset_id, 
non_partitioned_dags, event, session)
+        # PostgreSQL and SQLite both support ON CONFLICT DO UPDATE.
+        return cls._queue_dagruns_nonpartitioned_conflict_update(
+            asset_id, non_partitioned_dags, event, session, dialect_name
+        )
 
     @classmethod
     def _queue_partitioned_dags(
@@ -795,14 +828,20 @@ class AssetManager(LoggingMixin):
 
     @classmethod
     def _queue_dagruns_nonpartitioned_slow_path(
-        cls, asset_id: int, dags_to_queue: set[DagModel], session: Session
+        cls, asset_id: int, dags_to_queue: set[DagModel], event: AssetEvent, 
session: Session
     ) -> None:
         def _queue_dagrun_if_needed(dag: DagModel) -> str | None:
-            item = AssetDagRunQueue(target_dag_id=dag.dag_id, 
asset_id=asset_id)
+            item = AssetDagRunQueue(target_dag_id=dag.dag_id, 
asset_id=asset_id, created_at=event.timestamp)
             # Don't error whole transaction when a single RunQueue item 
conflicts.
             # 
https://docs.sqlalchemy.org/en/20/orm/session_transaction.html#using-savepoint
             try:
                 with session.begin_nested():
+                    existing = session.get(
+                        AssetDagRunQueue, {"target_dag_id": dag.dag_id, 
"asset_id": asset_id}
+                    )
+                    if existing and existing.created_at >= event.timestamp:
+                        cls.logger().debug("Skipping record %s due to newer 
timestamp", item)
+                        return dag.dag_id  # already queued with a newer 
timestamp
                     session.merge(item)
             except exc.IntegrityError:
                 cls.logger().debug("Skipping record %s", item, exc_info=True)
@@ -813,28 +852,46 @@ class AssetManager(LoggingMixin):
             cls.logger().debug("consuming dag ids %s", queued_dag_ids)
 
     @classmethod
-    def _queue_dagruns_nonpartitioned_postgres(
-        cls, asset_id: int, dags_to_queue: set[DagModel], session: Session
+    def _queue_dagruns_nonpartitioned_mysql(
+        cls, asset_id: int, dags_to_queue: set[DagModel], event: AssetEvent, 
session: Session
     ) -> None:
-        from sqlalchemy.dialects.postgresql import insert
+        from sqlalchemy import case
+        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).on_conflict_do_nothing()
-        session.execute(stmt, values)
+        stmt = insert(AssetDagRunQueue).values(asset_id=asset_id, 
created_at=event.timestamp)
+
+        update_stmt = stmt.on_duplicate_key_update(
+            created_at=case(
+                (stmt.inserted.created_at >= AssetDagRunQueue.created_at, 
stmt.inserted.created_at),
+                else_=AssetDagRunQueue.created_at,
+            )
+        )
+        session.execute(update_stmt, values)
 
     @classmethod
-    def _queue_dagruns_nonpartitioned_mysql(
-        cls, asset_id: int, dags_to_queue: set[DagModel], session: Session
+    def _queue_dagruns_nonpartitioned_conflict_update(
+        cls,
+        asset_id: int,
+        dags_to_queue: set[DagModel],
+        event: AssetEvent,
+        session: Session,
+        dialect_name: str,
     ) -> None:
-        from sqlalchemy.dialects.mysql import insert
+        """Handle ON CONFLICT DO UPDATE upsert for dialects that support it 
(postgresql, sqlite)."""
+        if dialect_name == "postgresql":
+            from sqlalchemy.dialects.postgresql import insert
+        else:
+            from sqlalchemy.dialects.sqlite import insert  # type: 
ignore[assignment]
 
         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)
+        stmt = insert(AssetDagRunQueue).values(asset_id=asset_id, 
created_at=event.timestamp)
+        update_stmt = stmt.on_conflict_do_update(
+            index_elements=["asset_id", "target_dag_id"],
+            set_={"created_at": stmt.excluded.created_at},
+            where=(AssetDagRunQueue.created_at < stmt.excluded.created_at),
+        )
+        session.execute(update_stmt, values)
 
 
 def resolve_asset_manager() -> AssetManager:
diff --git a/airflow-core/src/airflow/jobs/scheduler_job_runner.py 
b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
index daef05b2b7c..5f6d17ff360 100644
--- a/airflow-core/src/airflow/jobs/scheduler_job_runner.py
+++ b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
@@ -85,6 +85,7 @@ from airflow.models.asset import (
     PartitionedAssetKeyLog,
     TaskInletAssetReference,
     TaskOutletAssetReference,
+    association_table,
 )
 from airflow.models.asset_state_store import AssetStateStoreModel
 from airflow.models.backfill import Backfill, BackfillDagRun
@@ -2702,48 +2703,63 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
                                 )
                             ),
                         ),
-                        AssetEvent.timestamp <= triggered_date,
                         AssetEvent.timestamp > 
func.coalesce(*event_window_floor),
+                        AssetEvent.timestamp <= triggered_date,
+                        ~(
+                            select(association_table.c.event_id)
+                            .join(DagRun, DagRun.id == 
association_table.c.dag_run_id)
+                            .where(
+                                DagRun.dag_id == dag.dag_id,
+                                association_table.c.event_id == AssetEvent.id,
+                            )
+                            .exists()
+                        ),
                     )
                     .order_by(AssetEvent.timestamp.asc(), AssetEvent.id.asc())
                 )
             )
-
-            dag_run = dag.create_dagrun(
-                run_id=DagRun.generate_run_id(
-                    run_type=DagRunType.ASSET_TRIGGERED, logical_date=None, 
run_after=triggered_date
-                ),
-                logical_date=None,
-                data_interval=None,
-                run_after=triggered_date,
-                run_type=DagRunType.ASSET_TRIGGERED,
-                triggered_by=DagRunTriggeredByType.ASSET,
-                state=DagRunState.QUEUED,
-                creating_job_id=self.job.id,
-                session=session,
-            )
-            team_name = (
-                self._get_team_names_for_dag_ids([dag.dag_id], 
session).get(dag.dag_id)
-                if self._multi_team
-                else None
-            )
-            stats.incr("asset.triggered_dagruns", 
tags=prune_dict({"team_name": team_name}))
-            dag_run.consumed_asset_events.extend(asset_events)
-            self.log.info(
-                "Created asset-triggered DagRun for '%s': run_id=%s, consumed 
%d asset events",
-                dag.dag_id,
-                dag_run.run_id,
-                len(asset_events),
-            )
-
-            # Delete only consumed ADRQ rows to avoid dropping newly queued 
events
-            # (e.g. DagRun triggered by asset A while a new event for asset B 
arrives).
+            if asset_events:
+                dag_run = dag.create_dagrun(
+                    run_id=DagRun.generate_run_id(
+                        run_type=DagRunType.ASSET_TRIGGERED, 
logical_date=None, run_after=triggered_date
+                    ),
+                    logical_date=None,
+                    data_interval=None,
+                    run_after=triggered_date,
+                    run_type=DagRunType.ASSET_TRIGGERED,
+                    triggered_by=DagRunTriggeredByType.ASSET,
+                    state=DagRunState.QUEUED,
+                    creating_job_id=self.job.id,
+                    session=session,
+                )
+                team_name = (
+                    self._get_team_names_for_dag_ids([dag.dag_id], 
session).get(dag.dag_id)
+                    if self._multi_team
+                    else None
+                )
+                stats.incr("asset.triggered_dagruns", 
tags=prune_dict({"team_name": team_name}))
+                dag_run.consumed_asset_events.extend(asset_events)
+                self.log.info(
+                    "Created asset-triggered DagRun for '%s': run_id=%s, 
consumed %d asset events",
+                    dag.dag_id,
+                    dag_run.run_id,
+                    len(asset_events),
+                )
+            else:
+                self.log.info(
+                    "No DagRun created for '%s' at '%s' - asset events already 
consumed or none found",
+                    dag.dag_id,
+                    triggered_date,
+                )
+            # Always delete ADRQ rows for this batch to prevent stale entries 
accumulating,
+            # including when all events were already consumed by a concurrent 
DagRun.
             adrq_pks = [(record.asset_id, record.target_dag_id) for record in 
queued_adrqs]
             result = cast(
                 "CursorResult",
                 session.execute(
                     delete(AssetDagRunQueue).where(
-                        tuple_(AssetDagRunQueue.asset_id, 
AssetDagRunQueue.target_dag_id).in_(adrq_pks)
+                        tuple_(AssetDagRunQueue.asset_id, 
AssetDagRunQueue.target_dag_id).in_(adrq_pks),
+                        AssetDagRunQueue.created_at <= triggered_date,
                     )
                 ),
             )
diff --git a/airflow-core/tests/unit/assets/test_manager.py 
b/airflow-core/tests/unit/assets/test_manager.py
index c290d7f5a63..bc034cd7515 100644
--- a/airflow-core/tests/unit/assets/test_manager.py
+++ b/airflow-core/tests/unit/assets/test_manager.py
@@ -215,15 +215,16 @@ class TestAssetManager:
     @pytest.mark.parametrize(
         ("dialect_name", "expected_helper"),
         [
-            ("postgresql", "_queue_dagruns_nonpartitioned_postgres"),
+            ("postgresql", "_queue_dagruns_nonpartitioned_conflict_update"),
             ("mysql", "_queue_dagruns_nonpartitioned_mysql"),
-            ("sqlite", "_queue_dagruns_nonpartitioned_slow_path"),
+            ("sqlite", "_queue_dagruns_nonpartitioned_conflict_update"),
         ],
     )
     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)
+        event = mock.MagicMock()
         with (
             mock.patch("airflow.assets.manager.get_dialect_name", 
return_value=dialect_name),
             mock.patch.object(AssetManager, "_queue_partitioned_dags"),
@@ -234,18 +235,25 @@ class TestAssetManager:
                 dags_to_queue={dag},
                 partition_key=None,
                 partition_date=None,
-                event=mock.MagicMock(),
+                event=event,
                 task_instance=None,
                 session=session,
             )
-        mock_helper.assert_called_once_with(1, {dag}, session)
+        if expected_helper == "_queue_dagruns_nonpartitioned_conflict_update":
+            mock_helper.assert_called_once_with(1, {dag}, event, session, 
dialect_name)
+        elif expected_helper == "_queue_dagruns_nonpartitioned_mysql":
+            mock_helper.assert_called_once_with(1, {dag}, event, session)
+        else:
+            raise AssertionError(f"Unexpected expected_helper: 
{expected_helper}")
 
     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)
+        event = AssetEvent(asset_id=1)
+        AssetManager._queue_dagruns_nonpartitioned_mysql(
+            asset_id=1, dags_to_queue={dag}, event=event, session=session
+        )
 
         stmt, values = session.execute.call_args.args
         compiled = str(stmt.compile(dialect=mysql.dialect())).upper()
diff --git a/airflow-core/tests/unit/jobs/test_scheduler_job.py 
b/airflow-core/tests/unit/jobs/test_scheduler_job.py
index 4add965a6ab..1fb72d1df13 100644
--- a/airflow-core/tests/unit/jobs/test_scheduler_job.py
+++ b/airflow-core/tests/unit/jobs/test_scheduler_job.py
@@ -22,8 +22,10 @@ import datetime
 import logging
 import os
 import re
+import time
 from collections import Counter, deque
 from collections.abc import Callable, Generator, Iterator
+from concurrent.futures import ThreadPoolExecutor, as_completed
 from contextlib import ExitStack, contextmanager
 from datetime import timedelta
 from pathlib import Path
@@ -45,7 +47,7 @@ from airflow._shared.module_loading import qualname
 from airflow._shared.observability.metrics.base_stats_logger import StatsLogger
 from airflow._shared.timezones import timezone
 from airflow.api_fastapi.auth.tokens import JWTGenerator
-from airflow.assets.manager import AssetManager
+from airflow.assets.manager import AssetManager, _create_asset_event
 from airflow.callbacks.callback_requests import (
     DagCallbackRequest,
     DagRunContext,
@@ -5788,6 +5790,175 @@ class TestSchedulerJob:
         expected = {new_event.id} | ({old_event.id} if expects_old_event else 
set())
         assert {e.id for e in created_run.consumed_asset_events} == expected
 
+    @pytest.mark.need_serialized_dag
+    def test_create_dag_runs_asset_triggered_skips_stale_triggered_date(self, 
session, dag_maker):
+        asset = Asset(uri="test://asset-for-stale-trigger-date", 
name="asset-for-stale-trigger-date")
+        with dag_maker(dag_id="asset-consumer-stale-trigger-date", 
schedule=[asset], session=session):
+            pass
+        dag_model = dag_maker.dag_model
+        asset_id = session.scalar(select(AssetModel.id).where(AssetModel.uri 
== asset.uri))
+
+        queued_at = timezone.utcnow()
+        session.add(AssetDagRunQueue(target_dag_id=dag_model.dag_id, 
asset_id=asset_id, created_at=queued_at))
+        session.flush()
+
+        # Simulate another scheduler consuming ADRQ rows after we computed 
triggered_date_by_dag.
+        
session.execute(delete(AssetDagRunQueue).where(AssetDagRunQueue.target_dag_id 
== dag_model.dag_id))
+        session.flush()
+
+        scheduler_job = Job()
+        self.job_runner = SchedulerJobRunner(job=scheduler_job, 
executors=[self.null_exec])
+        self.job_runner._create_dag_runs_asset_triggered(
+            dag_models=[dag_model],
+            session=session,
+        )
+
+        # We do not create a new DagRun since the ADRQ has already been 
consumed
+        assert session.scalars(select(DagRun).where(DagRun.dag_id == 
dag_model.dag_id)).one_or_none() is None
+
+    @pytest.mark.need_serialized_dag
+    def test_create_dag_runs_asset_triggered_deletes_only_selected_adrq_rows(
+        self, session: Session, dag_maker
+    ):
+        asset_1 = Asset("ready-to-trigger-a-Dag-run")
+        asset_2 = Asset("should-still-exist-after-a-Dag-run-created")
+        with dag_maker(dag_id="asset-consumer-delete-selected", 
schedule=asset_1 | asset_2, session=session):
+            pass
+        dag_model = dag_maker.dag_model
+        asset_1_id = session.scalar(select(AssetModel.id).where(AssetModel.uri 
== asset_1.name))
+        asset_2_id = session.scalar(select(AssetModel.id).where(AssetModel.uri 
== asset_2.name))
+        session.add_all(
+            [
+                AssetEvent(
+                    asset_id=asset_1_id,
+                    timestamp=timezone.utcnow(),
+                ),
+                # The ADRQ that should triggers the Dag run creation
+                AssetDagRunQueue(
+                    asset_id=asset_1_id, target_dag_id=dag_model.dag_id, 
created_at=timezone.utcnow()
+                ),
+                AssetEvent(asset_id=asset_2_id, timestamp=timezone.utcnow()),
+                # The ADRQ that arrives after the Dag run creation but before 
ADRQ clean up
+                # This situation is simulated by _lock_only_selected_asset 
below
+                AssetDagRunQueue(
+                    asset_id=asset_2_id, target_dag_id=dag_model.dag_id, 
created_at=timezone.utcnow()
+                ),
+            ]
+        )
+        session.flush()
+
+        scheduler_job = Job()
+        self.job_runner = SchedulerJobRunner(job=scheduler_job, 
executors=[MockExecutor(do_update=False)])
+
+        def _lock_only_selected_asset(query, **_):
+            # Simulate SKIP LOCKED behavior where this scheduler can only 
consume one ADRQ row.
+            return query.where(AssetDagRunQueue.asset_id == asset_1_id)
+
+        with patch("airflow.jobs.scheduler_job_runner.with_row_locks", 
side_effect=_lock_only_selected_asset):
+            self.job_runner._create_dag_runs_asset_triggered(
+                dag_models=[dag_model],
+                session=session,
+            )
+
+        dr = session.scalars(select(DagRun).where(DagRun.dag_id == 
dag_model.dag_id)).one_or_none()
+        assert dr is not None
+
+        adrq_1 = session.scalars(
+            select(AssetDagRunQueue).where(
+                AssetDagRunQueue.target_dag_id == dag_model.dag_id,
+                AssetDagRunQueue.asset_id == asset_1_id,
+            )
+        ).one_or_none()
+        assert adrq_1 is None
+        adrq_2 = session.scalars(
+            select(AssetDagRunQueue).where(
+                AssetDagRunQueue.target_dag_id == dag_model.dag_id,
+                AssetDagRunQueue.asset_id == asset_2_id,
+            )
+        ).one_or_none()
+        assert adrq_2 is not None
+
+    @pytest.mark.need_serialized_dag
+    @pytest.mark.backend("postgres", "mysql")
+    def test_create_dag_runs_when_concurrent_asset_events_created(self, 
session: Session, dag_maker, caplog):
+
+        ASSET_EVENT_COUNT = 30
+        asset = Asset(name="test_asset")
+        with dag_maker(dag_id="consumer", schedule=asset, session=session):
+            pass
+        dag_model = dag_maker.dag_model
+        # Capture the dag_id as a plain string in the main thread. The worker 
threads must not
+        # touch this ORM object: it is bound to the main thread's session, 
which the loop below
+        # commits (and thus expires) concurrently, so any attribute access 
from a worker would
+        # load against another thread's session.
+        consumer_dag_id = dag_model.dag_id
+        with dag_maker(dag_id="asset-producer", start_date=timezone.utcnow(), 
session=session):
+            BashOperator(task_id="simulate-asset-outlet", bash_command="echo 
1")
+        dag_maker.create_dagrun(run_id="asset-producer-run")
+        asset_id = session.scalar(select(AssetModel.id).where(AssetModel.uri 
== asset.uri))
+        futures = []
+        consumed_asset_events = []
+        asset_event_metadata: list[tuple[int, datetime.datetime]] = []
+
+        def create_asset_events(sleep):
+
+            with create_session() as session:
+                # Re-fetch the DagModel in this thread's own session so all 
ORM access stays
+                # thread-local.
+                dag = session.get(DagModel, consumer_dag_id)
+                now = timezone.utcnow()
+                asset_manager = AssetManager()
+                asset_event = _create_asset_event(session=session, 
asset_id=asset_id, timestamp=now)
+                time.sleep(sleep)  # widen the race window between event 
creation and queueing
+                dialect_name = inspect(session.get_bind()).dialect.name
+                if dialect_name in ("postgresql", "sqlite"):
+                    
asset_manager._queue_dagruns_nonpartitioned_conflict_update(
+                        asset_id=asset_id,
+                        dags_to_queue=[dag],
+                        event=asset_event,
+                        session=session,
+                        dialect_name=dialect_name,
+                    )
+                elif dialect_name == "mysql":
+                    asset_manager._queue_dagruns_nonpartitioned_mysql(
+                        asset_id=asset_id, dags_to_queue=[dag], 
event=asset_event, session=session
+                    )
+
+            return asset_event.id, now.isoformat()
+
+        with (
+            ThreadPoolExecutor(max_workers=3) as executor,
+            caplog.at_level(
+                "WARNING",
+                logger="airflow.jobs.scheduler_job_runner",
+            ),
+        ):
+            for i in range(ASSET_EVENT_COUNT):
+                # Deterministically alternate between fast (0s) and slow (2s) 
workers so the
+                # test reliably exercises both code paths without relying on 
RNG.
+                future = executor.submit(create_asset_events, i % 3)
+                futures.append(future)
+            scheduler_job = Job()
+            self.job_runner = SchedulerJobRunner(job=scheduler_job, 
executors=[MockExecutor(do_update=False)])
+            seen_dr_ids: set[int] = set()
+            for future in as_completed(futures, timeout=120):
+                asset_event_metadata.append(future.result())
+                self.job_runner._create_dag_runs_asset_triggered(
+                    dag_models=[dag_model],
+                    session=session,
+                )
+                session.commit()
+                all_drs = session.scalars(select(DagRun).where(DagRun.dag_id 
== dag_model.dag_id)).all()
+                for dr in all_drs:
+                    if dr.id not in seen_dr_ids:
+                        seen_dr_ids.add(dr.id)
+                        consumed_asset_events += dr.consumed_asset_events
+        total_consumed_asset_events = len(consumed_asset_events)
+        assert total_consumed_asset_events == ASSET_EVENT_COUNT
+        assert len({event.id for event in consumed_asset_events}) == 
total_consumed_asset_events, (
+            "Expected no duplicated Asset event consumed"
+        )
+
     @pytest.mark.need_serialized_dag
     def test_create_dag_runs_asset_alias_with_asset_event_attached(self, 
session, dag_maker):
         """
@@ -5897,6 +6068,10 @@ class TestSchedulerJob:
             BashOperator(task_id="task", bash_command="echo 1", 
outlets=[asset])
         dr = dag_maker.create_dagrun()
 
+        with dag_maker(
+            dag_id=f"consumer_{suffix}", schedule=[asset], 
bundle_name=bundle_name, session=session
+        ):
+            pass
         asset_id = session.scalar(select(AssetModel.id).where(AssetModel.uri 
== asset.uri))
         event = AssetEvent(
             asset_id=asset_id,
@@ -5906,12 +6081,8 @@ class TestSchedulerJob:
             source_map_index=-1,
         )
         session.add(event)
-
-        with dag_maker(
-            dag_id=f"consumer_{suffix}", schedule=[asset], 
bundle_name=bundle_name, session=session
-        ):
-            pass
-
+        # flush here to ensure event timestamp is before the ADRQ created_at 
timestamp
+        session.flush()
         session.add(AssetDagRunQueue(asset_id=asset_id, 
target_dag_id=f"consumer_{suffix}"))
         session.flush()
 
@@ -5981,6 +6152,41 @@ class TestSchedulerJob:
         assert len(session.scalars(adrq_q).all()) == 1
         assert session.scalars(adrq_q).one().target_dag_id == "consumer"
 
+    @pytest.mark.need_serialized_dag
+    def test_no_create_dag_runs_when_no_asset_event(self, session: Session, 
dag_maker, caplog):
+        asset = Asset(name="test_asset")
+        with dag_maker(dag_id="consumer", schedule=asset, session=session):
+            pass
+        dag_model = dag_maker.dag_model
+        asset_id = session.scalar(select(AssetModel.id).where(AssetModel.uri 
== asset.uri))
+        # Simulate an ADRQ row whose matching asset events were already 
consumed by an earlier DagRun.
+        # The ADRQ should be cleaned up even when no new DagRun is created, to 
prevent stale ADRQ
+        # rows from accumulating and causing infinite scheduler loops.
+        adrq = AssetDagRunQueue(
+            asset_id=asset_id, target_dag_id=dag_model.dag_id, 
created_at=timezone.utcnow()
+        )
+        session.add(adrq)
+        session.flush()
+        adrq.created_at = timezone.utcnow() + timedelta(seconds=1)
+        session.merge(adrq)
+        with caplog.at_level("INFO"):
+            scheduler_job = Job()
+            self.job_runner = SchedulerJobRunner(job=scheduler_job, 
executors=[MockExecutor(do_update=False)])
+            self.job_runner._create_dag_runs_asset_triggered(
+                dag_models=[dag_model],
+                session=session,
+            )
+        dr = session.scalars(select(DagRun).where(DagRun.dag_id == 
dag_model.dag_id)).one_or_none()
+        assert "No DagRun created" in caplog.text
+        assert dr is None
+        _adrq = session.scalars(
+            select(AssetDagRunQueue).where(
+                AssetDagRunQueue.asset_id == asset_id, 
AssetDagRunQueue.target_dag_id == dag_model.dag_id
+            )
+        ).one_or_none()
+        # ADRQ is deleted even when no DagRun is created, to prevent stale 
rows accumulating.
+        assert _adrq is None
+
     @time_machine.travel(DEFAULT_DATE + datetime.timedelta(days=1, seconds=9), 
tick=False)
     @mock.patch("airflow._shared.observability.metrics.stats._get_backend")
     def test_start_dagruns(self, mock_get_backend, dag_maker, session):
diff --git a/airflow-core/tests/unit/models/test_taskinstance.py 
b/airflow-core/tests/unit/models/test_taskinstance.py
index e0d4a1063e9..53fa99aa224 100644
--- a/airflow-core/tests/unit/models/test_taskinstance.py
+++ b/airflow-core/tests/unit/models/test_taskinstance.py
@@ -3875,6 +3875,39 @@ def 
test_runtime_partition_key_does_not_backfill_dag_run_when_none(dag_maker, se
     assert dr.partition_key is None
 
 
[email protected]("sqlite")
+def test_runtime_partition_key_backfill_does_not_deadlock_on_sqlite(dag_maker, 
session):
+    """Regression test for the SQLite ``database is locked`` deadlock between 
the
+    writes in ``register_asset_changes_in_db`` and the second connection that
+    ``_create_asset_event`` used to open.
+
+    On file-based SQLite (the default ``-b sqlite`` test backend) the two
+    connections compete for the same RESERVED lock; the SQLite branch of
+    ``_create_asset_event`` must add the event directly to the caller's session
+    instead of opening a second connection.
+    """
+    asset = Asset(name="hello")
+    with dag_maker(dag_id="rt_pk_backfill_sqlite", 
schedule=PartitionedAtRuntime()) as dag:
+        EmptyOperator(task_id="hi", outlets=[asset])
+    dr = dag_maker.create_dagrun(session=session)
+    assert dr.partition_key is None
+    [ti] = dr.get_task_instances(session=session)
+
+    # Must not raise "sqlite3.OperationalError: database is locked."
+    TaskInstance.register_asset_changes_in_db(
+        ti=ti,
+        task_outlets=[ensure_serialized_asset(asset).asprofile()],
+        outlet_events=[
+            {"dest_asset_key": {"name": "hello", "uri": "hello"}, "extra": {}, 
"partition_key": "us"},
+        ],
+        session=session,
+    )
+    event = session.scalar(select(AssetEvent).where(AssetEvent.source_dag_id 
== dag.dag_id))
+    assert event.partition_key == "us"
+    session.refresh(dr)
+    assert dr.partition_key is None
+
+
 def 
test_runtime_partition_key_does_not_overwrite_scheduler_partition(dag_maker, 
session):
     """Task-emitted key lands on the AssetEvent but does NOT overwrite a 
scheduler-set DagRun.partition_key."""
     asset = Asset(name="hello")

Reply via email to