This is an automated email from the ASF dual-hosted git repository.
hussein-awala 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 0fb5bfc4df1 Fill scheduler task batches after concurrency rejection
(#70307)
0fb5bfc4df1 is described below
commit 0fb5bfc4df1da8e4f2ae1e436321a8d228630ed3
Author: Arthur <[email protected]>
AuthorDate: Thu Oct 22 11:12:12 2026 +0200
Fill scheduler task batches after concurrency rejection (#70307)
* Fill scheduler candidate batches after concurrency rejection
A candidate query could stop after selecting any executable task, leaving
remaining capacity unused when higher-priority candidates were rejected by
in-memory concurrency checks. This could repeatedly starve lower-priority tasks.
* Clarify scheduler candidate retry logging
* Avoid one-row scheduler refill queries
When a candidate batch is nearly full, reducing the query limit to the
remaining capacity can require one database round trip for every distinct
blocked task.
* Bound scheduler refill queries inside the critical section
* Exclude only the saturated Dag run from scheduler refill queries
* Test that refill cap ignores queries before the first selection
---
.../src/airflow/jobs/scheduler_job_runner.py | 90 +++++++----
airflow-core/tests/unit/jobs/test_scheduler_job.py | 170 +++++++++++++++++++++
.../observability/metrics/metrics_template.yaml | 8 +
3 files changed, 240 insertions(+), 28 deletions(-)
diff --git a/airflow-core/src/airflow/jobs/scheduler_job_runner.py
b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
index 7449c09e348..5a56f7bfc33 100644
--- a/airflow-core/src/airflow/jobs/scheduler_job_runner.py
+++ b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
@@ -174,6 +174,17 @@ TASK_STUCK_IN_QUEUED_RESCHEDULE_EVENT = "stuck in queued
reschedule"
# safety bound, not a behavioural knob operators need to tune.
MAX_PARTITION_DAG_RUNS_PER_LOOP = 500
+MAX_TI_REFILL_QUERIES_PER_LOOP = 3
+"""
+Max extra task instance queries per critical section to fill a partially
selected batch.
+
+Each query runs while holding the pool row locks (and the advisory lock on
PostgreSQL), which other
+HA schedulers wait on. Queries issued before any task instance is selected are
not capped, so a loop
+still selects at least one runnable task instance queued behind blocked ones.
+
+:meta private:
+"""
+
def _eager_load_dag_run_for_validation() -> tuple[LoaderOption, LoaderOption]:
"""
@@ -734,28 +745,38 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
concurrency_map = ConcurrencyMap()
concurrency_map.load(session=session)
+ executor_slots_available: dict[ExecutorName, int] = {}
+ for executor in self.executors:
+ if TYPE_CHECKING:
+ # All executors should have a name if they are initted from
the executor_loader.
+ # But we need to check for None to make mypy happy.
+ assert executor.name
+ executor_slots_available[executor.name] = executor.slots_available
+
# Number of tasks that cannot be scheduled because of no open slot in
pool
num_starving_tasks_total = 0
- # dag and task ids that can't be queued because of concurrency limits
- starved_dags: set[str] = set()
+ # Dag runs and tasks that can't be queued because of concurrency limits
+ starved_dag_runs: set[tuple[str, str]] = set()
starved_tasks: set[tuple[str, str]] = set()
starved_tasks_task_dagrun_concurrency: set[tuple[str, str, str]] =
set()
pool_num_starving_tasks: dict[str, int] = Counter()
+ num_refill_queries = 0
for loop_count in itertools.count(start=1):
num_starved_pools = len(starved_pools)
- num_starved_dags = len(starved_dags)
+ num_starved_dag_runs = len(starved_dag_runs)
num_starved_tasks = len(starved_tasks)
num_starved_tasks_task_dagrun_concurrency =
len(starved_tasks_task_dagrun_concurrency)
query = self._build_schedulable_tis_query(
starved_pools,
- starved_dags,
+ starved_dag_runs,
starved_tasks,
starved_tasks_task_dagrun_concurrency,
max_tis,
+ excluded_ti_ids=[ti.id for ti in executable_tis],
)
timer = stats.timer("scheduler.critical_section_query_duration")
@@ -805,15 +826,6 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
if team := dag_id_to_team_name.get(ti.dag_id):
ti.dag_run._team_name = team
- executor_slots_available: dict[ExecutorName, int] = {}
- # First get a mapping of executor names to slots they have
available
- for executor in self.executors:
- if TYPE_CHECKING:
- # All executors should have a name if they are initted
from the executor_loader.
- # But we need to check for None to make mypy happy.
- assert executor.name
- executor_slots_available[executor.name] =
executor.slots_available
-
for task_instance in task_instances_to_examine:
pool_name = task_instance.pool
@@ -890,20 +902,20 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
current_active_tasks_per_dag_run =
concurrency_map.dag_run_active_tasks_map[dag_run_key]
dag_max_active_tasks = task_instance.dag_model.max_active_tasks
self.log.info(
- "DAG %s has %s/%s running and queued tasks",
+ "Dag run %s of Dag %s has %s/%s running and queued tasks",
+ task_instance.run_id,
dag_id,
current_active_tasks_per_dag_run,
dag_max_active_tasks,
)
if current_active_tasks_per_dag_run >= dag_max_active_tasks:
self.log.info(
- "Not executing %s since the number of tasks running or
queued "
- "from DAG %s is >= to the DAG's max_active_tasks limit
of %s",
+ "Not executing %s since its Dag run has reached "
+ "the Dag's max_active_tasks limit of %s",
task_instance,
- dag_id,
dag_max_active_tasks,
)
- starved_dags.add(dag_id)
+ starved_dag_runs.add(dag_run_key)
continue
# Many DAGs do not define task concurrency limits, so avoid
@@ -956,21 +968,37 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
pool_stats["open"] = open_slots
- is_done = executable_tis or len(task_instances_to_examine) <
max_tis
+ if len(executable_tis) >= max_tis:
+ break
+
# Check this to avoid accidental infinite loops
found_new_filters = (
len(starved_pools) > num_starved_pools
- or len(starved_dags) > num_starved_dags
+ or len(starved_dag_runs) > num_starved_dag_runs
or len(starved_tasks) > num_starved_tasks
or len(starved_tasks_task_dagrun_concurrency) >
num_starved_tasks_task_dagrun_concurrency
)
- if is_done or not found_new_filters:
+ if len(executable_tis) >= max_tis or not found_new_filters:
break
+ if executable_tis:
+ if num_refill_queries >= MAX_TI_REFILL_QUERIES_PER_LOOP:
+ self.log.debug(
+ "Selected %s of %s task instances; reached the limit
of %s refill queries.",
+ len(executable_tis),
+ max_tis,
+ MAX_TI_REFILL_QUERIES_PER_LOOP,
+ )
+
stats.incr("scheduler.critical_section_refill_limit_reached")
+ break
+ num_refill_queries += 1
+
self.log.info(
- "Found no task instances to queue on query iteration %s "
- "but there could be more candidate task instances to check.",
+ "Selected %s of %s task instances after iteration %s; "
+ "retrying after excluding known blocked candidates.",
+ len(executable_tis),
+ max_tis,
loop_count,
)
@@ -993,15 +1021,17 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
def _build_schedulable_tis_query(
self,
starved_pools: set[str],
- starved_dags: set[str],
+ starved_dag_runs: set[tuple[str, str]],
starved_tasks: set[tuple[str, str]],
starved_tasks_task_dagrun_concurrency: set[tuple[str, str, str]],
max_tis: int,
+ *,
+ excluded_ti_ids: Collection[UUID] = (),
) -> Select[tuple[TI]]:
"""
Build a query that fetches SCHEDULED TIs eligible for execution this
cycle.
- Applies current starvation exclusions so that saturated pools, DAGs,
or tasks
+ Applies current starvation exclusions so that saturated pools, Dag
runs, or tasks
don't re-appear in the candidate set. Row-number windowing enforces
``max_active_tasks`` per DagRun. The returned query is ready to be
wrapped
with ``with_row_locks`` and executed by the caller; no session is
required here.
@@ -1035,13 +1065,13 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
)
# Starvation filters should be applied before computing the row_num
based on the
- # max_active_tasks limit. That way, starved dags and tasks that
shouldn't run,
+ # max_active_tasks limit. That way, starved Dag runs and tasks that
shouldn't run,
# won't occupy a slot.
if starved_pools:
query = query.where(TI.pool.not_in(starved_pools))
- if starved_dags:
- query = query.where(TI.dag_id.not_in(starved_dags))
+ if starved_dag_runs:
+ query = query.where(tuple_(TI.dag_id,
TI.run_id).not_in(starved_dag_runs))
if starved_tasks:
query = query.where(tuple_(TI.dag_id,
TI.task_id).not_in(starved_tasks))
@@ -1051,6 +1081,10 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
tuple_(TI.dag_id, TI.run_id,
TI.task_id).not_in(starved_tasks_task_dagrun_concurrency)
)
+ if excluded_ti_ids:
+ # Selected TIs stay SCHEDULED in the database until the selection
loop marks them QUEUED.
+ query = query.where(TI.id.not_in(excluded_ti_ids))
+
# Create a subquery with row numbers partitioned by dag_id and run_id.
# Different dags can have the same run_id but
# the dag_id combined with the run_id uniquely identify a run.
diff --git a/airflow-core/tests/unit/jobs/test_scheduler_job.py
b/airflow-core/tests/unit/jobs/test_scheduler_job.py
index a90b8bd00fa..84bb06cc768 100644
--- a/airflow-core/tests/unit/jobs/test_scheduler_job.py
+++ b/airflow-core/tests/unit/jobs/test_scheduler_job.py
@@ -69,6 +69,7 @@ from airflow.executors.workloads import WorkloadType
from airflow.executors.workloads.types import TaskInstanceUuid
from airflow.jobs.job import Job, run_job
from airflow.jobs.scheduler_job_runner import (
+ MAX_TI_REFILL_QUERIES_PER_LOOP,
TASK_STUCK_IN_QUEUED_RESCHEDULE_EVENT,
SchedulerJobRunner,
)
@@ -3537,6 +3538,175 @@ class TestSchedulerJob:
session.rollback()
+ @pytest.fixture
+ def interleaved_blocked_and_runnable_tis(self, dag_maker, session):
+ """
+ Create SCHEDULED TIs ordered so each 3-row candidate page holds one
runnable TI until the last.
+
+ Candidate order: blocked_0, runnable_0, blocked_1 | blocked_2,
runnable_1, blocked_3 |
+ blocked_4, runnable_2, runnable_3. Blocked TIs are at their
``max_active_tis_per_dag`` limit.
+ """
+ with dag_maker(dag_id="interleaved_blocked_and_runnable",
session=session):
+ blocked_tasks = [
+ EmptyOperator(task_id=f"blocked_{index}",
priority_weight=weight, max_active_tis_per_dag=1)
+ for index, weight in enumerate((9, 7, 6, 4, 3))
+ ]
+ runnable_tasks = [
+ EmptyOperator(task_id=f"runnable_{index}",
priority_weight=weight)
+ for index, weight in enumerate((8, 5, 2, 1))
+ ]
+ first_run = dag_maker.create_dagrun(run_type=DagRunType.SCHEDULED)
+ second_run = dag_maker.create_dagrun_after(first_run,
run_type=DagRunType.SCHEDULED)
+ for blocked_task in blocked_tasks:
+ first_run.get_task_instance(blocked_task.task_id,
session=session).state = State.RUNNING
+ second_run.get_task_instance(blocked_task.task_id,
session=session).state = State.SCHEDULED
+ for runnable_task in runnable_tasks:
+ first_run.get_task_instance(runnable_task.task_id,
session=session).state = State.SCHEDULED
+ session.flush()
+
+ @pytest.mark.usefixtures("interleaved_blocked_and_runnable_tis")
+ @pytest.mark.parametrize(
+ ("executor_slots", "expected_task_ids"),
+ [
+ pytest.param(8, ["runnable_0", "runnable_1", "runnable_2"],
id="fills_batch_up_to_max_tis"),
+ pytest.param(2, ["runnable_0", "runnable_1"],
id="executor_slots_shared_across_queries"),
+ ],
+ )
+ def
test_select_task_instances_to_queue_refills_batch_after_concurrency_rejection(
+ self, mock_executors, session, executor_slots, expected_task_ids
+ ):
+ self.job_runner = SchedulerJobRunner(job=Job())
+ self.job_runner.executor.slots_available = executor_slots
+
+ queued_tis = self.job_runner._select_task_instances_to_queue(
+ 3, make_pool_stats(), set(), session=session
+ )
+
+ assert [ti.task_id for ti in queued_tis] == expected_task_ids
+
+ @pytest.mark.usefixtures("interleaved_blocked_and_runnable_tis")
+
@mock.patch("airflow.jobs.scheduler_job_runner.MAX_TI_REFILL_QUERIES_PER_LOOP",
1)
+ @mock.patch("airflow._shared.observability.metrics.stats._get_backend")
+ def test_select_task_instances_to_queue_stops_at_refill_query_limit(self,
mock_get_backend, session):
+ mock_stats = mock.MagicMock(spec=StatsLogger)
+ mock_get_backend.return_value = mock_stats
+ self.job_runner = SchedulerJobRunner(job=Job())
+
+ queued_tis = self.job_runner._select_task_instances_to_queue(
+ 3, make_pool_stats(), set(), session=session
+ )
+
+ assert [ti.task_id for ti in queued_tis] == ["runnable_0",
"runnable_1"]
+
mock_stats.incr.assert_any_call("scheduler.critical_section_refill_limit_reached")
+
+ def
test_select_task_instances_to_queue_does_not_cap_queries_before_first_selection(
+ self, dag_maker, session
+ ):
+ """More blocked candidates than the refill cap must not hide a
lower-priority runnable TI."""
+ self.job_runner = SchedulerJobRunner(job=Job())
+ with dag_maker(dag_id="blocked_tis_exceed_refill_cap",
session=session):
+ blocked_tasks = [
+ EmptyOperator(task_id=f"blocked_{index}", priority_weight=10,
max_active_tis_per_dag=1)
+ for index in range(MAX_TI_REFILL_QUERIES_PER_LOOP + 2)
+ ]
+ runnable_task = EmptyOperator(task_id="runnable",
priority_weight=1)
+ first_run = dag_maker.create_dagrun(run_type=DagRunType.SCHEDULED)
+ second_run = dag_maker.create_dagrun_after(first_run,
run_type=DagRunType.SCHEDULED)
+ for blocked_task in blocked_tasks:
+ first_run.get_task_instance(blocked_task.task_id,
session=session).state = State.RUNNING
+ second_run.get_task_instance(blocked_task.task_id,
session=session).state = State.SCHEDULED
+ runnable_ti = second_run.get_task_instance(runnable_task.task_id,
session=session)
+ runnable_ti.state = State.SCHEDULED
+ session.flush()
+
+ queued_tis = self.job_runner._select_task_instances_to_queue(
+ 1, make_pool_stats(), set(), session=session
+ )
+
+ assert [ti.key for ti in queued_tis] == [runnable_ti.key]
+
+ def
test_select_task_instances_to_queue_finds_runnable_hidden_by_max_active_tasks_window(
+ self, dag_maker, session
+ ):
+ """Blocked TIs filling a run's ``max_active_tasks`` window must not
hide its runnable TIs."""
+ self.job_runner = SchedulerJobRunner(job=Job())
+ with dag_maker(
+ dag_id="blocked_tis_fill_max_active_tasks_window",
max_active_tasks=2, session=session
+ ):
+ blocked_tasks = [
+ EmptyOperator(task_id=f"blocked_{index}",
priority_weight=weight, max_active_tis_per_dag=1)
+ for index, weight in enumerate((3, 2))
+ ]
+ runnable_task = EmptyOperator(task_id="runnable",
priority_weight=1)
+ first_run = dag_maker.create_dagrun(run_type=DagRunType.SCHEDULED)
+ second_run = dag_maker.create_dagrun_after(first_run,
run_type=DagRunType.SCHEDULED)
+ for blocked_task in blocked_tasks:
+ first_run.get_task_instance(blocked_task.task_id,
session=session).state = State.RUNNING
+ second_run.get_task_instance(blocked_task.task_id,
session=session).state = State.SCHEDULED
+ runnable_ti = second_run.get_task_instance(runnable_task.task_id,
session=session)
+ runnable_ti.state = State.SCHEDULED
+ session.flush()
+
+ queued_tis = self.job_runner._select_task_instances_to_queue(
+ 32, make_pool_stats(), set(), session=session
+ )
+
+ assert [ti.key for ti in queued_tis] == [runnable_ti.key]
+
+ def test_select_task_instances_to_queue_preserves_priority_across_dag_runs(
+ self, dag_maker, mock_executors, session
+ ):
+ with dag_maker(dag_id="multiple_runs", max_active_tasks=2,
session=session):
+ EmptyOperator(task_id="running")
+ EmptyOperator(task_id="fills_run", priority_weight=100)
+ EmptyOperator(task_id="blocked_in_run", priority_weight=99)
+ EmptyOperator(task_id="other_run", priority_weight=98)
+ first_run = dag_maker.create_dagrun(run_type=DagRunType.SCHEDULED)
+ second_run = dag_maker.create_dagrun_after(first_run,
run_type=DagRunType.SCHEDULED)
+
+ with dag_maker(dag_id="lower_priority", session=session):
+ EmptyOperator(task_id="competitor", priority_weight=1)
+ competing_run = dag_maker.create_dagrun(run_type=DagRunType.SCHEDULED)
+
+ first_run.get_task_instance("running", session=session).state =
State.RUNNING
+ fills_run_ti = first_run.get_task_instance("fills_run",
session=session)
+ fills_run_ti.state = State.SCHEDULED
+ first_run.get_task_instance("blocked_in_run", session=session).state =
State.SCHEDULED
+ other_run_ti = second_run.get_task_instance("other_run",
session=session)
+ other_run_ti.state = State.SCHEDULED
+ competing_run.get_task_instance("competitor", session=session).state =
State.SCHEDULED
+ session.flush()
+
+ expected_keys = [fills_run_ti.key, other_run_ti.key]
+ pools = make_pool_stats(total=3, running=1)
+ self.job_runner = SchedulerJobRunner(job=Job())
+ queued_tis = []
+ # Allow another pass so this checks priority, not whether one pass
fills the batch.
+ for _ in range(2):
+ max_tis = int(pools["default_pool"]["open"])
+ if not max_tis:
+ break
+ queued_tis.extend(
+ self.job_runner._select_task_instances_to_queue(max_tis,
pools, set(), session=session)
+ )
+
+ assert [ti.key for ti in queued_tis] == expected_keys
+
+ def test_build_schedulable_tis_query_excludes_only_starved_dag_run(self,
dag_maker, session):
+ with dag_maker(dag_id="starved_run", session=session):
+ EmptyOperator(task_id="task")
+ first_run = dag_maker.create_dagrun(run_type=DagRunType.SCHEDULED)
+ second_run = dag_maker.create_dagrun_after(first_run,
run_type=DagRunType.SCHEDULED)
+ for dag_run in (first_run, second_run):
+ dag_run.get_task_instance("task", session=session).state =
State.SCHEDULED
+ session.flush()
+
+ query = SchedulerJobRunner(job=Job())._build_schedulable_tis_query(
+ set(), {("starved_run", first_run.run_id)}, set(), set(), 32
+ )
+
+ assert [ti.run_id for ti in session.scalars(query)] ==
[second_run.run_id]
+
def
test_find_executable_task_instances_task_concurrency_per_dagrun_for_first(self,
dag_maker):
scheduler_job = Job()
self.job_runner = SchedulerJobRunner(job=scheduler_job)
diff --git
a/shared/observability/src/airflow_shared/observability/metrics/metrics_template.yaml
b/shared/observability/src/airflow_shared/observability/metrics/metrics_template.yaml
index 45ce30f648c..0f9a796f480 100644
---
a/shared/observability/src/airflow_shared/observability/metrics/metrics_template.yaml
+++
b/shared/observability/src/airflow_shared/observability/metrics/metrics_template.yaml
@@ -207,6 +207,14 @@ metrics:
legacy_name: "-"
name_variables: []
+ - name: "scheduler.critical_section_refill_limit_reached"
+ description: "Count of times the scheduler stopped querying for more task
instances to fill
+ its batch because it reached the per-loop limit of refill queries. The
remaining runnable
+ task instances are considered in the next scheduler loop."
+ type: "counter"
+ legacy_name: "-"
+ name_variables: []
+
- name: "ti.start"
description: "Number of started task in a given Dag. Similar to
{job_name}_start but for task.
Metric with dag_id and task_id tagging."