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

uranusjr 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 44acf383e47 Reference asset events from asset_dag_run_queue (#70972)
44acf383e47 is described below

commit 44acf383e47892465c7e14f55f08b914ae150085
Author: Tzu-ping Chung <[email protected]>
AuthorDate: Wed Aug 5 09:13:54 2026 +0800

    Reference asset events from asset_dag_run_queue (#70972)
---
 airflow-core/docs/howto/usage-cli.rst              |   6 +
 airflow-core/docs/migrations-ref.rst               |   5 +-
 airflow-core/newsfragments/70972.significant.rst   |  13 ++
 airflow-core/src/airflow/assets/manager.py         | 135 +++---------
 .../src/airflow/jobs/scheduler_job_runner.py       |  87 +++-----
 .../0128_3_4_0_add_asset_event_id_to_adrq.py       | 205 ++++++++++++++++++
 airflow-core/src/airflow/models/asset.py           |  15 +-
 airflow-core/src/airflow/models/dag.py             |  55 +----
 airflow-core/src/airflow/utils/db.py               |   2 +-
 .../core_api/routes/public/test_assets.py          |   9 +-
 .../api_fastapi/core_api/routes/ui/test_assets.py  |  22 +-
 airflow-core/tests/unit/assets/test_manager.py     | 122 +++++++----
 airflow-core/tests/unit/jobs/test_scheduler_job.py | 240 +++++++++++++++------
 airflow-core/tests/unit/models/test_dag.py         | 116 ++++------
 .../tests/unit/models/test_taskinstance.py         |  17 +-
 .../tests/unit/timetables/test_assets_timetable.py |   5 +-
 16 files changed, 627 insertions(+), 427 deletions(-)

diff --git a/airflow-core/docs/howto/usage-cli.rst 
b/airflow-core/docs/howto/usage-cli.rst
index d96cf5331f9..21b37747e68 100644
--- a/airflow-core/docs/howto/usage-cli.rst
+++ b/airflow-core/docs/howto/usage-cli.rst
@@ -223,6 +223,12 @@ The ``db clean`` command works by deleting from each table 
the records older tha
 
 You can optionally provide a list of tables to perform deletes on. If no list 
of tables is supplied, all tables will be included.
 
+.. note::
+
+  Cleaning the ``asset_event`` table also drops any 
queued-but-not-yet-consumed asset events. This mainly
+  affects Dags waiting on a multi-asset condition, where a pending event can 
be purged before the condition
+  is met, meaning the Dag will not be triggered by it.
+
 You can filter cleanup to specific DAGs using ``--dag-ids`` (comma-separated 
list), or exclude specific DAGs using ``--exclude-dag-ids`` (comma-separated 
list). These options allow you to target or avoid cleanup for particular DAGs.
 
 You can use the ``--dry-run`` option to print the row counts in the primary 
tables to be cleaned.
diff --git a/airflow-core/docs/migrations-ref.rst 
b/airflow-core/docs/migrations-ref.rst
index dfc96be7901..e3b33cab185 100644
--- a/airflow-core/docs/migrations-ref.rst
+++ b/airflow-core/docs/migrations-ref.rst
@@ -39,7 +39,10 @@ Here's the list of all the Database Migrations that are 
executed via when you ru
 
+-------------------------+------------------+-------------------+--------------------------------------------------------------+
 | Revision ID             | Revises ID       | Airflow Version   | Description 
                                                 |
 
+=========================+==================+===================+==============================================================+
-| ``7a98f1b7dbd3`` (head) | ``c4e7a1f9b2d0`` | ``3.4.0``         | Add index 
on asset_event (asset_id, partition_key).          |
+| ``b2f1a9c7d4e0`` (head) | ``7a98f1b7dbd3`` | ``3.4.0``         | Reference 
the asset event from asset_dag_run_queue (consume- |
+|                         |                  |                   | 
by-reference).                                               |
++-------------------------+------------------+-------------------+--------------------------------------------------------------+
+| ``7a98f1b7dbd3``        | ``c4e7a1f9b2d0`` | ``3.4.0``         | Add index 
on asset_event (asset_id, partition_key).          |
 
+-------------------------+------------------+-------------------+--------------------------------------------------------------+
 | ``c4e7a1f9b2d0``        | ``436dc127462c`` | ``3.4.0``         | Add index 
on asset.uri.                                      |
 
+-------------------------+------------------+-------------------+--------------------------------------------------------------+
diff --git a/airflow-core/newsfragments/70972.significant.rst 
b/airflow-core/newsfragments/70972.significant.rst
new file mode 100644
index 00000000000..bae78066f12
--- /dev/null
+++ b/airflow-core/newsfragments/70972.significant.rst
@@ -0,0 +1,13 @@
+Changes to how pending asset events trigger Dag runs
+
+Deleting asset event records with ``airflow db clean`` now also removes any
+asset events that are still pending for a downstream Dag. This mainly affects
+Dags waiting on a multi-asset condition, where a pending event can be purged
+before the condition is met. Deleting an asset event now consistently means it
+will not trigger a run; previously such a pending trigger could still fire 
after
+the event had been purged.
+
+Relatedly, an asset event that was queued for a Dag while it was scheduled on
+that asset is still used to trigger the run, and appears among the run's
+triggering events, even if the asset is later removed from the Dag's schedule.
+Previously, they were silently dropped.
diff --git a/airflow-core/src/airflow/assets/manager.py 
b/airflow-core/src/airflow/assets/manager.py
index 9330c6071db..a417ad96eae 100644
--- a/airflow-core/src/airflow/assets/manager.py
+++ b/airflow-core/src/airflow/assets/manager.py
@@ -48,7 +48,6 @@ 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:
@@ -122,38 +121,6 @@ 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.
@@ -379,7 +346,10 @@ class AssetManager(LoggingMixin):
                 source_run_id=task_instance.run_id,
                 source_map_index=task_instance.map_index,
             )
-        asset_event = _create_asset_event(session=session, **event_kwargs)
+
+        asset_event = AssetEvent(**event_kwargs)
+        session.add(asset_event)
+        session.flush()
 
         dags_to_queue_from_asset = {ref.dag for ref in 
asset_model.scheduled_dags if not ref.dag.is_paused}
 
@@ -561,25 +531,7 @@ class AssetManager(LoggingMixin):
         if not non_partitioned_dags or partition_key is not None:
             return None
 
-        # Possible race condition: if multiple dags or multiple (usually
-        # mapped) tasks update the same asset, this can fail with a unique
-        # constraint violation.
-        #
-        # 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 TYPE_CHECKING:
-            assert dialect_name is not None
-        if dialect_name == "mysql":
-            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
-        )
+        return cls._queue_dagruns_nonpartitioned(asset_id, 
non_partitioned_dags, event, session)
 
     @classmethod
     def _queue_partitioned_dags(
@@ -844,71 +796,38 @@ class AssetManager(LoggingMixin):
             return apdr
 
     @classmethod
-    def _queue_dagruns_nonpartitioned_slow_path(
-        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, 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)
-            return dag.dag_id
-
-        queued_results = (_queue_dagrun_if_needed(dag) for dag in 
dags_to_queue)
-        if queued_dag_ids := [r for r in queued_results if r is not None]:
-            cls.logger().debug("consuming dag ids %s", queued_dag_ids)
-
-    @classmethod
-    def _queue_dagruns_nonpartitioned_mysql(
+    def _queue_dagruns_nonpartitioned(
         cls, asset_id: int, dags_to_queue: set[DagModel], event: AssetEvent, 
session: Session
     ) -> None:
-        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, 
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,
+        if not dags_to_queue:
+            return
+        values = [
+            {"asset_id": asset_id, "target_dag_id": dag.dag_id, 
"asset_event_id": event.id}
+            for dag in dags_to_queue
+        ]
+
+        if (dialect_name := get_dialect_name(session)) == "mysql":
+            from sqlalchemy.dialects.mysql import insert as my_insert
+
+            session.execute(
+                # The on-dup update a no-op since we are always working on 
ADRQs
+                # for the asset. It may not hold if you change e.g. ADRQ 
schema.
+                
my_insert(AssetDagRunQueue).on_duplicate_key_update(asset_id=AssetDagRunQueue.asset_id),
+                values,
             )
-        )
-        session.execute(update_stmt, values)
+            return
 
-    @classmethod
-    def _queue_dagruns_nonpartitioned_conflict_update(
-        cls,
-        asset_id: int,
-        dags_to_queue: set[DagModel],
-        event: AssetEvent,
-        session: Session,
-        dialect_name: str,
-    ) -> None:
-        """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, 
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(
+            insert(AssetDagRunQueue).on_conflict_do_nothing(
+                index_elements=["target_dag_id", "asset_event_id"]
+            ),
+            values,
         )
-        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 b6a29995d59..40d16515057 100644
--- a/airflow-core/src/airflow/jobs/scheduler_job_runner.py
+++ b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
@@ -28,7 +28,7 @@ import time
 from collections import Counter, defaultdict, deque
 from collections.abc import Callable, Collection, Iterable, Iterator
 from contextlib import ExitStack
-from datetime import date, datetime, timedelta
+from datetime import datetime, timedelta
 from functools import lru_cache, partial
 from itertools import groupby
 from typing import TYPE_CHECKING, Any, cast
@@ -135,10 +135,10 @@ from airflow.utils.types import DagRunTriggeredByType, 
DagRunType
 if TYPE_CHECKING:
     from types import FrameType
 
-    from pendulum.datetime import DateTime
     from sqlalchemy.engine import CursorResult
     from sqlalchemy.orm import Session
     from sqlalchemy.orm.interfaces import LoaderOption
+    from sqlalchemy.sql.elements import ColumnElement
     from sqlalchemy.sql.selectable import Subquery
 
     from airflow._shared.logging.types import Logger
@@ -2641,9 +2641,7 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
 
             queued_adrqs = session.scalars(
                 with_row_locks(
-                    select(AssetDagRunQueue)
-                    .where(AssetDagRunQueue.target_dag_id == dag.dag_id)
-                    .order_by(AssetDagRunQueue.created_at.desc()),
+                    
select(AssetDagRunQueue).where(AssetDagRunQueue.target_dag_id == dag.dag_id),
                     of=AssetDagRunQueue,
                     skip_locked=True,
                     key_share=False,
@@ -2658,53 +2656,31 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
                 )
                 continue
 
-            triggered_date: DateTime = 
timezone.coerce_datetime(queued_adrqs[0].created_at)
-            self.log.debug(
-                "Creating asset-triggered DagRun for '%s': %d queued assets, 
triggered_date=%s",
-                dag.dag_id,
-                len(queued_adrqs),
-                triggered_date,
-            )
-            cte = (
-                
select(func.max(DagRun.run_after).label("previous_dag_run_run_after"))
-                .where(
-                    DagRun.dag_id == dag.dag_id,
-                    DagRun.run_type == DagRunType.ASSET_TRIGGERED,
-                    DagRun.run_after < triggered_date,
-                )
-                .cte()
-            )
-
-            # A first asset-triggered run has no previous run to floor the 
event window. With
-            # catchup off, floor it at when the Dag started scheduling on its 
assets so the
-            # backlog is skipped; with catchup on, only date.min applies and 
the backlog replays.
-            event_window_floor: list[Any] = [cte.c.previous_dag_run_run_after]
-            if not dag.catchup:
-                event_window_floor.append(
-                    select(func.min(DagScheduleAssetReference.created_at))
-                    .where(DagScheduleAssetReference.dag_id == dag.dag_id)
-                    .scalar_subquery()
+            referenced_event_ids = {adrq.asset_event_id for adrq in 
queued_adrqs}
+            event_predicate: ColumnElement[bool] = 
AssetEvent.id.in_(referenced_event_ids)
+            if dag.catchup:
+                # With catchup on, also consume events recorded before the Dag 
started
+                # scheduling on its assets/aliases, not just those with a 
queue row. (With catchup
+                # off only queued events are consumed.) The not-consumed 
filter below dedupes
+                # across runs, so no event window is needed.
+                event_predicate = or_(
+                    event_predicate,
+                    AssetEvent.asset_id.in_(
+                        select(DagScheduleAssetReference.asset_id).where(
+                            DagScheduleAssetReference.dag_id == dag.dag_id
+                        )
+                    ),
+                    AssetEvent.source_aliases.any(
+                        AssetAliasModel.scheduled_dags.any(
+                            DagScheduleAssetAliasReference.dag_id == dag.dag_id
+                        )
+                    ),
                 )
-            event_window_floor.append(date.min)
-
             asset_events = list(
                 session.scalars(
                     select(AssetEvent)
                     .where(
-                        or_(
-                            AssetEvent.asset_id.in_(
-                                
select(DagScheduleAssetReference.asset_id).where(
-                                    DagScheduleAssetReference.dag_id == 
dag.dag_id
-                                )
-                            ),
-                            AssetEvent.source_aliases.any(
-                                AssetAliasModel.scheduled_dags.any(
-                                    DagScheduleAssetAliasReference.dag_id == 
dag.dag_id
-                                )
-                            ),
-                        ),
-                        AssetEvent.timestamp > 
func.coalesce(*event_window_floor),
-                        AssetEvent.timestamp <= triggered_date,
+                        event_predicate,
                         ~(
                             select(association_table.c.event_id)
                             .join(DagRun, DagRun.id == 
association_table.c.dag_run_id)
@@ -2719,6 +2695,13 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
                 )
             )
             if asset_events:
+                triggered_date = timezone.coerce_datetime(max(event.timestamp 
for event in asset_events))
+                self.log.debug(
+                    "Creating asset-triggered DagRun for '%s': %d queued 
assets, triggered_date=%s",
+                    dag.dag_id,
+                    len(queued_adrqs),
+                    triggered_date,
+                )
                 dag_run = dag.create_dagrun(
                     run_id=DagRun.generate_run_id(
                         run_type=DagRunType.ASSET_TRIGGERED, 
logical_date=None, run_after=triggered_date
@@ -2747,19 +2730,19 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
                 )
             else:
                 self.log.info(
-                    "No DagRun created for '%s' at '%s' - asset events already 
consumed or none found",
+                    "No DagRun created for '%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),
-                        AssetDagRunQueue.created_at <= triggered_date,
+                        tuple_(
+                            AssetDagRunQueue.target_dag_id,
+                            AssetDagRunQueue.asset_event_id,
+                        ).in_((adrq.target_dag_id, adrq.asset_event_id) for 
adrq in queued_adrqs)
                     )
                 ),
             )
diff --git 
a/airflow-core/src/airflow/migrations/versions/0128_3_4_0_add_asset_event_id_to_adrq.py
 
b/airflow-core/src/airflow/migrations/versions/0128_3_4_0_add_asset_event_id_to_adrq.py
new file mode 100644
index 00000000000..ff2a2282181
--- /dev/null
+++ 
b/airflow-core/src/airflow/migrations/versions/0128_3_4_0_add_asset_event_id_to_adrq.py
@@ -0,0 +1,205 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""
+Reference the asset event from asset_dag_run_queue (consume-by-reference).
+
+Table ``asset_dag_run_queue`` gets a new column ``asset_event_id``, and the
+primary key is made ``(target_dag_id, asset_event_id)``, so the scheduler
+consumes queued asset events by reference instead of by a ``created_at`` time
+window. ``asset_id`` is kept as a denormalized column.
+
+Existing rows are coalesced (one per ``(asset_id, target_dag_id)``) and carry 
no
+event reference. Rather than dropping them (which would silently skip pending
+asset-triggered Dag runs), the pre-migration scheduler's own consumption 
window is
+replayed per dag to rebuild per-event rows:
+
+    triggered_date =
+        MAX(asset_dag_run_queue.created_at) (per dag)
+    floor = COALESCE(
+        MAX(dag_run.run_after) for asset-triggered runs of the dag with
+            run_after < triggered_date,                  (per dag)
+        dag_schedule_asset_reference.created_at,     (per dag + asset)
+    )
+    contributing events =
+        the queued asset's events with
+        floor < asset_event.timestamp <= triggered_date
+
+The expansion is staged in a side table, the queue is cleared, the (now empty)
+table is reshaped, and the staged rows are inserted back.
+
+Revision ID: b2f1a9c7d4e0
+Revises: 7a98f1b7dbd3
+Create Date: 2026-08-03 12:00:00.000000
+"""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from textwrap import dedent
+
+import sqlalchemy as sa
+from alembic import context, op
+
+# revision identifiers, used by Alembic.
+revision = "b2f1a9c7d4e0"
+down_revision = "7a98f1b7dbd3"
+branch_labels = None
+depends_on = None
+airflow_version = "3.4.0"
+
+_STAGING = "_adrq_migration_staging"
+
+_STAGE_SQL = f"""
+CREATE TABLE {_STAGING} AS
+SELECT DISTINCT
+    adrq.target_dag_id AS target_dag_id,
+    adrq.asset_id      AS asset_id,
+    ae.id              AS asset_event_id
+FROM asset_dag_run_queue adrq
+JOIN (
+    SELECT t.target_dag_id,
+           t.triggered_date,
+           (
+               SELECT MAX(dr.run_after)
+               FROM dag_run dr
+               WHERE dr.dag_id = t.target_dag_id
+                 AND dr.run_type = 'asset_triggered'
+                 AND dr.run_after < t.triggered_date
+           ) AS floor_date
+    FROM (
+        SELECT target_dag_id, MAX(created_at) AS triggered_date
+        FROM asset_dag_run_queue
+        GROUP BY target_dag_id
+    ) t
+) td ON td.target_dag_id = adrq.target_dag_id
+LEFT JOIN dag_schedule_asset_reference dsar
+    ON dsar.dag_id = adrq.target_dag_id
+   AND dsar.asset_id = adrq.asset_id
+JOIN asset_event ae
+    ON ae.asset_id = adrq.asset_id
+   AND ae.timestamp <= td.triggered_date
+   AND ae.timestamp > COALESCE(td.floor_date, dsar.created_at, :floor_min)
+"""
+
+
+def upgrade():
+    """Directly reference the asset event from asset_dag_run_queue."""
+    # 1. Add the reference column, nullable for now; it is made NOT NULL after 
the rebuild.
+    op.add_column("asset_dag_run_queue", sa.Column("asset_event_id", 
sa.Integer(), nullable=True))
+
+    # 2. Stage the faithful per-event expansion, then clear the coalesced rows.
+    if context.is_offline_mode():
+        print(
+            dedent("""
+            ------------
+            --  WARNING: asset_dag_run_queue cannot be rebuilt in offline mode;
+            --  any pending (unprocessed) queued asset events will be dropped.
+            ------------
+            """)
+        )
+        op.execute("DELETE FROM asset_dag_run_queue")
+    else:
+        conn = op.get_bind()
+        floor_min = datetime(1970, 1, 1, tzinfo=timezone.utc)
+        conn.execute(sa.text(_STAGE_SQL), {"floor_min": floor_min})
+        conn.execute(sa.text("DELETE FROM asset_dag_run_queue"))
+
+    # 3. Make the reference NOT NULL, move the PK, adjust FKs and indexes.
+    with op.batch_alter_table("asset_dag_run_queue") as batch_op:
+        batch_op.alter_column("asset_event_id", existing_type=sa.Integer(), 
nullable=False)
+        batch_op.drop_constraint("adrq_asset_fkey", type_="foreignkey")
+        batch_op.drop_constraint("assetdagrunqueue_pkey", type_="primary")
+        batch_op.create_primary_key("assetdagrunqueue_pkey", ["target_dag_id", 
"asset_event_id"])
+        batch_op.create_index("idx_adrq_asset_id", ["asset_id"])
+        batch_op.create_foreign_key("adrq_asset_fkey", "asset", ["asset_id"], 
["id"], ondelete="CASCADE")
+        batch_op.create_foreign_key(
+            "adrq_asset_event_fkey", "asset_event", ["asset_event_id"], 
["id"], ondelete="CASCADE"
+        )
+        batch_op.drop_index("idx_asset_dag_run_queue_target_dag_id")
+
+    # 4. Repopulate the per-event rows from staging.
+    if not context.is_offline_mode():
+        conn = op.get_bind()
+        now = datetime.now(timezone.utc)
+        conn.execute(
+            sa.text(
+                f"""
+                INSERT INTO asset_dag_run_queue (asset_id, target_dag_id, 
asset_event_id, created_at)
+                SELECT asset_id, target_dag_id, asset_event_id, :now
+                FROM {_STAGING}
+                """
+            ),
+            {"now": now},
+        )
+        op.drop_table(_STAGING)
+
+
+def downgrade():
+    """Revert reference to asset_id from asset_dag_run_queue."""
+    # 1. Rebuild the coalesced rows: collapse per-event rows to one row per
+    #    (asset_id, target_dag_id) (created_at = max referenced event 
timestamp), then clear.
+    if context.is_offline_mode():
+        print(
+            dedent("""
+            ------------
+            --  WARNING: asset_dag_run_queue cannot be rebuilt in offline mode;
+            --  any pending (unprocessed) queued asset events will be dropped.
+            ------------
+            """)
+        )
+        op.execute("DELETE FROM asset_dag_run_queue")
+    else:
+        conn = op.get_bind()
+        conn.execute(
+            sa.text(
+                f"""
+                CREATE TABLE {_STAGING} AS
+                SELECT adrq.asset_id      AS asset_id,
+                    adrq.target_dag_id AS target_dag_id,
+                    MAX(ae.timestamp)  AS created_at
+                FROM asset_dag_run_queue adrq
+                JOIN asset_event ae ON ae.id = adrq.asset_event_id
+                GROUP BY adrq.asset_id, adrq.target_dag_id
+                """
+            )
+        )
+        conn.execute(sa.text("DELETE FROM asset_dag_run_queue"))
+
+    # 2. Drop the reference + FK, restore the old primary key and indexes.
+    with op.batch_alter_table("asset_dag_run_queue") as batch_op:
+        batch_op.drop_constraint("adrq_asset_event_fkey", type_="foreignkey")
+        batch_op.create_index("idx_asset_dag_run_queue_target_dag_id", 
["target_dag_id"])
+        batch_op.drop_constraint("assetdagrunqueue_pkey", type_="primary")
+        batch_op.create_primary_key("assetdagrunqueue_pkey", ["asset_id", 
"target_dag_id"])
+        batch_op.drop_index("idx_adrq_asset_id")
+        batch_op.drop_column("asset_event_id")
+
+    # 3. Repopulate the coalesced rows.
+    if not context.is_offline_mode():
+        conn = op.get_bind()
+        conn.execute(
+            sa.text(
+                f"""
+                INSERT INTO asset_dag_run_queue (asset_id, target_dag_id, 
created_at)
+                SELECT asset_id, target_dag_id, created_at
+                FROM {_STAGING}
+                """
+            )
+        )
+        op.drop_table(_STAGING)
diff --git a/airflow-core/src/airflow/models/asset.py 
b/airflow-core/src/airflow/models/asset.py
index 7aafddb3b94..750aac8d2b7 100644
--- a/airflow-core/src/airflow/models/asset.py
+++ b/airflow-core/src/airflow/models/asset.py
@@ -749,33 +749,40 @@ class TaskInletAssetReference(Base):
 class AssetDagRunQueue(Base):
     """Model for storing asset events that need processing."""
 
