Skip to content

Commit 11762d4

Browse files
committed
Reduce scheduler spikes when expanding large mapped tasks
Large mapped expansions can overload one scheduler pass, increasing scheduling latency and making unrelated Dags wait. This change smooths mapped fan-out handling so high-cardinality expansions do not monopolize scheduler heartbeats, while preserving custom task-instance mutation-hook behavior.
1 parent 4406537 commit 11762d4

4 files changed

Lines changed: 182 additions & 24 deletions

File tree

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

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1685,6 +1685,9 @@ def _expand_mapped_task_if_needed(ti: TI) -> Iterable[TI] | None:
16851685
expansion_happened = False
16861686
# Set of task ids for which was already done _revise_map_indexes_if_mapped
16871687
revised_map_index_task_ids: set[str] = set()
1688+
max_tis_per_query = airflow_conf.getint("scheduler", "max_tis_per_query")
1689+
if max_tis_per_query <= 0:
1690+
max_tis_per_query = airflow_conf.getint("core", "parallelism")
16881691
for schedulable in itertools.chain(schedulable_tis, additional_tis):
16891692
if TYPE_CHECKING:
16901693
assert isinstance(schedulable.task, Operator)
@@ -1702,7 +1705,21 @@ def _expand_mapped_task_if_needed(ti: TI) -> Iterable[TI] | None:
17021705
if schedulable.map_index < 0:
17031706
new_tis = _expand_mapped_task_if_needed(schedulable)
17041707
if new_tis is not None:
1705-
additional_tis.extend(new_tis)
1708+
expanded_tis = list(new_tis)
1709+
# Avoid evaluating a huge number of newly expanded TIs in the same pass.
1710+
# They are persisted already and picked up in subsequent loops.
1711+
remaining_budget = max(max_tis_per_query - len(additional_tis), 0)
1712+
if remaining_budget:
1713+
additional_tis.extend(expanded_tis[:remaining_budget])
1714+
dropped_tis = len(expanded_tis) - remaining_budget
1715+
if dropped_tis > 0:
1716+
self.log.debug(
1717+
"Deferring dependency checks for expanded TIs to a later scheduler pass",
1718+
task_id=schedulable.task_id,
1719+
dag_id=self.dag_id,
1720+
run_id=self.run_id,
1721+
deferred_count=dropped_tis,
1722+
)
17061723
expansion_happened = True
17071724
# Expansion changes a mapped task's instance count, which invalidates the
17081725
# trigger-rule upstream-count memo on this DepContext (a downstream evaluated
@@ -2145,17 +2162,50 @@ def _revise_map_indexes_if_mapped(
21452162
)
21462163
session.flush()
21472164

2148-
new_tis: list[TI] = []
2149-
for index in range(total_length):
2150-
if index in existing_indexes:
2151-
continue
2165+
from airflow.settings import task_instance_mutation_hook
2166+
2167+
new_indexes = [index for index in range(total_length) if index not in existing_indexes]
2168+
if not new_indexes:
2169+
return []
2170+
2171+
hook_is_noop = getattr(task_instance_mutation_hook, "is_noop", False) is True
2172+
if hook_is_noop:
2173+
ti_mappings = [
2174+
TI.insert_mapping(
2175+
self.run_id,
2176+
task,
2177+
map_index=index,
2178+
dag_version_id=dag_version_id,
2179+
dag_run=self,
2180+
)
2181+
for index in new_indexes
2182+
]
2183+
session.bulk_insert_mappings(TI.__mapper__, ti_mappings)
2184+
session.flush()
2185+
inserted_tis = list(
2186+
session.scalars(
2187+
select(TI)
2188+
.where(
2189+
TI.dag_id == self.dag_id,
2190+
TI.task_id == task.task_id,
2191+
TI.run_id == self.run_id,
2192+
TI.map_index.in_(new_indexes),
2193+
)
2194+
.order_by(TI.map_index)
2195+
).all()
2196+
)
2197+
for ti in inserted_tis:
2198+
ti.task = task
2199+
return inserted_tis
2200+
2201+
created_tis: list[TI] = []
2202+
for index in new_indexes:
21522203
ti = TI(task, run_id=self.run_id, map_index=index, state=None, dag_version_id=dag_version_id)
21532204
self.log.debug("Expanding TIs upserted %s", ti)
21542205
_add_and_prime_mapped_ti(ti, task, self, session=session)
2155-
new_tis.append(ti)
2156-
if new_tis:
2157-
session.flush()
2158-
return new_tis
2206+
created_tis.append(ti)
2207+
session.flush()
2208+
return created_tis
21592209

21602210
@classmethod
21612211
@provide_session

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

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -256,22 +256,58 @@ def expand_mapped_task(
256256
)
257257
)
258258

