Skip to content

Commit 7eb0c44

Browse files
Stop leftover asset events hiding behind a stale max_active_runs cache
The cached exceeds_max_non_backfill flag was applied to every Dag, including asset-triggered ones, and asset create never refreshed it. Leftovers then waited for an unrelated later event. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent d4193a2 commit 7eb0c44

5 files changed

Lines changed: 64 additions & 6 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Asset-triggered Dags no longer skip leftover queued events when a stale ``max_active_runs`` cache still says the Dag is at capacity.

airflow-core/src/airflow/jobs/scheduler_job_runner.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2752,6 +2752,7 @@ def _create_dag_runs_asset_triggered(
27522752
)
27532753
stats.incr("asset.triggered_dagruns", tags=prune_dict({"team_name": team_name}))
27542754
dag_run.consumed_asset_events.extend(asset_events)
2755+
self._set_exceeds_max_active_runs(dag_model=dag_model, session=session)
27552756
self.log.info(
27562757
"Created asset-triggered DagRun for '%s': run_id=%s, consumed %d asset events",
27572758
dag.dag_id,

airflow-core/src/airflow/models/dag.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
Integer,
3737
String,
3838
Text,
39+
and_,
3940
case,
4041
func,
4142
inspect as sa_inspect,
@@ -776,9 +777,13 @@ def dag_ready(dag_id: str, cond: SerializedAssetBase, statuses: dict[UKey, bool]
776777
cls.is_paused == expression.false(),
777778
cls.is_stale == expression.false(),
778779
cls.has_import_errors == expression.false(),
779-
cls.exceeds_max_non_backfill == expression.false(),
780+
# The cached flag is timetable-only. Asset leftovers stay selectable
781+
# here; the live QUEUED+RUNNING count above already deferred Dags at cap.
780782
or_(
781-
cls.next_dagrun_create_after <= func.now(),
783+
and_(
784+
cls.exceeds_max_non_backfill == expression.false(),
785+
cls.next_dagrun_create_after <= func.now(),
786+
),
782787
cls.dag_id.in_(asset_triggered_dag_ids),
783788
),
784789
)

airflow-core/tests/unit/jobs/test_scheduler_job.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5940,10 +5940,12 @@ def _make_event(timestamp):
59405940
def test_asset_events_queued_while_at_max_active_runs_are_all_consumed(
59415941
self, catchup, terminal_state, session, dag_maker
59425942
):
5943-
"""Events 2, 3, 4 that land while run 1 holds ``max_active_runs=1`` must all be
5944-
consumed by run 2 after the cap lifts. Catchup only changes whether
5945-
pre-subscription events join the first run; it must not change this
5946-
follow-up consume. A FAILED run 1 lifts the cap the same way SUCCESS does.
5943+
"""Events queued while a Dag is at max_active_runs land on the next run.
5944+
5945+
Events 2, 3, 4 that land while run 1 holds ``max_active_runs=1`` must all be
5946+
consumed by run 2 after the cap lifts. A later event 5 is its own run.
5947+
Catchup only changes whether pre-subscription events join the first run.
5948+
A FAILED run 1 lifts the cap the same way SUCCESS does.
59475949
"""
59485950
asset = Asset(uri="test://asset-max-active-runs-queue", name="mar_queue_asset", group="test_group")
59495951
with dag_maker(
@@ -6024,6 +6026,21 @@ def _adrq_event_ids():
60246026
assert run2.run_after == timezone.coerce_datetime(event4.timestamp)
60256027
assert _adrq_event_ids() == set()
60266028

6029+
event5 = _queue_event(base + timedelta(minutes=10))
6030+
_tick()
6031+
runs = _runs()
6032+
assert len(runs) == 2
6033+
assert _adrq_event_ids() == {event5.id}
6034+
6035+
run2 = session.merge(run2)
6036+
run2.state = terminal_state
6037+
session.flush()
6038+
_tick()
6039+
runs = _runs()
6040+
assert len(runs) == 3
6041+
assert {e.id for e in runs[2].consumed_asset_events} == {event5.id}
6042+
assert _adrq_event_ids() == set()
6043+
60276044
@pytest.mark.need_serialized_dag
60286045
def test_asset_events_wait_when_max_active_runs_is_two(self, session, dag_maker):
60296046
"""A third queued event is not consumed until one of two active runs finishes."""

airflow-core/tests/unit/models/test_dag.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2612,6 +2612,40 @@ def test_dags_needing_dagruns_assets(self, dag_maker, session):
26122612
dag_models = query.all()
26132613
assert dag_models == [dag_model]
26142614

2615+
def test_dags_needing_dagruns_assets_stale_flag_does_not_hide_adrq(self, dag_maker, session):
2616+
"""A stale exceeds_max_non_backfill flag must not hide asset leftovers.
2617+
2618+
The cached flag is timetable-only. With zero active runs and queued
2619+
events, the Dag stays selectable even when the flag is still True.
2620+
"""
2621+
asset = Asset(uri="test://asset-stale-flag", group="test-group")
2622+
with dag_maker(
2623+
session=session,
2624+
dag_id="stale_flag_asset_consumer",
2625+
max_active_runs=1,
2626+
schedule=[asset],
2627+
start_date=pendulum.now().add(days=-2),
2628+
) as dag:
2629+
EmptyOperator(task_id="dummy")
2630+
2631+
dag_model = session.scalar(select(DagModel).where(DagModel.dag_id == dag.dag_id))
2632+
asset_model: AssetModel = dag_model.schedule_assets[0]
2633+
for _ in range(3):
2634+
event = AssetEvent(asset_id=asset_model.id, timestamp=timezone.utcnow())
2635+
session.add(event)
2636+
session.flush()
2637+
session.add(
2638+
AssetDagRunQueue(
2639+
asset_id=asset_model.id, target_dag_id=dag_model.dag_id, asset_event_id=event.id
2640+
)
2641+
)
2642+
dag_model.exceeds_max_non_backfill = True
2643+
session.flush()
2644+
2645+
query, triggered_date_by_dag = DagModel.dags_needing_dagruns(session)
2646+
assert query.all() == [dag_model]
2647+
assert dag_model.dag_id in triggered_date_by_dag
2648+
26152649
@pytest.mark.parametrize("active_state", [DagRunState.QUEUED, DagRunState.RUNNING])
26162650
@pytest.mark.parametrize(
26172651
"run_type",

0 commit comments

Comments
 (0)