-    asset_id: Mapped[int] = mapped_column(Integer, primary_key=True, 
nullable=False)
     target_dag_id: Mapped[str] = mapped_column(StringID(), primary_key=True, 
nullable=False)
+    asset_event_id: Mapped[int] = mapped_column(Integer, primary_key=True, 
nullable=False)
+    asset_id: Mapped[int] = mapped_column(Integer, nullable=False)
     created_at: Mapped[datetime] = mapped_column(UtcDateTime, 
default=timezone.utcnow, nullable=False)
     asset: Mapped[AssetModel] = relationship("AssetModel", viewonly=True)
     dag_model: Mapped[DagModel] = relationship("DagModel", viewonly=True)
 
     __tablename__ = "asset_dag_run_queue"
     __table_args__ = (
-        PrimaryKeyConstraint(asset_id, target_dag_id, 
name="assetdagrunqueue_pkey"),
+        PrimaryKeyConstraint(target_dag_id, asset_event_id, 
name="assetdagrunqueue_pkey"),
         ForeignKeyConstraint(
             (asset_id,),
             ["asset.id"],
             name="adrq_asset_fkey",
             ondelete="CASCADE",
         ),
+        ForeignKeyConstraint(
+            (asset_event_id,),
+            ["asset_event.id"],
+            name="adrq_asset_event_fkey",
+            ondelete="CASCADE",
+        ),
         ForeignKeyConstraint(
             (target_dag_id,),
             ["dag.dag_id"],
             name="adrq_dag_fkey",
             ondelete="CASCADE",
         ),
-        Index("idx_asset_dag_run_queue_target_dag_id", target_dag_id),
+        Index("idx_adrq_asset_id", asset_id),
     )
 
     def __eq__(self, other: object) -> bool:
         if isinstance(other, self.__class__):