259+
from airflow.settings import task_instance_mutation_hook
260+
261+
hook_is_noop = getattr(task_instance_mutation_hook, "is_noop", False) is True
259262
new_tis: list[TaskInstance] = []
260-
for index in indexes_to_map:
261-
ti = TaskInstance(
262-
task,
263-
run_id=run_id,
264-
map_index=index,
265-
state=state,
266-
dag_version_id=dag_version_id,
267-
)
268-
task.log.debug("Expanding TIs upserted %s", ti)
269-
_add_and_prime_mapped_ti(
270-
ti, task, dr, session=session, context_carrier=new_task_run_carrier(dr.context_carrier)
271-
)
272-
new_tis.append(ti)
273-
if new_tis:
274-
session.flush()
263+
if hook_is_noop and isinstance(indexes_to_map, range):
264+
ti_mappings = [
265+
TaskInstance.insert_mapping(
266+
run_id,
267+
task,
268+
map_index=index,
269+
dag_version_id=dag_version_id,
270+
dag_run=dr,
271+
)
272+
for index in indexes_to_map
273+
]
274+
if state is not None:
275+
for ti_mapping in ti_mappings:
276+
ti_mapping["state"] = state
277+
if ti_mappings:
278+
session.bulk_insert_mappings(TaskInstance.__mapper__, ti_mappings)
279+
session.flush()
280+
new_tis = list(
281+
session.scalars(
282+
select(TaskInstance)
283+
.where(
284+
TaskInstance.dag_id == task.dag_id,
285+
TaskInstance.task_id == task.task_id,
286+
TaskInstance.run_id == run_id,
287+
TaskInstance.map_index >= indexes_to_map.start,
288+
TaskInstance.map_index < indexes_to_map.stop,
289+
)
290+
.order_by(TaskInstance.map_index)
291+
).all()
292+
)
293+
for ti in new_tis:
294+
ti.task = task
295+
else:
296+
for index in indexes_to_map:
297+
ti = TaskInstance(
298+
task,
299+
run_id=run_id,
300+
map_index=index,
301+
state=state,
302+
dag_version_id=dag_version_id,
303+
)
304+
task.log.debug("Expanding TIs upserted %s", ti)
305+
_add_and_prime_mapped_ti(
306+
ti, task, dr, session=session, context_carrier=new_task_run_carrier(dr.context_carrier)
307+
)
308+
new_tis.append(ti)
309+
if new_tis:
310+
session.flush()
275311
all_expanded_tis.extend(new_tis)
276312

277313
# Coerce the None case to 0 -- these two are almost treated identically,

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2139,6 +2139,37 @@ def task_2(arg2): ...
21392139
]
21402140

21412141

2142+
def test_mapped_expansion_defers_some_tis_to_later_scheduler_pass(dag_maker, session):
2143+
@task
2144+
def task_1(): ...
2145+
2146+
with dag_maker(session=session):
2147+
2148+
@task
2149+
def task_2(arg2): ...
2150+
2151+
task_2.expand(arg2=task_1())
2152+
2153+
dr: DagRun = dag_maker.create_dagrun()
2154+
ti = dr.get_task_instance(task_id="task_1", session=session)
2155+
assert ti
2156+
ti.state = TaskInstanceState.SUCCESS
2157+
session.add(TaskMap.from_task_instance_xcom(ti, [1, 2, 3, 4]))
2158+
session.flush()
2159+
2160+
with conf_vars({("scheduler", "max_tis_per_query"): "2"}):
2161+
decision = dr.task_instance_scheduling_decisions(session=session)
2162+
2163+
indices = [(ti.task_id, ti.map_index) for ti in decision.schedulable_tis]
2164+
assert indices == [("task_2", 0), ("task_2", 1)]
2165+
2166+
with conf_vars({("scheduler", "max_tis_per_query"): "2"}):
2167+
decision = dr.task_instance_scheduling_decisions(session=session)
2168+
2169+
indices = [(ti.task_id, ti.map_index) for ti in decision.schedulable_tis]
2170+
assert indices == [("task_2", 0), ("task_2", 1), ("task_2", 2), ("task_2", 3)]
2171+
2172+
21422173
def test_mapped_literal_length_reduction_at_runtime_adds_removed_state(dag_maker, session):
21432174
"""
21442175
Test that when the length of mapped literal reduces at runtime, the missing task instances

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,47 @@ def test_expand_mapped_task_task_instance_mutation_hook(dag_maker, session, crea
501501
assert call.args[0].map_index == expected_map_index[index]
502502

503503

504+
def test_expand_mapped_task_uses_bulk_insert_when_mutation_hook_is_noop(dag_maker, session) -> None:
505+
with dag_maker(session=session, serialized=True) as dag:
506+
task1 = BaseOperator(task_id="op1")
507+
mapped = MockOperator.partial(task_id="task_2").expand(arg2=task1.output)
508+
509+
dr = dag_maker.create_dagrun()
510+
511+
class NoopHook:
512+
is_noop = True
513+
514+
def __call__(self, *_, **__):
515+
return None
516+
517+
noop_hook = NoopHook()
518+
519+
with (
520+
mock.patch("airflow.settings.task_instance_mutation_hook", noop_hook),
521+
mock.patch.object(session, "bulk_insert_mappings", wraps=session.bulk_insert_mappings) as bulk_insert,
522+
):
523+
expand_mapped_task(
524+
dag.task_dict[mapped.task_id],
525+
dr.run_id,
526+
task1.task_id,
527+
length=4,
528+
session=session,
529+
)
530+
531+
assert bulk_insert.called
532+
mapped_indexes = session.scalars(
533+
select(TaskInstance.map_index)
534+
.where(
535+
TaskInstance.dag_id == dag.dag_id,
536+
TaskInstance.task_id == mapped.task_id,
537+
TaskInstance.run_id == dr.run_id,
538+
TaskInstance.map_index >= 0,
539+
)
540+
.order_by(TaskInstance.map_index)
541+
).all()
542+
assert mapped_indexes == [0, 1, 2, 3]
543+
544+
504545
class TestMappedSetupTeardown:
505546
@staticmethod
506547
def get_states(dr):

0 commit comments

Comments
 (0)