1818from __future__ import annotations
1919
2020import itertools
21+ import logging
2122import multiprocessing
2223import operator
2324import os
3233from itertools import groupby
3334from 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+ )
3649from sqlalchemy .exc import OperationalError
3750from sqlalchemy .orm import joinedload , lazyload , load_only , make_transient , selectinload
3851from sqlalchemy .sql import expression
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+
201225class 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