-            return self.asset_id == other.asset_id and self.target_dag_id == 
other.target_dag_id
+            return self.target_dag_id == other.target_dag_id and 
self.asset_event_id == other.asset_event_id
         return NotImplemented
 
     def __hash__(self):
diff --git a/airflow-core/src/airflow/models/dag.py 
b/airflow-core/src/airflow/models/dag.py
index 29407e53bb0..d6dfab06a27 100644
--- a/airflow-core/src/airflow/models/dag.py
+++ b/airflow-core/src/airflow/models/dag.py
@@ -36,7 +36,6 @@ from sqlalchemy import (
     Integer,
     String,
     Text,
-    and_,
     case,
     func,
     or_,
@@ -60,7 +59,7 @@ from airflow._shared.timezones import timezone
 from airflow.assets.evaluation import AssetEvaluator
 from airflow.configuration import conf as airflow_conf
 from airflow.exceptions import AirflowException
-from airflow.models.asset import AssetDagRunQueue, AssetModel
+from airflow.models.asset import AssetDagRunQueue
 from airflow.models.base import Base, StringID
 from airflow.models.dagbundle import DagBundleModel
 from airflow.models.dagrun import DagRun
@@ -263,47 +262,6 @@ def get_last_dagrun(dag_id: str, session: Session, 
include_manually_triggered: b
     return session.scalar(query.limit(1))
 
 
-def get_asset_triggered_next_run_info(
-    dag_ids: list[str], *, session: Session
-) -> dict[str, dict[str, int | str]]:
-    """
-    Get next run info for a list of dag_ids.
-
-    Given a list of dag_ids, get string representing how close any that are 
asset triggered are to
-    their next run, e.g. "1 of 2 assets updated".
-    """
-    from airflow.models.asset import AssetDagRunQueue as ADRQ, 
DagScheduleAssetReference
-
-    return {
-        x.dag_id: {
-            "uri": x.uri,
-            "ready": x.ready,
-            "total": x.total,
-        }
-        for x in session.execute(
-            select(
-                DagScheduleAssetReference.dag_id,
-                # This is a dirty hack to workaround group by requiring an 
aggregate,
-                # since grouping by asset is not what we want to do here...but 
it works
-                case((func.count() == 1, func.max(AssetModel.uri)), 
else_="").label("uri"),
-                func.count().label("total"),
-                func.sum(case((ADRQ.target_dag_id.is_not(None), 1), 
else_=0)).label("ready"),
-            )
-            .join(
-                ADRQ,
-                and_(
-                    ADRQ.asset_id == DagScheduleAssetReference.asset_id,
-                    ADRQ.target_dag_id == DagScheduleAssetReference.dag_id,
-                ),
-                isouter=True,
-            )
-            .join(AssetModel, AssetModel.id == 
DagScheduleAssetReference.asset_id)
-            .group_by(DagScheduleAssetReference.dag_id)
-            .where(DagScheduleAssetReference.dag_id.in_(dag_ids))
-        ).all()
-    }
-
-
 class DagTag(Base):
     """A tag name per dag, to allow quick filtering in the DAG view."""
 
@@ -855,17 +813,6 @@ class DagModel(Base):
             next_dagrun_partition_date=str(self.next_dagrun_partition_date),
         )
 
-    @provide_session
-    def get_asset_triggered_next_run_info(
-        self, *, session: Session = NEW_SESSION
-    ) -> dict[str, int | str] | None:
-        if self.asset_expression is None:
-            return None
-
-        # When an asset alias does not resolve into assets, 
get_asset_triggered_next_run_info returns
-        # an empty dict as there's no asset info to get. This method should 
thus return None.
-        return get_asset_triggered_next_run_info([self.dag_id], 
session=session).get(self.dag_id, None)
-
     @staticmethod
     @cached(_team_name_cache, key=lambda dag_id, **_: dag_id, 
lock=_team_name_cache_lock)
     @provide_session
diff --git a/airflow-core/src/airflow/utils/db.py 
b/airflow-core/src/airflow/utils/db.py
index 615ecb66c67..21ccd5ea3bc 100644
--- a/airflow-core/src/airflow/utils/db.py
+++ b/airflow-core/src/airflow/utils/db.py
@@ -117,7 +117,7 @@ _REVISION_HEADS_MAP: dict[str, str] = {
     "3.1.8": "509b94a1042d",
     "3.2.0": "1d6611b6ab7c",
     "3.3.0": "d2f4e1b3c5a7",
-    "3.4.0": "7a98f1b7dbd3",
+    "3.4.0": "b2f1a9c7d4e0",
 }
 
 # Prefix used to identify tables holding data moved during migration.
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_assets.py 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_assets.py
index 39054a28d70..462d1605af0 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_assets.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_assets.py
@@ -1505,7 +1505,10 @@ class TestQueuedEventEndpoint(TestAssets):
     def _create_asset_dag_run_queues(self, dag_id, asset_id, session):
         session.execute(delete(AssetDagRunQueue))
         session.flush()
-        adrq = AssetDagRunQueue(target_dag_id=dag_id, asset_id=asset_id)
+        event = AssetEvent(asset_id=asset_id, timestamp=timezone.utcnow())
+        session.add(event)
+        session.flush()
+        adrq = AssetDagRunQueue(target_dag_id=dag_id, asset_id=asset_id, 
asset_event_id=event.id)
         session.add(adrq)
         session.commit()
         return adrq
@@ -2226,10 +2229,10 @@ class 
TestDeleteAssetQueuedEvents(TestQueuedEventEndpoint):
         (asset,) = self.create_assets(session=session, num=1)
         self._create_asset_dag_run_queues(dag_id, asset.id, session)
 
-        assert session.get(AssetDagRunQueue, (asset.id, dag_id)) is not None
+        assert session.scalars(select(AssetDagRunQueue)).all()
         response = test_client.delete(f"/assets/{asset.id}/queuedEvents")
         assert response.status_code == 204
-        assert session.get(AssetDagRunQueue, (asset.id, dag_id)) is None
+        assert session.scalars(select(AssetDagRunQueue)).all() == []
         check_last_log(session, dag_id=None, 
event="delete_asset_queued_events", logical_date=None)
 
     def test_should_respond_401(self, unauthenticated_test_client):
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_assets.py 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_assets.py
index 05735154c5f..2427729035f 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_assets.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_assets.py
@@ -137,9 +137,15 @@ class TestNextRunAssets:
             )
         }
         # Queue and add an event only for A
