This is an automated email from the ASF dual-hosted git repository.
vatsrahul1001 pushed a commit to branch v3-4-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
commit 736fe690a95d81ac1701c7691c92c7ffb9a31390
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Thu Oct 29 10:00:53 2026 +0000
[v3-4-test] Avoid unbounded IN lists for queued asset events (#75943)
(#75948)
Co-authored-by: Tzu-ping Chung <[email protected]>
---
.../src/airflow/jobs/scheduler_job_runner.py | 158 +++++++-----
airflow-core/src/airflow/timetables/base.py | 4 +-
airflow-core/tests/unit/jobs/test_scheduler_job.py | 270 +++++++++++++++++----
3 files changed, 324 insertions(+), 108 deletions(-)
diff --git a/airflow-core/src/airflow/jobs/scheduler_job_runner.py
b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
index 7b8b2d53906..3817bcd450e 100644
--- a/airflow-core/src/airflow/jobs/scheduler_job_runner.py
+++ b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
@@ -146,7 +146,7 @@ if TYPE_CHECKING:
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.elements import ColumnElement, SQLColumnExpression
from sqlalchemy.sql.selectable import Select, Subquery
from airflow._shared.logging.types import Logger
@@ -313,6 +313,16 @@ def _get_current_dr_task_concurrency(states:
Iterable[TaskInstanceState]) -> Sub
)
+def _consumed_by_dag(dag_id: str, event_id: SQLColumnExpression[int]) ->
ColumnElement[bool]:
+ """Check whether the asset event *event_id* has been consumed by a run of
the Dag."""
+ return (
+ select(association_table.c.event_id)
+ .join(DagRun, DagRun.id == association_table.c.dag_run_id)
+ .where(DagRun.dag_id == dag_id, association_table.c.event_id ==
event_id)
+ .exists()
+ )
+
+
class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
"""
SchedulerJobRunner runs for a specific time interval and schedules jobs
that are ready to run.
@@ -2777,18 +2787,17 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
)
continue
- consumed_asset_records: Sequence[AssetDagRunQueue] = ()
- gated_asset_events: list[AssetEvent] = []
if dag_model.timetable_asset_gated:
# The asset condition was evaluated without locks when this
Dag was
# selected; re-evaluate under ADRQ row locks so concurrent
schedulers
# cannot consume the same events twice. If the condition is
not (or no
# longer) satisfied, skip without touching the pending
schedule slot so
# a later loop retries it.
- gate = self._collect_gated_asset_events(dag=serdag,
session=session)
- if gate is None:
+ if (gate := self._collect_gated_asset_events(dag=serdag,
session=session)) is None:
continue
- consumed_asset_records, gated_asset_events = gate
+ gated_max_event_id, gated_asset_events = gate
+ else:
+ gated_max_event_id, gated_asset_events = None, ()
try:
next_info =
serdag.timetable.next_run_info_from_dag_model(dag_model=dag_model)
@@ -2823,10 +2832,13 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
session=session,
active_non_backfill_runs=active_runs_of_dags[dag_model.dag_id],
)
- if consumed_asset_records:
+ if gated_max_event_id is not None:
created_run.consumed_asset_events.extend(gated_asset_events)
+ session.flush() # Make consumed events visible to ADRQ
cleanup.
self._delete_consumed_asset_records(
- records=consumed_asset_records,
dag_id=dag_model.dag_id, session=session
+ dag_id=dag_model.dag_id,
+ max_event_id=gated_max_event_id,
+ session=session,
)
# Exceptions like ValueError, ParamValidationError, etc. are
raised by
@@ -2845,12 +2857,13 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
def _collect_gated_asset_events(
self, *, dag: SerializedDAG, session: Session
- ) -> tuple[Sequence[AssetDagRunQueue], list[AssetEvent]] | None:
+ ) -> tuple[int, Sequence[AssetEvent]] | None:
"""
Check an asset-gated Dag's asset condition and collect what a new run
consumes.
- Returns ``None`` when the condition is not satisfied by the queued
asset
- events, in which case no run should be created yet.
+ Returns the largest event ID among the locked ADRQ rows, and the events
+ the new run consumes. Returns ``None`` when the condition is not
satisfied
+ by the queued asset events, in which case no run should be created yet.
"""
records = self._lock_queued_asset_records(dag_id=dag.dag_id,
load_assets=True, session=session)
if not records:
@@ -2863,15 +2876,15 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
return None
if not ready:
return None
- asset_events = self._select_consumed_asset_events(
- dag=dag,
- records=records,
- session=session,
- )
+ max_event_id = max(record.asset_event_id for record in records)
+ asset_events = self._select_consumed_asset_events(dag=dag,
max_event_id=max_event_id, session=session)
if not asset_events:
- self._delete_consumed_asset_records(records=records,
dag_id=dag.dag_id, session=session)
+ # The select returns every queued event this Dag has not consumed
+ # yet; getting nothing back means all locked rows point to events
+ # that were already consumed. Delete those stale rows.
+ self._delete_consumed_asset_records(dag_id=dag.dag_id,
max_event_id=max_event_id, session=session)
return None
- return records, asset_events
+ return max_event_id, asset_events
def _lock_queued_asset_records(
self, *, dag_id: str, load_assets: bool, session: Session
@@ -2894,13 +2907,27 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
self,
*,
dag: SerializedDAG,
- records: Sequence[AssetDagRunQueue],
+ max_event_id: int,
limit: int | None = None,
session: Session,
- ) -> list[AssetEvent]:
- """Select unconsumed events referenced by a Dag's locked ADRQ rows."""
- referenced_event_ids = {record.asset_event_id for record in records}
- event_predicate: ColumnElement[bool] =
AssetEvent.id.in_(referenced_event_ids)
+ ) -> Sequence[AssetEvent]:
+ """
+ Select unconsumed events referenced by a Dag's locked ADRQ rows.
+
+ *max_event_id* is the largest event ID among the rows locked by this
+ scheduler, so events queued after the lock was taken are left for the
+ next scheduling loop. Concurrent schedulers can't consume this Dag's
rows
+ since the Dag's own row is locked in ``DagModel.dags_needing_dagruns``.
+ """
+ event_predicate: ColumnElement[bool] = (
+ select(AssetDagRunQueue.asset_event_id)
+ .where(
+ AssetDagRunQueue.target_dag_id == dag.dag_id,
+ AssetDagRunQueue.asset_event_id == AssetEvent.id,
+ AssetDagRunQueue.asset_event_id <= max_event_id,
+ )
+ .exists()
+ )
if dag.catchup:
event_predicate = or_(
event_predicate,
@@ -2913,42 +2940,45 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
AssetAliasModel.scheduled_dags.any(DagScheduleAssetAliasReference.dag_id ==
dag.dag_id)
),
)
- return list(
- session.scalars(
- select(AssetEvent)
- .where(
- event_predicate,
- ~(
- 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())
- .limit(limit)
+ return session.scalars(
+ select(AssetEvent)
+ .where(
+ event_predicate,
+ ~_consumed_by_dag(dag.dag_id, AssetEvent.id),
)
- )
+ .order_by(AssetEvent.timestamp.asc(), AssetEvent.id.asc())
+ .limit(limit)
+ ).all()
def _delete_consumed_asset_records(
- self, *, records: Sequence[AssetDagRunQueue], dag_id: str, session:
Session
+ self,
+ *,
+ dag_id: str,
+ max_event_id: int,
+ keep_event_id: int | None = None,
+ session: Session,
) -> None:
- # 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).
- result = cast(
- "CursorResult",
- session.execute(
- delete(AssetDagRunQueue).where(
- tuple_(
- AssetDagRunQueue.target_dag_id,
- AssetDagRunQueue.asset_event_id,
- ).in_((record.target_dag_id, record.asset_event_id) for
record in records)
- )
- ),
+ """
+ Delete ADRQ rows whose events have been consumed by a run of the Dag.
+
+ Only consumed rows are deleted to avoid dropping newly queued events.
+ Rows are matched against the events consumed by the Dag's runs (i.e.
+ ``DagRun.consumed_asset_events``) with a subquery, so the caller must
+ flush newly consumed events before calling this.
+
+ :param max_event_id: Largest event ID among the ADRQ rows locked by
this
+ scheduler. Rows queued after the lock was taken are not touched.
+ :param keep_event_id: If given, the ADRQ row of this event is kept
even if
+ the event is consumed.
+ """
+ stmt = delete(AssetDagRunQueue).where(
+ AssetDagRunQueue.target_dag_id == dag_id,
+ AssetDagRunQueue.asset_event_id <= max_event_id,
+ _consumed_by_dag(dag_id, AssetDagRunQueue.asset_event_id),
)
+ if keep_event_id is not None:
+ stmt = stmt.where(AssetDagRunQueue.asset_event_id != keep_event_id)
+ result = cast("CursorResult", session.execute(stmt))
self.log.info(
"Deleted %d ADRQ rows for '%s'",
result.rowcount,
@@ -3007,9 +3037,10 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
if run_limit <= 0:
continue
+ max_event_id = max(record.asset_event_id for record in
queued_adrqs)
asset_events = self._select_consumed_asset_events(
dag=dag,
- records=queued_adrqs,
+ max_event_id=max_event_id,
limit=None if dag.timetable.batch_asset_events else run_limit
+ 1,
session=session,
)
@@ -3052,23 +3083,20 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
dag_run.run_id,
len(events),
)
+ session.flush() # Make consumed events visible to ADRQ
cleanup.
else:
self.log.info(
"No DagRun created for '%s' - asset events already
consumed or none found",
dag.dag_id,
)
- records_to_delete = queued_adrqs
- if has_more_events:
- consumed_ids = {event.id for event in asset_events}
- if dag.catchup:
- # Historical events may have no ADRQ. Keep a wakeup row
until that backlog drains.
- consumed_ids.discard(max(record.asset_event_id for record
in queued_adrqs))
- records_to_delete = [
- record for record in queued_adrqs if record.asset_event_id
in consumed_ids
- ]
+ # Dequeue rows for events both just consumed above and any stale
+ # ones. This runs even if no Dag run was created; otherwise those
+ # rows would keep the Dag selected in every scheduling loop.
self._delete_consumed_asset_records(
- records=records_to_delete,
dag_id=dag.dag_id,
+ max_event_id=max_event_id,
+ # Historical events may have no ADRQ. Keep a wakeup row until
that backlog drains.
+ keep_event_id=max_event_id if has_more_events and dag.catchup
else None,
session=session,
)
diff --git a/airflow-core/src/airflow/timetables/base.py
b/airflow-core/src/airflow/timetables/base.py
index a2325395a08..e1e5b8d6a0a 100644
--- a/airflow-core/src/airflow/timetables/base.py
+++ b/airflow-core/src/airflow/timetables/base.py
@@ -233,8 +233,8 @@ class Timetable(Protocol):
"""Whether queued asset events are consumed together in one Dag run."""
def group_asset_events(
- self, events: list[AssetEvent], triggered_date: DateTime
- ) -> Iterable[tuple[DateTime, list[AssetEvent]]]:
+ self, events: Sequence[AssetEvent], triggered_date: DateTime
+ ) -> Iterable[tuple[DateTime, Sequence[AssetEvent]]]:
"""Yield a run time and its consumed events for each asset-triggered
run."""
if not events:
return
diff --git a/airflow-core/tests/unit/jobs/test_scheduler_job.py
b/airflow-core/tests/unit/jobs/test_scheduler_job.py
index 37b440655ee..e808b722674 100644
--- a/airflow-core/tests/unit/jobs/test_scheduler_job.py
+++ b/airflow-core/tests/unit/jobs/test_scheduler_job.py
@@ -39,7 +39,7 @@ import pendulum
import psutil
import pytest
import time_machine
-from sqlalchemy import delete, func, inspect, select, update
+from sqlalchemy import delete, event as sa_event, func, inspect, select, update
from sqlalchemy.dialects import mysql
from sqlalchemy.orm import joinedload
@@ -7074,32 +7074,45 @@ class TestSchedulerJob:
assert session.scalars(select(DagRun).where(DagRun.dag_id ==
dag_model.dag_id)).one_or_none() is None
@pytest.mark.need_serialized_dag
+ @pytest.mark.parametrize(
+ ("locked_index", "expected_consumed_indexes",
"expected_queued_indexes"),
+ [
+ # The skipped row has a higher event ID than any locked row, so it
is
+ # outside the bound and left queued for the next scheduling loop.
+ pytest.param(0, {0}, {1}, id="skipped-row-newer"),
+ # The skipped row has a lower event ID than the locked row, so it
is
+ # within the bound, and its event is consumed and dequeued as well.
+ pytest.param(1, {0, 1}, set(), id="skipped-row-older"),
+ ],
+ )
def test_create_dag_runs_asset_triggered_deletes_only_selected_adrq_rows(
- self, session: Session, dag_maker
+ self,
+ session: Session,
+ dag_maker,
+ locked_index: int,
+ expected_consumed_indexes: set[int],
+ expected_queued_indexes: set[int],
):
- 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):
+ assets = [Asset("asset-delete-selected-1"),
Asset("asset-delete-selected-2")]
+ with dag_maker(
+ dag_id="asset-consumer-delete-selected", schedule=assets[0] |
assets[1], 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))
- 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()
+ asset_ids = [
+ session.scalar(select(AssetModel.id).where(AssetModel.uri ==
asset.name)) for asset in assets
+ ]
+ now = timezone.utcnow()
+ events = []
+ for i, asset_id in enumerate(asset_ids):
+ # Flush one by one so event IDs follow list order.
+ event = AssetEvent(asset_id=asset_id, timestamp=now +
timedelta(seconds=i))
+ session.add(event)
+ session.flush()
+ events.append(event)
session.add_all(
- [
- # The ADRQ that should trigger the Dag run creation
- AssetDagRunQueue(
- asset_id=asset_1_id, target_dag_id=dag_model.dag_id,
asset_event_id=event_1.id
- ),
- # 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,
asset_event_id=event_2.id
- ),
- ]
+ AssetDagRunQueue(asset_id=e.asset_id,
target_dag_id=dag_model.dag_id, asset_event_id=e.id)
+ for e in events
)
session.flush()
@@ -7107,8 +7120,8 @@ class TestSchedulerJob:
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)
+ # Simulate SKIP LOCKED skipping the other row, e.g. held by
another transaction.
+ return query.where(AssetDagRunQueue.asset_id ==
asset_ids[locked_index])
with patch("airflow.jobs.scheduler_job_runner.with_row_locks",
side_effect=_lock_only_selected_asset):
self.job_runner._create_dag_runs_asset_triggered(
@@ -7116,23 +7129,14 @@ class TestSchedulerJob:
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
+ runs = session.scalars(select(DagRun).where(DagRun.dag_id ==
dag_model.dag_id)).all()
+ assert {e.id for run in runs for e in run.consumed_asset_events} == {
+ events[i].id for i in expected_consumed_indexes
+ }
+ queued_asset_ids = session.scalars(
+
select(AssetDagRunQueue.asset_id).where(AssetDagRunQueue.target_dag_id ==
dag_model.dag_id)
+ ).all()
+ assert set(queued_asset_ids) == {asset_ids[i] for i in
expected_queued_indexes}
@pytest.mark.need_serialized_dag
@pytest.mark.backend("postgres", "mysql")
@@ -12806,6 +12810,190 @@ def
test_create_dagruns_asset_and_time_late_arrival_uses_oldest_pending_logical_
)
[email protected]
+def _capture_max_bind_params(session: Session):
+ """Track the largest number of bind parameters sent in a single SQL
statement."""
+ counts: list[int] = [0]
+
+ def _listener(conn, cursor, statement, parameters, context, executemany):
+ if not executemany and parameters:
+ counts.append(len(parameters))
+
+ engine = session.get_bind()
+ sa_event.listen(engine, "before_cursor_execute", _listener)
+ try:
+ yield counts
+ finally:
+ sa_event.remove(engine, "before_cursor_execute", _listener)
+
+
+# Large enough that a literal IN list of queued events dwarfs any fixed-size
query.
+_MANY_QUEUED_EVENTS = 300
+
+
+def _queue_many_asset_events(
+ *,
+ dag_id: str,
+ asset_ids: list[int],
+ timestamp: datetime.datetime,
+ session: Session,
+) -> None:
+ events = [
+ AssetEvent(asset_id=asset_id, timestamp=timestamp)
+ for asset_id in asset_ids
+ for _ in range(_MANY_QUEUED_EVENTS)
+ ]
+ session.add_all(events)
+ session.flush()
+ session.add_all(
+ AssetDagRunQueue(asset_id=e.asset_id, target_dag_id=dag_id,
asset_event_id=e.id) for e in events
+ )
+ session.flush()
+
+
[email protected]_serialized_dag
[email protected]("batch_asset_events", [False, True])
+def test_create_dagruns_asset_triggered_many_queued_events_no_literal_in(
+ session: Session, dag_maker, batch_asset_events: bool
+):
+ """Queued events must not be sent to the database as a literal list
(#75865)."""
+ asset_1 = Asset(uri="test://many-events-1", name="many-events-1")
+ asset_2 = Asset(uri="test://many-events-2", name="many-events-2")
+ max_active_runs = 4 # Below the default per-loop run budget, so it is the
effective limit.
+ with dag_maker(
+ dag_id="many-events-triggered",
+ schedule=AssetTriggeredTimetable(assets=asset_1 & asset_2,
batch_asset_events=batch_asset_events),
+ max_active_runs=max_active_runs,
+ session=session,
+ ):
+ EmptyOperator(task_id="dummy_task")
+ dag_model = dag_maker.dag_model
+ asset_ids = list(
+
session.scalars(select(AssetModel.id).where(AssetModel.uri.in_([asset_1.uri,
asset_2.uri])))
+ )
+ _queue_many_asset_events(
+ dag_id=dag_model.dag_id,
+ asset_ids=asset_ids,
+ timestamp=timezone.utcnow(),
+ session=session,
+ )
+
+ runner = SchedulerJobRunner(job=Job(),
executors=[MockExecutor(do_update=False)])
+ with _capture_max_bind_params(session) as param_counts:
+ runner._create_dag_runs_asset_triggered(dag_models=[dag_model],
session=session)
+ session.flush()
+
+ assert max(param_counts) < _MANY_QUEUED_EVENTS
+ total = _MANY_QUEUED_EVENTS * len(asset_ids)
+ runs = session.scalars(select(DagRun).where(DagRun.dag_id ==
dag_model.dag_id)).all()
+ consumed = {e.id for run in runs for e in run.consumed_asset_events}
+ remaining = session.scalars(
+
select(AssetDagRunQueue.asset_event_id).where(AssetDagRunQueue.target_dag_id ==
dag_model.dag_id)
+ ).all()
+ if batch_asset_events:
+ assert len(runs) == 1
+ assert len(consumed) == total
+ else:
+ assert len(runs) == max_active_runs
+ assert len(consumed) == max_active_runs
+ assert consumed.isdisjoint(remaining)
+ assert len(remaining) == total - len(consumed)
+
+
[email protected]("disable_load_example")
[email protected]_serialized_dag
+def test_create_dagruns_asset_gated_many_queued_events_no_literal_in(session:
Session, dag_maker):
+ """Queued events must not be sent to the database as a literal list
(#75865)."""
+ asset_1 = Asset(uri="test://many-events-gated-1",
name="many-events-gated-1")
+ asset_2 = Asset(uri="test://many-events-gated-2",
name="many-events-gated-2")
+ logical_date = pendulum.datetime(2026, 3, 29, 17, tz="UTC")
+ with dag_maker(
+ dag_id="many-events-gated",
+ schedule=AssetAndTimeSchedule(
+ timetable=CronTriggerTimetable("* * * * *", timezone="UTC"),
+ assets=[asset_1, asset_2],
+ ),
+ session=session,
+ ):
+ EmptyOperator(task_id="dummy_task")
+ dag_model = dag_maker.dag_model
+ dag_model.next_dagrun = logical_date
+ dag_model.next_dagrun_data_interval = (logical_date, logical_date)
+ dag_model.next_dagrun_create_after = logical_date
+ asset_ids = list(
+
session.scalars(select(AssetModel.id).where(AssetModel.uri.in_([asset_1.uri,
asset_2.uri])))
+ )
+ _queue_many_asset_events(
+ dag_id=dag_model.dag_id,
+ asset_ids=asset_ids,
+ timestamp=logical_date,
+ session=session,
+ )
+
+ runner = SchedulerJobRunner(job=Job(), executors=[MockExecutor()])
+ with _capture_max_bind_params(session) as param_counts:
+ runner._create_dagruns_for_dags(cast("CommitProhibitorGuard",
session), session)
+ session.flush()
+
+ assert max(param_counts) < _MANY_QUEUED_EVENTS
+ dag_run = session.scalars(select(DagRun).where(DagRun.dag_id ==
dag_model.dag_id)).one()
+ assert len(dag_run.consumed_asset_events) == _MANY_QUEUED_EVENTS *
len(asset_ids)
+ assert (
+
session.scalar(select(AssetDagRunQueue).where(AssetDagRunQueue.target_dag_id ==
dag_model.dag_id))
+ is None
+ )
+
+
[email protected]("disable_load_example")
[email protected]_serialized_dag
+def test_create_dagruns_asset_gated_dequeues_already_consumed_events(session:
Session, dag_maker):
+ asset = Asset(uri="test://asset-gated-consumed",
name="asset-gated-consumed")
+ logical_date = pendulum.datetime(2026, 3, 29, 17, tz="UTC")
+ with dag_maker(
+ dag_id="asset-gated-consumed",
+ schedule=AssetAndTimeSchedule(
+ timetable=CronTriggerTimetable("* * * * *", timezone="UTC"),
+ assets=[asset],
+ ),
+ session=session,
+ ):
+ EmptyOperator(task_id="dummy_task")
+ dag_model = dag_maker.dag_model
+
+ # An event already consumed by an earlier run of this Dag...
+ asset_id = session.scalar(select(AssetModel.id).where(AssetModel.uri ==
asset.uri))
+ event = AssetEvent(asset_id=asset_id, timestamp=logical_date)
+ session.add(event)
+ session.flush()
+ prior_run = dag_maker.create_dagrun(
+ run_id="prior-consuming-run",
+ logical_date=logical_date - timedelta(hours=1),
+ data_interval=(logical_date - timedelta(hours=1), logical_date -
timedelta(hours=1)),
+ )
+ prior_run.consumed_asset_events.append(event)
+ # ... but still queued. This makes the asset condition look satisfied.
+ session.add(AssetDagRunQueue(asset_id=asset_id,
target_dag_id=dag_model.dag_id, asset_event_id=event.id))
+ dag_model.next_dagrun = logical_date
+ dag_model.next_dagrun_data_interval = (logical_date, logical_date)
+ dag_model.next_dagrun_create_after = logical_date
+ session.flush()
+
+ SchedulerJobRunner(job=Job(),
executors=[MockExecutor()])._create_dagruns_for_dags(
+ cast("CommitProhibitorGuard", session), session
+ )
+ session.flush()
+
+ # No run is created, the schedule slot is kept, and the stale row is
dequeued.
+ runs = session.scalars(select(DagRun).where(DagRun.dag_id ==
dag_model.dag_id)).all()
+ assert [run.run_id for run in runs] == ["prior-consuming-run"]
+ session.refresh(dag_model)
+ assert dag_model.next_dagrun == logical_date
+ assert (
+
session.scalar(select(AssetDagRunQueue).where(AssetDagRunQueue.target_dag_id ==
dag_model.dag_id))
+ is None
+ )
+
+
@time_machine.travel("2026-03-29 19:00:00+00:00")
@pytest.mark.usefixtures("disable_load_example")
@pytest.mark.need_serialized_dag