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

kaxil 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 d5c31e94892 Stop the scheduler creating Dag runs for paused 
partitioned-asset Dags (#73151)
d5c31e94892 is described below

commit d5c31e94892c3acc998a5537d2edcf7288d1cd5b
Author: Kaxil Naik <[email protected]>
AuthorDate: Tue Sep 15 16:02:22 2026 +0100

    Stop the scheduler creating Dag runs for paused partitioned-asset Dags 
(#73151)
    
    The partitioned-asset creation loop was the only scheduler path that did not
    check is_paused. Every other path goes through 
DagModel.dags_needing_dagruns,
    which has filtered it since the method existed.
    
    The sharpest way in is a completed drain. _finalize_draining_dags converges 
a
    draining Dag to paused in one step, clearing is_draining and setting 
is_paused
    together, so every pending AssetPartitionDagRun that the draining filter had
    been holding became eligible the instant the drain finished -- and the loop
    created a run for exactly the Dag the operator had just drained.
    
    A plain pause reaches it through a narrower window, since AssetManager skips
    inactive Dags when recording partition keys and no APDR can advance while 
the
    Dag is paused. What remains is an APDR already satisfiable at the moment of
    pausing: the pause lands between the satisfying asset event and the next
    scheduler tick, and the run is created anyway. That includes the automatic
    pause after max_consecutive_failed_dag_runs, which the user never initiated.
    
    Either way the run cannot start -- get_queued_dag_runs_to_set_running 
filters
    on is_paused -- so it sits in QUEUED, listed in the UI and the API but 
unable
    to progress, until someone reactivates the Dag, at which point it starts
    alongside everything else.
    
    The filter goes in the WHERE clause rather than a post-query skip so a 
paused
    Dag's APDRs do not consume the per-tick FIFO budget ahead of Dags that can
    actually run.
---
 .../src/airflow/jobs/scheduler_job_runner.py       | 17 +++++
 airflow-core/tests/unit/jobs/test_scheduler_job.py | 81 ++++++++++++++++++++--
 2 files changed, 94 insertions(+), 4 deletions(-)

diff --git a/airflow-core/src/airflow/jobs/scheduler_job_runner.py 
b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
index 9be6eaec550..fe1e09a9ee5 100644
--- a/airflow-core/src/airflow/jobs/scheduler_job_runner.py
+++ b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
@@ -2300,6 +2300,22 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
         asset, firing on stale history would conflict with the declared 
topology,
         so the APDR waits. Reactivating the asset resumes evaluation 
automatically.
         This matches the UI's progress view (``_fetch_active_assets_per_dag``).
+
+        Pausing and draining freeze pending APDRs, mirroring the ``is_paused`` 
/
+        ``is_draining`` half of 
:meth:`~airflow.models.dag.DagModel.dags_needing_dagruns`.
+        ``has_import_errors`` needs no predicate of its own: it is only ever 
set together
+        with ``is_stale`` (``_update_import_errors``) and cleared together 
with it
+        (``DagModelOperation.update_dags``), so the ``is_stale`` filter above 
already
+        excludes those Dags. ``exceeds_max_non_backfill`` is the one genuine 
divergence --
+        an APDR for a Dag already at ``max_active_runs`` still creates its 
run, which then
+        waits at the QUEUED->RUNNING gate rather than being held back here.
+
+        Nothing accrues while a Dag is inactive -- 
``AssetManager.register_asset_change``
+        drops paused and draining Dags before any ``PartitionedAssetKeyLog`` 
row is
+        written -- so an event produced during the pause is never recorded and 
a partially
+        satisfied APDR cannot advance past it. On reactivation the APDR 
resumes from the
+        keys logged before it went inactive, unless the rollup definition 
changed in the
+        meantime, in which case the stale-fingerprint cleanup below drops it 
instead.
         """
         # Cap per-tick work so the scheduler transaction stays bounded and 
other
         # scheduling work isn't starved. Remaining APDRs drain across 
subsequent ticks.
@@ -2320,6 +2336,7 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
                 .join(DagModel, DagModel.dag_id == 
AssetPartitionDagRun.target_dag_id)
                 .where(
                     AssetPartitionDagRun.created_dag_run_id.is_(None),
+                    DagModel.is_paused.is_(False),
                     DagModel.is_draining.is_(False),
                     DagModel.is_stale.is_(False),
                 )
diff --git a/airflow-core/tests/unit/jobs/test_scheduler_job.py 
b/airflow-core/tests/unit/jobs/test_scheduler_job.py
index de21286adf7..ca91ef5f76f 100644
--- a/airflow-core/tests/unit/jobs/test_scheduler_job.py
+++ b/airflow-core/tests/unit/jobs/test_scheduler_job.py
@@ -11634,9 +11634,21 @@ def _produce_and_register_asset_event(
 
 @pytest.mark.need_serialized_dag
 @pytest.mark.usefixtures("clear_asset_partition_rows")
-def test_partitioned_asset_dag_run_is_not_created_while_draining(dag_maker: 
DagMaker, session: Session):
[email protected](
+    "scheduling_state",
+    [DagSchedulingState.DRAINING, DagSchedulingState.PAUSED],
+)
+def test_partitioned_asset_dag_run_waits_while_not_active_and_fires_on_resume(
+    dag_maker: DagMaker, session: Session, scheduling_state: DagSchedulingState
+):
+    """
+    A pending APDR is frozen while its Dag is paused or draining, not consumed.
+
+    The run it would have created is deferred rather than dropped, so 
reactivating the
+    Dag fires it on the next tick.
+    """
     asset = Asset(name="asset")
-    consumer_dag_id = "draining-asset-event-consumer"
+    consumer_dag_id = "inactive-asset-event-consumer"
     with dag_maker(
         dag_id=consumer_dag_id,
         schedule=PartitionedAssetTimetable(assets=asset),
@@ -11645,8 +11657,12 @@ def 
test_partitioned_asset_dag_run_is_not_created_while_draining(dag_maker: DagM
         EmptyOperator(task_id="consumer")
     session.commit()
 
+    # Ordering is load-bearing: the asset event must be produced while the Dag 
is still
+    # active, because AssetManager.register_asset_change skips inactive Dags 
entirely.
+    # Pausing first would leave no APDR at all and the assertions below would 
pass
+    # vacuously.
     apdr = _produce_and_register_asset_event(
-        dag_id="draining-asset-event-producer",
+        dag_id="inactive-asset-event-producer",
         asset=asset,
         partition_key="partition",
         session=session,
@@ -11654,7 +11670,7 @@ def 
test_partitioned_asset_dag_run_is_not_created_while_draining(dag_maker: DagM
     )
     dag_model = session.get(DagModel, consumer_dag_id)
     assert dag_model is not None
-    dag_model.set_scheduling_state(DagSchedulingState.DRAINING)
+    dag_model.set_scheduling_state(scheduling_state)
     session.commit()
 
     runner = SchedulerJobRunner(
@@ -11666,6 +11682,63 @@ def 
test_partitioned_asset_dag_run_is_not_created_while_draining(dag_maker: DagM
     assert partition_dags == set()
     assert apdr.created_dag_run_id is None
 
+    # The APDR is deferred, not dropped: a rollup that was already satisfiable 
before the
+    # pause fires as soon as the Dag is active again.
+    dag_model.set_scheduling_state(DagSchedulingState.ACTIVE)
+    session.commit()
+
+    assert runner._create_dagruns_for_partitioned_asset_dags(session=session) 
== {consumer_dag_id}
+    session.refresh(apdr)
+    assert apdr.created_dag_run_id is not None
+
+
[email protected]_serialized_dag
[email protected]("clear_asset_partition_rows")
+def test_partitioned_asset_dag_run_is_not_created_after_a_drain_completes(
+    dag_maker: DagMaker, session: Session
+):
+    """
+    Completing a drain must not hand the drained Dag a fresh partition-driven 
run.
+
+    ``_finalize_draining_dags`` converges draining to paused in one step, so a 
pending
+    APDR that the draining filter had been holding would otherwise become 
eligible the
+    instant the drain finished -- against the very Dag the operator just 
drained.
+    """
+    asset = Asset(name="asset")
+    consumer_dag_id = "drained-asset-event-consumer"
+    with dag_maker(
+        dag_id=consumer_dag_id,
+        schedule=PartitionedAssetTimetable(assets=asset),
+        session=session,
+    ):
+        EmptyOperator(task_id="consumer")
+    session.commit()
+
+    apdr = _produce_and_register_asset_event(
+        dag_id="drained-asset-event-producer",
+        asset=asset,
+        partition_key="partition",
+        session=session,
+        dag_maker=dag_maker,
+    )
+    dag_model = session.get(DagModel, consumer_dag_id)
+    assert dag_model is not None
+    dag_model.set_scheduling_state(DagSchedulingState.DRAINING)
+    session.commit()
+
+    runner = SchedulerJobRunner(
+        job=Job(job_type=SchedulerJobRunner.job_type), 
executors=[MockExecutor(do_update=False)]
+    )
+    assert runner._create_dagruns_for_partitioned_asset_dags(session=session) 
== set()
+
+    # The Dag has no unfinished runs, so the drain converges to paused on this 
tick.
+    runner._finalize_draining_dags(session=session)
+    assert dag_model.scheduling_state == DagSchedulingState.PAUSED
+    session.flush()
+
+    assert runner._create_dagruns_for_partitioned_asset_dags(session=session) 
== set()
+    assert apdr.created_dag_run_id is None
+
 
 @pytest.mark.need_serialized_dag
 @pytest.mark.usefixtures("clear_asset_partition_rows")

Reply via email to