-        session.add(AssetDagRunQueue(asset_id=assets["s3://bucket/A"].id, 
target_dag_id="two_assets_equal"))
+        event = AssetEvent(asset_id=assets["s3://bucket/A"].id, 
timestamp=dr.logical_date or pendulum.now())
+        session.add(event)
+        session.flush()
         session.add(
-            AssetEvent(asset_id=assets["s3://bucket/A"].id, 
timestamp=dr.logical_date or pendulum.now())
+            AssetDagRunQueue(
+                asset_id=assets["s3://bucket/A"].id,
+                target_dag_id="two_assets_equal",
+                asset_event_id=event.id,
+            )
         )
         session.commit()
 
@@ -210,12 +216,16 @@ class TestNextRunAssets:
         dag_maker.sync_dagbag_to_db()
 
         asset = session.scalars(select(AssetModel).where(AssetModel.uri == 
"s3://bucket/F")).one()
-        session.add(AssetDagRunQueue(asset_id=asset.id, 
target_dag_id="filter_run"))
-        # event before latest_run should be ignored
         ts_base = dr.logical_date or pendulum.now()
-        session.add(AssetEvent(asset_id=asset.id, 
timestamp=ts_base.subtract(minutes=10)))
+        # event before latest_run should be ignored
+        event_before = AssetEvent(asset_id=asset.id, 
timestamp=ts_base.subtract(minutes=10))
         # event after latest_run counts
-        session.add(AssetEvent(asset_id=asset.id, 
timestamp=ts_base.add(minutes=10)))
+        event_after = AssetEvent(asset_id=asset.id, 
timestamp=ts_base.add(minutes=10))
+        session.add_all([event_before, event_after])
+        session.flush()
+        session.add(
+            AssetDagRunQueue(asset_id=asset.id, target_dag_id="filter_run", 
asset_event_id=event_after.id)
+        )
         session.commit()
 
         resp = test_client.get("/next_run_assets/filter_run")
diff --git a/airflow-core/tests/unit/assets/test_manager.py 
b/airflow-core/tests/unit/assets/test_manager.py
index 030a0d96c8b..3d9d75ff12a 100644
--- a/airflow-core/tests/unit/assets/test_manager.py
+++ b/airflow-core/tests/unit/assets/test_manager.py
@@ -25,8 +25,8 @@ from typing import TYPE_CHECKING
 from unittest import mock
 
 import pytest
-from sqlalchemy import delete, func, select
-from sqlalchemy.dialects import mysql
+from sqlalchemy import func, select
+from sqlalchemy.dialects import mysql, postgresql, sqlite
 from sqlalchemy.orm import Session
 
 from airflow import settings
@@ -67,7 +67,7 @@ pytestmark = pytest.mark.db_test
 pytest.importorskip("pydantic", minversion="2.0.0")
 
 
[email protected]
[email protected](autouse=True)
 def clear_assets():
     from tests_common.test_utils.db import clear_db_assets
 
@@ -137,7 +137,6 @@ class TestAssetManager:
         asm = AssetModel(uri="test://asset1/", name="test_asset_uri", 
group="asset")
         session.add(asm)
         asm.scheduled_dags = [DagScheduleAssetReference(dag_id=dag.dag_id) for 
dag in (dag1, dag2)]
-        session.execute(delete(AssetDagRunQueue))
         session.flush()
 
         asset_manager.register_asset_change(task_instance=mock_task_instance, 
asset=asset, session=session)
@@ -150,7 +149,6 @@ class TestAssetManager:
         )
         assert 
session.scalar(select(func.count()).select_from(AssetDagRunQueue)) == 2
 
-    @pytest.mark.usefixtures("clear_assets")
     def test_register_asset_change_with_alias(
         self, session, dag_maker, mock_task_instance, testing_dag_bundle
     ):
@@ -173,7 +171,6 @@ class TestAssetManager:
             DagScheduleAssetAliasReference(alias_id=asam.id, dag_id=dag.dag_id)
             for dag in (consumer_dag_1, consumer_dag_2)
         ]
-        session.execute(delete(AssetDagRunQueue))
         session.flush()
 
         asset = Asset(uri="test://asset1", name="test_asset_uri")
@@ -199,7 +196,6 @@ class TestAssetManager:
         asset = Asset(uri="test://asset1", name="never_consumed")
         asm = AssetModel(uri="test://asset1/", name="never_consumed", 
group="asset")
         session.add(asm)
-        session.execute(delete(AssetDagRunQueue))
         session.flush()
 
         asset_manager.register_asset_change(task_instance=mock_task_instance, 
asset=asset, session=session)
@@ -212,23 +208,42 @@ class TestAssetManager:
         )
         assert 
