Skip to content

Commit 9ddf568

Browse files
xBis7choo121600
authored andcommitted
Include the max_active_tasks limit in the query fetching TIs to be queued (apache#54103)
1 parent 6bba070 commit 9ddf568

2 files changed

Lines changed: 379 additions & 6 deletions

File tree

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

Lines changed: 98 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from __future__ import annotations
1919

2020
import itertools
21+
import logging
2122
import multiprocessing
2223
import operator
2324
import os
@@ -32,7 +33,19 @@
3233
from itertools import groupby
3334
from typing import TYPE_CHECKING, Any
3435

35-
from sqlalchemy import and_, delete, desc, exists, func, inspect, or_, select, text, tuple_, update
36+
from sqlalchemy import (
37+
and_,
38+
delete,
39+
desc,
40+
exists,
41+
func,
42+
inspect,
43+
or_,
44+
select,
45+
text,
46+
tuple_,
47+
update,
48+
)
3649
from sqlalchemy.exc import OperationalError
3750
from sqlalchemy.orm import joinedload, lazyload, load_only, make_transient, selectinload
3851
from sqlalchemy.sql import expression
@@ -110,6 +123,7 @@
110123
from pendulum.datetime import DateTime
111124
from sqlalchemy.orm import Session
112125
from sqlalchemy.orm.interfaces import LoaderOption
126+
from sqlalchemy.sql.selectable import Subquery
113127

114128
from airflow._shared.logging.types import Logger
115129
from airflow.executors.base_executor import BaseExecutor
@@ -198,6 +212,16 @@ def _is_parent_process() -> bool:
198212
return multiprocessing.current_process().name == "MainProcess"
199213

200214

215+
def _get_current_dr_task_concurrency(states: Iterable[TaskInstanceState]) -> Subquery:
216+
"""Get the dag_run IDs and how many tasks are in the provided states for each one."""
217+
return (
218+
select(TI.dag_id, TI.run_id, func.count("*").label("task_per_dr_count"))
219+
.where(TI.state.in_(states))
220+
.group_by(TI.dag_id, TI.run_id)
221+
.subquery()
222+
)
223+
224+
201225
class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
202226
"""
203227
SchedulerJobRunner runs for a specific time interval and schedules jobs that are ready to run.
@@ -485,6 +509,13 @@ def _executable_task_instances_to_queued(self, max_tis: int, session: Session) -
485509
num_starved_tasks = len(starved_tasks)
486510
num_starved_tasks_task_dagrun_concurrency = len(starved_tasks_task_dagrun_concurrency)
487511

512+
# This behaves the same as 'concurrency_map.load()' with the difference that
513+
# 'load()' executes immediately while '_get_current_dr_task_concurrency' creates a
514+
# subquery object that is then executed along with main query.
515+
# The results of 'load()' aren't used again here because by the time the main query
516+
# executes, there could be a change that will be ignored.
517+
dr_task_concurrency_subquery = _get_current_dr_task_concurrency(states=EXECUTION_STATES)
518+
488519
query = (
489520
select(TI)
490521
.with_hint(TI, "USE INDEX (ti_state)", dialect_name="mysql")
@@ -494,10 +525,23 @@ def _executable_task_instances_to_queued(self, max_tis: int, session: Session) -
494525
.where(~DM.is_paused)
495526
.where(TI.state == TaskInstanceState.SCHEDULED)
496527
.where(DM.bundle_name.is_not(None))
497-
.options(selectinload(TI.dag_model))
528+
.join(
529+
dr_task_concurrency_subquery,
530+
and_(
531+
TI.dag_id == dr_task_concurrency_subquery.c.dag_id,
532+
TI.run_id == dr_task_concurrency_subquery.c.run_id,
533+
),
534+
isouter=True,
535+
)
536+
.where(
537+
func.coalesce(dr_task_concurrency_subquery.c.task_per_dr_count, 0) < DM.max_active_tasks
538+
)
498539
.order_by(-TI.priority_weight, DR.logical_date, TI.map_index)
499540
)
500541

542+
# Starvation filters should be applied before computing the row_num based on the
543+
# max_active_tasks limit. That way, starved dags and tasks that shouldn't run,
544+
# won't occupy a slot.
501545
if starved_pools:
502546
query = query.where(TI.pool.not_in(starved_pools))
503547

@@ -512,14 +556,62 @@ def _executable_task_instances_to_queued(self, max_tis: int, session: Session) -
512556
tuple_(TI.dag_id, TI.run_id, TI.task_id).not_in(starved_tasks_task_dagrun_concurrency)
513557
)
514558

559+
# Create a subquery with row numbers partitioned by dag_id and run_id.
560+
# Different dags can have the same run_id but
561+
# the dag_id combined with the run_id uniquely identify a run.
562+
ranked_query = (
563+
query.add_columns(
564+
func.row_number()
565+
.over(
566+
partition_by=[TI.dag_id, TI.run_id],
567+
order_by=[-TI.priority_weight, DR.logical_date, TI.map_index],
568+
)
569+
.label("row_num"),
570+
DM.max_active_tasks.label("dr_max_active_tasks"),
571+
# Create columns for the order_by checks here for sqlite.
572+
TI.priority_weight.label("priority_weight_for_ordering"),
573+
DR.logical_date.label("logical_date_for_ordering"),
574+
TI.map_index.label("map_index_for_ordering"),
575+
)
576+
).subquery()
577+
578+
# Select only rows where row_number <= max_active_tasks.
579+
query = (
580+
select(TI)
581+
.with_hint(TI, "USE INDEX (ti_state)", dialect_name="mysql")
582+
.select_from(ranked_query)
583+
.join(
584+
TI,
585+
(TI.dag_id == ranked_query.c.dag_id)
586+
& (TI.task_id == ranked_query.c.task_id)
587+
& (TI.run_id == ranked_query.c.run_id)
588+
& (TI.map_index == ranked_query.c.map_index),
589+
)
590+
.where(ranked_query.c.row_num <= ranked_query.c.dr_max_active_tasks)
591+
# Add the order_by columns from the ranked query for sqlite.
592+
.order_by(
593+
-ranked_query.c.priority_weight_for_ordering,
594+
ranked_query.c.logical_date_for_ordering,
595+
ranked_query.c.map_index_for_ordering,
596+
)
597+
.options(selectinload(TI.dag_model))
598+
)
599+
515600
query = query.limit(max_tis)
516601

517602
timer = Stats.timer("scheduler.critical_section_query_duration")
518603
timer.start()
519604

520605
try:
521606
locked_query = with_row_locks(query, of=TI, session=session, skip_locked=True)
522-
task_instances_to_examine: list[TI] = list(session.scalars(locked_query).all())
607+
task_instances_to_examine = session.scalars(locked_query).all()
608+
609+
if self.log.isEnabledFor(logging.DEBUG):
610+
self.log.debug("Length of the tis to examine is %d", len(task_instances_to_examine))
611+
self.log.debug(
612+
"TaskInstance selection is: %s",
613+
dict(Counter(ti.dag_id for ti in task_instances_to_examine)),
614+
)
523615

524616
timer.stop(send=True)
525617
except OperationalError as e:
@@ -709,7 +801,9 @@ def _executable_task_instances_to_queued(self, max_tis: int, session: Session) -
709801
if executor_slots_available[executor_obj.name] <= 0:
710802
self.log.debug(
711803
"Not scheduling %s since its executor %s does not currently have any more "
712-
"available slots"
804+
"available slots",
805+
task_instance.task_id,
806+
executor_obj.name,
713807
)
714808
starved_tasks.add((task_instance.dag_id, task_instance.task_id))
715809
continue

0 commit comments

Comments
 (0)