session.scalar(select(func.count()).select_from(AssetDagRunQueue)) == 0
 
-    @pytest.mark.parametrize(
-        ("dialect_name", "expected_helper"),
-        [
-            ("postgresql", "_queue_dagruns_nonpartitioned_conflict_update"),
-            ("mysql", "_queue_dagruns_nonpartitioned_mysql"),
-            ("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."""
+    def test_register_asset_change_is_atomic_on_caller_session(self, session, 
mock_task_instance):
+        """The AssetEvent is written on the caller's session: visible before 
commit, gone after rollback.
+
+        Under the old side-session behaviour the event was committed 
independently and would be
+        orphaned if the caller's transaction rolled back. Registration is now 
atomic with the caller.
+        """
+        asset_manager = AssetManager()
+
+        asset = Asset(uri="test://atomic1", name="atomic_asset")
+        asm = AssetModel(uri="test://atomic1/", name="atomic_asset", 
group="asset")
+        session.add(asm)
+        session.flush()
+
+        event = asset_manager.register_asset_change(
+            task_instance=mock_task_instance, asset=asset, session=session
+        )
+        session.flush()
+
+        # The event is written on the caller's session and carries a real id 
before any commit.
+        assert event is not None
+        assert event.id is not None
+        event_id = event.id
+        assert session.get(AssetEvent, event_id) is not None
+
+        # Rolling back the caller's transaction discards the event -> no 
orphan row.
+        session.rollback()
+        assert session.get(AssetEvent, event_id) is None
+
+    def test_queue_dagruns_calls_nonpartitioned_helper(self):
+        """`_queue_dagruns` delegates non-partitioned dags to the single 
non-partitioned 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"),
-            mock.patch.object(AssetManager, expected_helper) as mock_helper,
+            mock.patch.object(AssetManager, "_queue_dagruns_nonpartitioned") 
as mock_helper,
         ):
             AssetManager._queue_dagruns(
                 asset_id=1,
@@ -239,26 +254,45 @@ class TestAssetManager:
                 task_instance=None,
                 session=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}")
+        mock_helper.assert_called_once_with(1, {dag}, event, session)
 
-    def test_queue_dagruns_nonpartitioned_mysql_builds_upsert(self):
-        """Test that the MySQL queue path emits an INSERT ... ON DUPLICATE KEY 
UPDATE."""
+    @pytest.mark.parametrize("dialect_name", ["postgresql", "sqlite"])
+    def test_queue_dagruns_nonpartitioned_insert_or_ignore(self, dialect_name):
+        """On postgres/sqlite the helper emits INSERT ... ON CONFLICT DO 
NOTHING referencing the event."""
         dag = DagModel(dag_id="dag1")
         session = mock.MagicMock(spec=Session)
         event = AssetEvent(asset_id=1)
-        AssetManager._queue_dagruns_nonpartitioned_mysql(
-            asset_id=1, dags_to_queue={dag}, event=event, session=session
-        )
+        event.id = 99
+        with mock.patch("airflow.assets.manager.get_dialect_name", 
return_value=dialect_name):
+            AssetManager._queue_dagruns_nonpartitioned(
+                asset_id=1, dags_to_queue={dag}, event=event, session=session
+            )
+
+        stmt, values = session.execute.call_args.args
+        dialect = {"postgresql": postgresql.dialect(), "sqlite": 
sqlite.dialect()}[dialect_name]
+        compiled = str(stmt.compile(dialect=dialect)).upper()
+        assert "ON CONFLICT" in compiled
+        assert "DO NOTHING" in compiled
+        # One ADRQ row is inserted per (dag, event), carrying the denormalized 
asset_id
+        # plus the referenced asset_event_id.
+        assert values == [{"asset_id": 1, "target_dag_id": "dag1", 
"asset_event_id": 99}]
+
+    def test_queue_dagruns_nonpartitioned_upsert_noop_on_mysql(self):
+        """On MySQL the helper upserts with a no-op ON DUPLICATE KEY UPDATE 
(scoped to duplicate
+        keys, unlike INSERT IGNORE) referencing the triggering event."""
+        dag = DagModel(dag_id="dag1")
+        session = mock.MagicMock(spec=Session)
+        event = AssetEvent(asset_id=1)
+        event.id = 99
+        with mock.patch("airflow.assets.manager.get_dialect_name", 
return_value="mysql"):
+            AssetManager._queue_dagruns_nonpartitioned(
+                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()
-        assert "ON DUPLICATE KEY UPDATE" in compiled
-        assert values == [{"target_dag_id": "dag1"}]
+        assert "ON DUPLICATE KEY UPDATE ASSET_ID = 
ASSET_DAG_RUN_QUEUE.ASSET_ID" in compiled
+        assert values == [{"asset_id": 1, "target_dag_id": "dag1", 
"asset_event_id": 99}]
 
     def test_register_asset_change_notifies_asset_listener(
         self, session, mock_task_instance, testing_dag_bundle, listener_manager
@@ -381,7 +415,7 @@ class TestAssetManager:
         assert len(set(ids)) == 1
         assert 
session.scalar(select(func.count()).select_from(AssetPartitionDagRun)) == 1
 
-    @pytest.mark.usefixtures("clear_assets", "testing_dag_bundle")
+    @pytest.mark.usefixtures("testing_dag_bundle")
     def test_get_or_create_apdr_suppresses_conflicting_partition_date(self, 
session):
         """Two events resolving the same target key to different dates → 
suppress to None.
 
@@ -416,7 +450,7 @@ class TestAssetManager:
         assert second.id == first.id  # same pending APDR
         assert second.partition_date is None  # conflict suppressed, 
deterministic
 
-    @pytest.mark.usefixtures("clear_assets", "testing_dag_bundle")
+    @pytest.mark.usefixtures("testing_dag_bundle")
     def test_get_or_create_apdr_keeps_agreeing_partition_date(self, session):
         """A later event carrying the same (or no) date does not trip the 
conflict suppression."""
         asm = AssetModel(uri="test://asset1/", name="partition_asset", 
group="asset")
@@ -444,7 +478,7 @@ class TestAssetManager:
         assert with_none.id == first.id
         assert with_none.partition_date == source_date
 
-    @pytest.mark.usefixtures("clear_assets", "testing_dag_bundle")
+    @pytest.mark.usefixtures("testing_dag_bundle")
     def test_get_or_create_apdr_adopts_date_when_existing_is_none(self, 
session):
         """An APDR created with no date adopts a later event's carried date 
(not dropped)."""
         asm = AssetModel(uri="test://asset1/", name="partition_asset", 
group="asset")
@@ -469,7 +503,7 @@ class TestAssetManager:
         assert adopted.id == first.id
         assert adopted.partition_date == source_date
 
-    @pytest.mark.usefixtures("clear_assets", "testing_dag_bundle")
+    @pytest.mark.usefixtures("testing_dag_bundle")
     def test_get_or_create_apdr_recovers_after_conflict(self, session):
         """Once a conflict has suppressed the date to None, a later event 
re-adopts a date."""
         asm = AssetModel(uri="test://asset1/", name="partition_asset", 
group="asset")
@@ -497,7 +531,7 @@ class TestAssetManager:
         assert recovered.id == first.id
         assert recovered.partition_date == date_2
 
-    @pytest.mark.usefixtures("clear_assets", "testing_dag_bundle")
+    @pytest.mark.usefixtures("testing_dag_bundle")
     def test_carry_partition_date_failure_degrades_to_none(self, session, 
dag_maker, mock_task_instance):
         """A mapper whose carry_partition_date raises must not abort the write.
 
@@ -634,7 +668,6 @@ class TestAssetManager:
         # Link the Stale Dag to the Asset
         asm.scheduled_dags = 
[DagScheduleAssetReference(dag_id=stale_dag.dag_id)]
 
-        session.execute(delete(AssetDagRunQueue))
         session.flush()
 
         # Register the asset change
@@ -649,7 +682,7 @@ class TestAssetManager:
         queued_id = session.scalar(select(AssetDagRunQueue.target_dag_id))
         assert queued_id == "stale_dag"
 
-    @pytest.mark.usefixtures("clear_assets", "testing_dag_bundle")
+    @pytest.mark.usefixtures("testing_dag_bundle")
     def 
test_partitioned_asset_event_does_not_trigger_non_partitioned_dag(self, 
session, mock_task_instance):
         """partitioned asset events (events with partition key) must not queue 
non-partition-aware Dags."""
         asm = AssetModel(uri="test://asset/", name="test_asset", group="asset")
@@ -659,7 +692,6 @@ class TestAssetManager:
         )
         session.add(dag)
         asm.scheduled_dags = [DagScheduleAssetReference(dag_id=dag.dag_id)]
-        session.execute(delete(AssetDagRunQueue))
         session.flush()
 
         AssetManager.register_asset_change(
@@ -683,7 +715,7 @@ class TestAssetManager:
             pytest.param(6, True, id="one_over_cap_trips"),
         ],
     )
-    @pytest.mark.usefixtures("clear_assets", "testing_dag_bundle")
+    @pytest.mark.usefixtures("testing_dag_bundle")
     def test_partition_fan_out_cap(self, session, dag_maker, 
mock_task_instance, cap, expect_trip):
         """The ``[scheduler] partition_mapper_max_downstream_keys`` cap gates 
fan-out.
 
@@ -740,7 +772,7 @@ class TestAssetManager:
         assert error_call.kwargs["cap_source"] == f"[scheduler] 
partition_mapper_max_downstream_keys={cap}"
 
     @conf_vars({("scheduler", "partition_mapper_max_downstream_keys"): "100"})
-    @pytest.mark.usefixtures("clear_assets", "testing_dag_bundle")
+    @pytest.mark.usefixtures("testing_dag_bundle")
     def test_partition_fanout_per_mapper_override_stricter_than_global_trips(
         self, session, dag_maker, mock_task_instance
     ):
@@ -787,7 +819,7 @@ class TestAssetManager:
         assert error_call.kwargs["cap_source"] == "max_downstream_keys=3"
 
     @conf_vars({("scheduler", "partition_mapper_max_downstream_keys"): "3"})
-    @pytest.mark.usefixtures("clear_assets", "testing_dag_bundle")
+    @pytest.mark.usefixtures("testing_dag_bundle")
     def test_partition_fanout_per_mapper_override_looser_than_global_permits(
         self, session, dag_maker, mock_task_instance
     ):
@@ -831,7 +863,7 @@ class TestAssetManager:
         )
 
     @conf_vars({("scheduler", "partition_mapper_max_downstream_keys"): "1"})
-    @pytest.mark.usefixtures("clear_assets", "testing_dag_bundle")
+    @pytest.mark.usefixtures("testing_dag_bundle")
     def test_partition_fanout_per_mapper_at_cap_is_allowed(self, session, 
dag_maker, mock_task_instance):
         """Per-mapper max_downstream_keys=7 with a 7-key fanout: exactly at 
cap is allowed.
 
@@ -870,7 +902,7 @@ class TestAssetManager:
         )
 
     @conf_vars({("scheduler", "partition_mapper_max_downstream_keys"): "1"})
-    @pytest.mark.usefixtures("clear_assets", "testing_dag_bundle")
+    @pytest.mark.usefixtures("testing_dag_bundle")
     def test_partition_fanout_per_mapper_one_over_cap_trips(self, session, 
dag_maker, mock_task_instance):
         """Per-mapper max_downstream_keys=6 with a 7-key fanout: one over cap 
trips the guard.
 
diff --git a/airflow-core/tests/unit/jobs/test_scheduler_job.py 
b/airflow-core/tests/unit/jobs/test_scheduler_job.py
index 53ea5bc933f..c83d9039d8f 100644
--- a/airflow-core/tests/unit/jobs/test_scheduler_job.py
+++ b/airflow-core/tests/unit/jobs/test_scheduler_job.py
@@ -47,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, _create_asset_event
+from airflow.assets.manager import AssetManager
 from airflow.callbacks.callback_requests import (
     DagCallbackRequest,
     DagRunContext,
@@ -5707,16 +5707,14 @@ class TestSchedulerJob:
             timestamp=base + timedelta(seconds=2),
         )
         session.add_all([event1, event2])
+        session.flush()  # assign event ids so the ADRQ rows can reference them
 
         session = dag_maker.session
         session.add_all(
             [
-                AssetDagRunQueue(
-                    asset_id=asset1_id, target_dag_id=dag2.dag_id, 
created_at=base + timedelta(hours=1)
-                ),
-                AssetDagRunQueue(
-                    asset_id=asset1_id, target_dag_id=dag3.dag_id, 
created_at=base + timedelta(hours=1)
-                ),
+                AssetDagRunQueue(asset_id=asset1_id, 
target_dag_id=dag2.dag_id, asset_event_id=event1.id),
+                AssetDagRunQueue(asset_id=asset1_id, 
target_dag_id=dag3.dag_id, asset_event_id=event1.id),
+                AssetDagRunQueue(asset_id=asset1_id, 
target_dag_id=dag3.dag_id, asset_event_id=event2.id),
             ]
         )
         session.flush()
@@ -5773,28 +5771,21 @@ class TestSchedulerJob:
     def test_new_asset_triggered_dag_backlog_gated_by_catchup(
         self, catchup, expects_old_event, session, dag_maker
     ):
-        """Reproduces #39456: catchup gates whether a new asset-triggered Dag 
replays the
-        pre-creation backlog. With catchup off (the default) it only consumes 
events after it
-        started scheduling on the asset; with catchup on it replays the full 
history."""
+        """catchup gates whether a newly-subscribed asset-triggered Dag 
consumes its backlog.
+
+        With catchup off (the default) the Dag consumes only events with a 
queue row (i.e. those
+        emitted after it began scheduling on the asset). With catchup on, the 
first triggered run
+        also consumes the pre-subscription backlog -- every not-yet-consumed 
event for the Dag's
+        assets -- selected directly by the scheduler (no queue row required, 
no time window).
+        """
         asset = Asset(uri="test://asset-historical", name="hist_asset", 
group="test_group")
 
-        # Producer Dag + run that the asset events are sourced from.
+        # Producer + a historical event that exists BEFORE any consumer 
subscribes.
         with dag_maker(dag_id="historical-producer", 
start_date=timezone.utcnow(), session=session):
             BashOperator(task_id="task", bash_command="echo 1", 
outlets=[asset])
         producer_run = dag_maker.create_dagrun(run_id="producer-run")
-
         asset_id = session.scalar(select(AssetModel.id).where(AssetModel.uri 
== asset.uri))
 
-        # Consumer Dag created now; its schedule reference's created_at is the 
cut-off.
-        with dag_maker(dag_id="historical-consumer", schedule=[asset], 
catchup=catchup):
-            pass
-        consumer_dag = dag_maker.dag
-        reference_created_at = session.scalar(
-            select(DagScheduleAssetReference.created_at).where(
-                DagScheduleAssetReference.dag_id == consumer_dag.dag_id
-            )
-        )
-
         def _make_event(timestamp):
             return AssetEvent(
                 asset_id=asset_id,
@@ -5805,18 +5796,28 @@ class TestSchedulerJob:
                 timestamp=timestamp,
             )
 
-        old_event = _make_event(reference_created_at - timedelta(days=1))
-        new_event = _make_event(reference_created_at + timedelta(seconds=1))
-        session.add_all([old_event, new_event])
-        # Trigger time after both events so neither is excluded by the upper 
bound.
+        old_event = _make_event(timezone.utcnow() - timedelta(days=1))
+        session.add(old_event)
+        session.commit()
+
+        # Consumer subscribes now: catchup=True backfills a queue row for 
old_event (the
+        # pre-subscription backlog); catchup=False backfills nothing.
+        with dag_maker(dag_id="historical-consumer", schedule=[asset], 
catchup=catchup, session=session):
+            pass
+        consumer_dag = dag_maker.dag
+
+        # A post-subscription event always gets its own queue row.
+        new_event = _make_event(timezone.utcnow())
+        session.add(new_event)
+        session.flush()
         session.add(
             AssetDagRunQueue(
                 asset_id=asset_id,
                 target_dag_id=consumer_dag.dag_id,
-                created_at=reference_created_at + timedelta(hours=1),
+                asset_event_id=new_event.id,
             )
         )
-        session.flush()
+        session.commit()
 
         scheduler_job = Job()
         self.job_runner = SchedulerJobRunner(job=scheduler_job, 
executors=[self.null_exec])
@@ -5828,6 +5829,78 @@ 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_asset_events_out_of_order_are_both_consumed(self, session, 
dag_maker):
+        """Regression test for GH-54659.
+
+        Two events for the same asset can become visible out of timestamp 
order (for example a
+        long-running producer commits an "older" event after a "newer" one). 
Under the old
+        created_at/timestamp watermark the older event could be stranded below 
the watermark and
+        never consumed. With consume-by-reference every event referenced by an 
ADRQ row is
+        consumed, regardless of the order in which the timestamps became 
visible.
+        """
+        asset = Asset(uri="test://asset-ooo", name="ooo_asset", 
group="test_group")
+
+        with dag_maker(dag_id="ooo-producer", start_date=timezone.utcnow(), 
session=session):
+            BashOperator(task_id="task", bash_command="echo 1", 
outlets=[asset])
+        producer_run = dag_maker.create_dagrun(run_id="producer-run")
+
+        asset_id = session.scalar(select(AssetModel.id).where(AssetModel.uri 
== asset.uri))
+
+        with dag_maker(dag_id="ooo-consumer", schedule=[asset]):
+            pass
+        consumer_dag = dag_maker.dag
+
+        base = timezone.utcnow()
+
+        def _make_event(timestamp):
+            return AssetEvent(
+                asset_id=asset_id,
+                source_task_id="task",
+                source_dag_id=producer_run.dag_id,
+                source_run_id=producer_run.run_id,
+                source_map_index=-1,
+                timestamp=timestamp,
+            )
+
+        # The "newer" event is registered (and gets its lower id) BEFORE the 
"older" one, so
+        # insertion order and timestamp order disagree.
+        newer_event = _make_event(base + timedelta(seconds=10))
+        session.add(newer_event)
+        session.flush()
+        older_event = _make_event(base + timedelta(seconds=1))
+        session.add(older_event)
+        session.flush()
+
+        session.add_all(
+            [
+                AssetDagRunQueue(
+                    asset_id=asset_id, target_dag_id=consumer_dag.dag_id, 
asset_event_id=newer_event.id
+                ),
+                AssetDagRunQueue(
+                    asset_id=asset_id, target_dag_id=consumer_dag.dag_id, 
asset_event_id=older_event.id
+                ),
+            ]
+        )
+        session.flush()
+
+        scheduler_job = Job()
+        self.job_runner = SchedulerJobRunner(job=scheduler_job, 
executors=[self.null_exec])
+        with create_session() as session:
+            self.job_runner._create_dagruns_for_dags(session, session)
+
+        created_run = session.scalars(select(DagRun).where(DagRun.dag_id == 
consumer_dag.dag_id)).one()
+        assert created_run.state == State.QUEUED
+        # Neither event is stranded: both are consumed by the single run.
+        assert {e.id for e in created_run.consumed_asset_events} == 
{newer_event.id, older_event.id}
+        # All ADRQ rows for the dag are cleared once consumed.
+        assert (
+            session.scalars(
+                select(AssetDagRunQueue).where(AssetDagRunQueue.target_dag_id 
== consumer_dag.dag_id)
+            ).all()
+            == []
+        )
+
     @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")
@@ -5836,8 +5909,12 @@ class TestSchedulerJob:
         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))
+        event = AssetEvent(asset_id=asset_id, timestamp=timezone.utcnow())
+        session.add(event)
+        session.flush()
+        session.add(
+            AssetDagRunQueue(target_dag_id=dag_model.dag_id, 
asset_id=asset_id, asset_event_id=event.id)
+        )
         session.flush()
 
         # Simulate another scheduler consuming ADRQ rows after we computed 
triggered_date_by_dag.
@@ -5865,21 +5942,20 @@ class TestSchedulerJob:
         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))
+        event_1 = AssetEvent(asset_id=asset_1_id, timestamp=timezone.utcnow())
+        event_2 = AssetEvent(asset_id=asset_2_id, timestamp=timezone.utcnow())
+        session.add_all([event_1, event_2])
+        session.flush()
         session.add_all(
             [
-                AssetEvent(
-                    asset_id=asset_1_id,
-                    timestamp=timezone.utcnow(),
-                ),
-                # The ADRQ that should triggers the Dag run creation
+                # The ADRQ that should trigger the Dag run creation
                 AssetDagRunQueue(
-                    asset_id=asset_1_id, target_dag_id=dag_model.dag_id, 
created_at=timezone.utcnow()
+                    asset_id=asset_1_id, target_dag_id=dag_model.dag_id, 
asset_event_id=event_1.id
                 ),
-                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
+                # 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()
+                    asset_id=asset_2_id, target_dag_id=dag_model.dag_id, 
asset_event_id=event_2.id
                 ),
             ]
         )
@@ -5946,23 +6022,23 @@ class TestSchedulerJob:
                 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)
+                # The event is now created inline on the caller's session 
(atomic), rather than in
+                # a side session, so build it directly here.
+                asset_event = AssetEvent(asset_id=asset_id, timestamp=now)
+                session.add(asset_event)
+                session.flush()
+                event_id = asset_event.id
                 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
-                    )
+                # A single dialect-agnostic helper now performs 
insert-or-ignore keyed on
+                # (target_dag_id, asset_event_id).
+                asset_manager._queue_dagruns_nonpartitioned(
+                    asset_id=asset_id,
+                    dags_to_queue={dag},
+                    event=asset_event,
+                    session=session,
+                )
 
-            return asset_event.id, now.isoformat()
+            return event_id, now.isoformat()
 
         with (
             ThreadPoolExecutor(max_workers=3) as executor,
@@ -6045,7 +6121,9 @@ class TestSchedulerJob:
         session = dag_maker.session
         session.add_all(
             [
-                AssetDagRunQueue(asset_id=asset1_id, 
target_dag_id=consumer_dag.dag_id),
+                AssetDagRunQueue(
+                    asset_id=asset1_id, target_dag_id=consumer_dag.dag_id, 
asset_event_id=event.id
+                ),
             ]
         )
         session.flush()
@@ -6119,9 +6197,11 @@ class TestSchedulerJob:
             source_map_index=-1,
         )
         session.add(event)
-        # flush here to ensure event timestamp is before the ADRQ created_at 
timestamp
+        # flush here to assign the event id referenced by the ADRQ row
         session.flush()
-        session.add(AssetDagRunQueue(asset_id=asset_id, 
target_dag_id=f"consumer_{suffix}"))
+        session.add(
+            AssetDagRunQueue(asset_id=asset_id, 
target_dag_id=f"consumer_{suffix}", asset_event_id=event.id)
+        )
         session.flush()
 
         with conf_vars({("core", "multi_team"): multi_team}):
@@ -6187,26 +6267,42 @@ class TestSchedulerJob:
         )
         session.flush()
         assert [e.source_run_id for e in session.scalars(ase_q)] == 
[dr1.run_id, dr2.run_id]
-        assert len(session.scalars(adrq_q).all()) == 1
-        assert session.scalars(adrq_q).one().target_dag_id == "consumer"
+        # ADRQ rows are per asset event now. A stale dag still enqueues while 
disabled (asserted
+        # above), so both events stay queued; a paused dag never enqueued the 
first event, so only
+        # the second remains.
+        expected_adrqs = 2 if "is_stale" in disable else 1
+        adrqs = session.scalars(adrq_q).all()
+        assert len(adrqs) == expected_adrqs
+        assert all(adrq.target_dag_id == "consumer" for adrq in adrqs)
 
     @pytest.mark.need_serialized_dag
-    def test_no_create_dag_runs_when_no_asset_event(self, session: Session, 
dag_maker, caplog):
+    def test_no_create_dag_runs_when_asset_event_already_consumed(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()
+
+        # An event that has already been consumed by an earlier DagRun of this 
dag.
+        event = AssetEvent(asset_id=asset_id, timestamp=timezone.utcnow())
+        session.add(event)
+        session.flush()
+        prior_run = dag_maker.create_dagrun(
+            run_id="prior-consuming-run",
+            logical_date=DEFAULT_DATE,
+            data_interval=(DEFAULT_DATE, DEFAULT_DATE),
         )
-        session.add(adrq)
+        prior_run.consumed_asset_events.append(event)
         session.flush()
-        adrq.created_at = timezone.utcnow() + timedelta(seconds=1)
-        session.merge(adrq)
+
+        # A stale ADRQ that still references the already-consumed event. It 
should be cleaned up
+        # even when no new DagRun is created, to prevent stale ADRQ rows from 
accumulating and
+        # causing infinite scheduler loops.
+        session.add(
+            AssetDagRunQueue(asset_id=asset_id, 
target_dag_id=dag_model.dag_id, asset_event_id=event.id)
+        )
+        session.flush()
+
         with caplog.at_level("INFO"):
             scheduler_job = Job()
             self.job_runner = SchedulerJobRunner(job=scheduler_job, 
executors=[MockExecutor(do_update=False)])
@@ -6214,9 +6310,11 @@ class TestSchedulerJob:
                 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
+        # No *new* DagRun is created; only the pre-existing consuming run 
remains.
+        runs = session.scalars(select(DagRun).where(DagRun.dag_id == 
dag_model.dag_id)).all()
+        assert [r.run_id for r in runs] == ["prior-consuming-run"]
         _adrq = session.scalars(
             select(AssetDagRunQueue).where(
                 AssetDagRunQueue.asset_id == asset_id, 
AssetDagRunQueue.target_dag_id == dag_model.dag_id
diff --git a/airflow-core/tests/unit/models/test_dag.py 
b/airflow-core/tests/unit/models/test_dag.py
index ead765b3171..17fdac7b7ba 100644
--- a/airflow-core/tests/unit/models/test_dag.py
+++ b/airflow-core/tests/unit/models/test_dag.py
@@ -55,7 +55,6 @@ from airflow.models.dag import (
     DagOwnerAttributes,
     DagTag,
     clear_team_name_cache,
-    get_asset_triggered_next_run_info,
     get_next_data_interval,
     get_run_data_interval,
 )
@@ -2585,7 +2584,12 @@ class TestDagModel:
         # add queue records so we'll need a run
         dag_model = session.scalar(select(DagModel).where(DagModel.dag_id == 
dag.dag_id))
         asset_model: AssetModel = dag_model.schedule_assets[0]
-        session.add(AssetDagRunQueue(asset_id=asset_model.id, 
target_dag_id=dag_model.dag_id))
+        event = AssetEvent(asset_id=asset_model.id, 
timestamp=timezone.utcnow())
+        session.add(event)
+        session.flush()
+        session.add(
+            AssetDagRunQueue(asset_id=asset_model.id, 
target_dag_id=dag_model.dag_id, asset_event_id=event.id)
+        )
         session.flush()
         query, _ = DagModel.dags_needing_dagruns(session)
         dag_models = query.all()
@@ -2639,7 +2643,10 @@ class TestDagModel:
         session.add(dag_model)
         session.flush()
 
-        session.add(AssetDagRunQueue(asset_id=asset_id, 
target_dag_id=orphan_dag_id))
+        event = AssetEvent(asset_id=asset_id, timestamp=timezone.utcnow())
+        session.add(event)
+        session.flush()
+        session.add(AssetDagRunQueue(asset_id=asset_id, 
target_dag_id=orphan_dag_id, asset_event_id=event.id))
         session.flush()
 
         with caplog.at_level(logging.DEBUG, logger="airflow.models.dag"):
@@ -2707,10 +2714,14 @@ class TestDagModel:
         )
         session.flush()
 
+        event_z = AssetEvent(asset_id=id_z, timestamp=timezone.utcnow())
+        event_a = AssetEvent(asset_id=id_a, timestamp=timezone.utcnow())
+        session.add_all([event_z, event_a])
+        session.flush()
         session.add_all(
             [
-                AssetDagRunQueue(asset_id=id_z, target_dag_id="ghost_z"),
-                AssetDagRunQueue(asset_id=id_a, target_dag_id="ghost_a"),
+                AssetDagRunQueue(asset_id=id_z, target_dag_id="ghost_z", 
asset_event_id=event_z.id),
+                AssetDagRunQueue(asset_id=id_a, target_dag_id="ghost_a", 
asset_event_id=event_a.id),
             ]
         )
         session.flush()
@@ -2754,7 +2765,14 @@ class TestDagModel:
         asset_models = dag_model.schedule_assets
         assert len(asset_models) == num_assets
         for asset_model in asset_models:
-            session.add(AssetDagRunQueue(asset_id=asset_model.id, 
target_dag_id=dag_model.dag_id))
+            event = AssetEvent(asset_id=asset_model.id, 
timestamp=timezone.utcnow())
+            session.add(event)
+            session.flush()
+            session.add(
+                AssetDagRunQueue(
+                    asset_id=asset_model.id, target_dag_id=dag_model.dag_id, 
asset_event_id=event.id
+                )
+            )
         session.flush()
 
         # Clear identity map so N+1 on adrq.asset is exposed
@@ -2788,7 +2806,12 @@ class TestDagModel:
 
         # add queue records so we'll need a run
         dag_model = dag_maker.dag_model
-        session.add(AssetDagRunQueue(asset_id=asset_model.id, 
target_dag_id=dag_model.dag_id))
+        event = AssetEvent(asset_id=asset_model.id, 
timestamp=timezone.utcnow())
+        session.add(event)
+        session.flush()
+        session.add(
+            AssetDagRunQueue(asset_id=asset_model.id, 
target_dag_id=dag_model.dag_id, asset_event_id=event.id)
+        )
         session.flush()
         query, _ = DagModel.dags_needing_dagruns(session)
         dag_models = query.all()
@@ -3046,12 +3069,24 @@ class TestDagModel:
             pass
 
         session.flush()
+        asset_event_ids = {
+            e.asset_id: e.id
+            for e in session.scalars(
+                select(AssetEvent).where(AssetEvent.asset_id.in_([asset1_id, 
asset2_id]))
+            )
+        }
         session.add_all(
             [
-                AssetDagRunQueue(asset_id=asset1_id, target_dag_id=dag.dag_id, 
created_at=DEFAULT_DATE),
+                AssetDagRunQueue(
+                    asset_id=asset1_id,
+                    target_dag_id=dag.dag_id,
+                    asset_event_id=asset_event_ids[asset1_id],
+                    created_at=DEFAULT_DATE,
+                ),
                 AssetDagRunQueue(
                     asset_id=asset2_id,
                     target_dag_id=dag.dag_id,
+                    asset_event_id=asset_event_ids[asset2_id],
                     created_at=DEFAULT_DATE + timedelta(hours=1),
                 ),
             ]
@@ -3806,71 +3841,6 @@ def test__time_restriction(dag_maker, dag_date, 
tasks_date, catchup, restrict):
     assert dag._time_restriction == restrict
 
 
-def test_get_asset_triggered_next_run_info(dag_maker, clear_assets):
-    asset1 = Asset(uri="test://asset1", name="test_asset1", group="test-group")
-    asset2 = Asset(uri="test://asset2", group="test-group")
-    asset3 = Asset(uri="test://asset3", group="test-group")
-    with dag_maker(dag_id="assets-1", schedule=[asset2]):
-        pass
-    dag1 = dag_maker.dag
-
-    with dag_maker(dag_id="assets-2", schedule=[asset1, asset2]):
-        pass
-    dag2 = dag_maker.dag
-
-    with dag_maker(dag_id="assets-3", schedule=[asset1, asset2, asset3]):
-        pass
-    dag3 = dag_maker.dag
-
-    session = dag_maker.session
-    asset1_id = session.scalar(select(AssetModel.id).where(AssetModel.uri == 
asset1.uri))
-    session.bulk_save_objects(
-        [
-            AssetDagRunQueue(asset_id=asset1_id, target_dag_id=dag2.dag_id),
-            AssetDagRunQueue(asset_id=asset1_id, target_dag_id=dag3.dag_id),
-        ]
-    )
-    session.flush()
-
-    assets = 
session.execute(select(AssetModel.uri).order_by(AssetModel.id)).all()
-
-    info = get_asset_triggered_next_run_info([dag1.dag_id], session=session)
-    assert info[dag1.dag_id] == {
-        "ready": 0,
-        "total": 1,
-        "uri": assets[0].uri,
-    }
-
-    # This time, check both dag2 and dag3 at the same time (tests filtering)
-    info = get_asset_triggered_next_run_info([dag2.dag_id, dag3.dag_id], 
session=session)
-    assert info[dag2.dag_id] == {
-        "ready": 1,
-        "total": 2,
-        "uri": "",
-    }
-    assert info[dag3.dag_id] == {
-        "ready": 1,
-        "total": 3,
-        "uri": "",
-    }
-
-
[email protected]_serialized_dag
-def 
test_get_asset_triggered_next_run_info_with_unresolved_asset_alias(dag_maker, 
clear_assets):
-    asset_alias1 = AssetAlias(name="alias")
-    with dag_maker(dag_id="dag-1", schedule=[asset_alias1]):
-        pass
-    dag1 = dag_maker.dag
-    session = dag_maker.session
-    session.flush()
-
-    info = get_asset_triggered_next_run_info([dag1.dag_id], session=session)
-    assert info == {}
-
-    dag1_model = DagModel.get_dagmodel(dag1.dag_id)
-    assert dag1_model.get_asset_triggered_next_run_info(session=session) is 
None
-
-
 @pytest.mark.parametrize(
     "run_id_type",
     [DagRunType.BACKFILL_JOB, DagRunType.SCHEDULED, 
DagRunType.ASSET_TRIGGERED],
diff --git a/airflow-core/tests/unit/models/test_taskinstance.py 
b/airflow-core/tests/unit/models/test_taskinstance.py
index 53fa99aa224..6a348a3f2ca 100644
--- a/airflow-core/tests/unit/models/test_taskinstance.py
+++ b/airflow-core/tests/unit/models/test_taskinstance.py
@@ -3877,14 +3877,15 @@ def 
test_runtime_partition_key_does_not_backfill_dag_run_when_none(dag_maker, se
 
 @pytest.mark.backend("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.
+    """Regression test for the SQLite ``database is locked`` deadlock.
+
+    This happens when a second connection is used to trigger while
+    ``register_asset_changes_in_db`` was writing.
+
+    The asset event is now created inline on the caller's session (see
+    ``AssetManager.register_asset_change``) instead of opening a side session, 
so
+    on file-based SQLite (the default ``-b sqlite`` test backend) there is no
+    longer a second connection competing for the same RESERVED lock.
     """
     asset = Asset(name="hello")
     with dag_maker(dag_id="rt_pk_backfill_sqlite", 
schedule=PartitionedAtRuntime()) as dag:
diff --git a/airflow-core/tests/unit/timetables/test_assets_timetable.py 
b/airflow-core/tests/unit/timetables/test_assets_timetable.py
index 3c12c886f28..ccdc395ae41 100644
--- a/airflow-core/tests/unit/timetables/test_assets_timetable.py
+++ b/airflow-core/tests/unit/timetables/test_assets_timetable.py
@@ -295,7 +295,10 @@ class TestAssetConditionWithTimetable:
 
         # Add AssetDagRunQueue entries to simulate asset event processing
         for am in asset_models:
-            session.add(AssetDagRunQueue(asset_id=am.id, 
target_dag_id=dag.dag_id))
+            event = AssetEvent(asset_id=am.id)
+            session.add(event)
+            session.flush()
+            session.add(AssetDagRunQueue(asset_id=am.id, 
target_dag_id=dag.dag_id, asset_event_id=event.id))
         session.commit()
 
         # Fetch and evaluate asset triggers for all DAGs affected by asset 
events

Reply via email to