Skip to content

Commit dd58070

Browse files
committed
Stop failing scheduled tasks when a serialized Dag is briefly missing
A serialized Dag that cannot be read is almost always transient -- the Dag processor is mid-write, or the row was briefly unreadable -- but the scheduler treated it as final and failed every SCHEDULED task instance of the Dag, across all of its Dag runs, bypassing retries and failure callbacks. The Dag run was not finished either, so those tasks could only be recovered by clearing them by hand. The bulk update also doubled as a starvation filter: flipping the rows to FAILED is what kept them out of the next iteration of the critical-section query. Starving the run preserves that effect, so an unresolvable Dag cannot hold up the Dags queued behind it. The error is reported once per Dag rather than once per task instance, so a Dag with many stuck runs cannot flood the scheduler log now that its task instances survive the round. closes: #62050
1 parent a763ae3 commit dd58070

3 files changed

Lines changed: 159 additions & 21 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Stop failing every ``SCHEDULED`` task instance of a Dag when its serialized Dag cannot be read while evaluating task-level concurrency limits; such a lookup is usually transient, so the task instances are now left ``SCHEDULED`` and reconsidered on the next scheduling loop.

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

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -587,6 +587,7 @@ def _task_concurrency_allows_execution(
587587
task_instance: TI,
588588
concurrency_map: ConcurrencyMap,
589589
session: Session,
590+
starved_dag_runs: dict[str, set[str]],
590591
starved_tasks: set[tuple[str, str]],
591592
starved_tasks_task_dagrun_concurrency: set[tuple[str, str, str]],
592593
) -> bool:
@@ -595,25 +596,30 @@ def _task_concurrency_allows_execution(
595596
task_id = task_instance.task_id
596597
run_id = task_instance.run_id
597598

599+
if run_id in starved_dag_runs.get(dag_id, ()):
600+
return False
601+
598602
serialized_dag = self.scheduler_dag_bag.get_dag_for_run(
599603
dag_run=task_instance.dag_run,
600604
session=session,
601605
)
602606

603-
# If the DAG is missing, fail all scheduled TIs for this DAG.
607+
# Leave the task instances SCHEDULED: the miss is usually transient, and when it is not,
608+
# _schedule_dag_run cannot progress the run either, so failing its task instances costs
609+
# them their retries and callbacks without unsticking the run. Starving the run is what
610+
# keeps this loop live: unstarved, these task instances refill every query iteration and
611+
# no other Dag is ever examined.
604612
if not serialized_dag:
605-
self.log.error(
606-
"DAG '%s' for task instance %s not found in serialized_dag table",
607-
dag_id,
608-
task_instance,
609-
)
613+
if dag_id not in starved_dag_runs:
614+
self.log.error(
615+
"Dag '%s' (run %s) for task instance %s not found in serialized_dag table; "
616+
"skipping its task instances for this scheduling round",
617+
dag_id,
618+
run_id,
619+
task_instance,
620+
)
610621

611-
session.execute(
612-
update(TI)
613-
.where(TI.dag_id == dag_id, TI.state == TaskInstanceState.SCHEDULED)
614-
.values(state=TaskInstanceState.FAILED)
615-
.execution_options(synchronize_session="fetch")
616-
)
622+
starved_dag_runs.setdefault(dag_id, set()).add(run_id)
617623

618624
return False
619625

@@ -723,12 +729,17 @@ def _executable_task_instances_to_queued(self, max_tis: int, session: Session) -
723729
starved_dags: set[str] = set()
724730
starved_tasks: set[tuple[str, str]] = set()
725731
starved_tasks_task_dagrun_concurrency: set[tuple[str, str, str]] = set()
732+
# Run ids, per Dag, whose serialized Dag could not be resolved. Grouped that way because
733+
# resolution is per run -- a run pinned to a deleted version misses while another run of
734+
# the same Dag still resolves -- so the runs starve one by one but the Dag is logged once.
735+
starved_dag_runs: dict[str, set[str]] = {}
726736

727737
pool_num_starving_tasks: dict[str, int] = Counter()
728738

729739
for loop_count in itertools.count(start=1):
730740
num_starved_pools = len(starved_pools)
731741
num_starved_dags = len(starved_dags)
742+
num_starved_dag_runs = sum(len(run_ids) for run_ids in starved_dag_runs.values())
732743
num_starved_tasks = len(starved_tasks)
733744
num_starved_tasks_task_dagrun_concurrency = len(starved_tasks_task_dagrun_concurrency)
734745

@@ -771,6 +782,17 @@ def _executable_task_instances_to_queued(self, max_tis: int, session: Session) -
771782
if starved_dags:
772783
query = query.where(TI.dag_id.not_in(starved_dags))
773784

785+
if starved_dag_runs:
786+
query = query.where(
787+
tuple_(TI.dag_id, TI.run_id).not_in(
788+
[
789+
(dag_id, run_id)
790+
for dag_id, run_ids in starved_dag_runs.items()
791+
for run_id in run_ids
792+
]
793+
)
794+
)
795+
774796
if starved_tasks:
775797
query = query.where(tuple_(TI.dag_id, TI.task_id).not_in(starved_tasks))
776798

@@ -987,6 +1009,7 @@ def _executable_task_instances_to_queued(self, max_tis: int, session: Session) -
9871009
task_instance=task_instance,
9881010
concurrency_map=concurrency_map,
9891011
session=session,
1012+
starved_dag_runs=starved_dag_runs,
9901013
starved_tasks=starved_tasks,
9911014
starved_tasks_task_dagrun_concurrency=(starved_tasks_task_dagrun_concurrency),
9921015
)
@@ -1035,6 +1058,7 @@ def _executable_task_instances_to_queued(self, max_tis: int, session: Session) -
10351058
found_new_filters = (
10361059
len(starved_pools) > num_starved_pools
10371060
or len(starved_dags) > num_starved_dags
1061+
or sum(len(run_ids) for run_ids in starved_dag_runs.values()) > num_starved_dag_runs
10381062
or len(starved_tasks) > num_starved_tasks
10391063
or len(starved_tasks_task_dagrun_concurrency) > num_starved_tasks_task_dagrun_concurrency
10401064
)

‎airflow-core/tests/unit/jobs/test_scheduler_job.py‎

Lines changed: 122 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2188,8 +2188,8 @@ def test_find_executable_task_instances_in_default_pool(self, dag_maker, mock_ex
21882188
session.rollback()
21892189
session.close()
21902190

2191-
def test_queued_task_instances_fails_with_missing_dag(self, dag_maker, session):
2192-
"""Check that task instances of missing DAGs are failed"""
2191+
def test_queued_task_instances_skipped_when_serialized_dag_missing(self, dag_maker, session):
2192+
"""An unresolvable serialized Dag leaves every run's task instances SCHEDULED for a retry."""
21932193
dag_id = "SchedulerJobTest.test_find_executable_task_instances_not_in_dagbag"
21942194
task_id_1 = "dummy"
21952195
task_id_2 = "dummydummy"
@@ -2201,22 +2201,135 @@ def test_queued_task_instances_fails_with_missing_dag(self, dag_maker, session):
22012201
scheduler_job = Job()
22022202
self.job_runner = SchedulerJobRunner(job=scheduler_job)
22032203

2204-
self.job_runner.scheduler_dag_bag = mock.MagicMock()
2204+
self.job_runner.scheduler_dag_bag = mock.MagicMock(spec=CachedDBDagBag)
22052205
self.job_runner.scheduler_dag_bag.get_dag_for_run.return_value = None
22062206

2207+
dr1 = dag_maker.create_dagrun(run_id="run_1", state=DagRunState.RUNNING)
2208+
dr2 = dag_maker.create_dagrun_after(dr1, run_id="run_2", state=DagRunState.RUNNING)
2209+
2210+
for dr in (dr1, dr2):
2211+
for ti in dr.task_instances:
2212+
ti.state = State.SCHEDULED
2213+
session.merge(ti)
2214+
session.flush()
2215+
res = self.job_runner._executable_task_instances_to_queued(max_tis=32, session=session)
2216+
session.flush()
2217+
assert len(res) == 0
2218+
for dr in (dr1, dr2):
2219+
tis = dr.get_task_instances(session=session)
2220+
assert len(tis) == 2
2221+
assert all(ti.state == State.SCHEDULED for ti in tis)
2222+
2223+
# The lookup is made once per run, not once for each of the four task instances.
2224+
assert self.job_runner.scheduler_dag_bag.get_dag_for_run.call_count == 2
2225+
2226+
def test_missing_serialized_dag_for_one_run_does_not_block_another_run(self, dag_maker, session):
2227+
"""Resolution is per run, so one run's miss must not hold back the Dag's other runs."""
2228+
dag_id = "SchedulerJobTest.test_missing_serialized_dag_one_run"
2229+
2230+
with dag_maker(dag_id=dag_id, session=session, default_args={"max_active_tis_per_dag": 2}):
2231+
EmptyOperator(task_id="dummy")
2232+
2233+
dr1 = dag_maker.create_dagrun(run_id="run_1", state=DagRunState.RUNNING)
2234+
dr2 = dag_maker.create_dagrun_after(dr1, run_id="run_2", state=DagRunState.RUNNING)
2235+
serialized_dag = dag_maker.serialized_dag
2236+
2237+
scheduler_job = Job()
2238+
self.job_runner = SchedulerJobRunner(job=scheduler_job)
2239+
self.job_runner.scheduler_dag_bag = mock.MagicMock(spec=CachedDBDagBag)
2240+
self.job_runner.scheduler_dag_bag.get_dag_for_run.side_effect = lambda dag_run, session: (
2241+
None if dag_run.run_id == "run_1" else serialized_dag
2242+
)
2243+
2244+
for dr in (dr1, dr2):
2245+
for ti in dr.task_instances:
2246+
ti.state = State.SCHEDULED
2247+
session.merge(ti)
2248+
session.flush()
2249+
2250+
res = self.job_runner._executable_task_instances_to_queued(max_tis=32, session=session)
2251+
session.flush()
2252+
2253+
assert [(ti.dag_id, ti.run_id) for ti in res] == [(dag_id, "run_2")]
2254+
assert all(ti.state == State.SCHEDULED for ti in dr1.get_task_instances(session=session))
2255+
2256+
def test_missing_serialized_dag_queues_task_instances_once_it_resolves(self, dag_maker, session):
2257+
"""Task instances left SCHEDULED are queued on a later round once the serialized Dag resolves."""
2258+
dag_id = "SchedulerJobTest.test_missing_serialized_dag_resolves"
2259+
2260+
with dag_maker(dag_id=dag_id, session=session, default_args={"max_active_tis_per_dag": 2}):
2261+
EmptyOperator(task_id="dummy")
2262+
22072263
dr = dag_maker.create_dagrun(state=DagRunState.RUNNING)
2264+
serialized_dag = dag_maker.serialized_dag
22082265

2209-
tis = dr.task_instances
2210-
for ti in tis:
2266+
scheduler_job = Job()
2267+
self.job_runner = SchedulerJobRunner(job=scheduler_job)
2268+
self.job_runner.scheduler_dag_bag = mock.MagicMock(spec=CachedDBDagBag)
2269+
self.job_runner.scheduler_dag_bag.get_dag_for_run.side_effect = [None, serialized_dag]
2270+
2271+
for ti in dr.task_instances:
22112272
ti.state = State.SCHEDULED
22122273
session.merge(ti)
22132274
session.flush()
2275+
2276+
assert self.job_runner._executable_task_instances_to_queued(max_tis=32, session=session) == []
2277+
session.flush()
2278+
assert all(ti.state == State.SCHEDULED for ti in dr.get_task_instances(session=session))
2279+
22142280
res = self.job_runner._executable_task_instances_to_queued(max_tis=32, session=session)
2281+
assert [ti.task_id for ti in res] == ["dummy"]
2282+
2283+
def test_missing_serialized_dag_does_not_starve_other_dags(self, dag_maker, session, caplog):
2284+
"""A Dag that cannot be resolved must not hold up the Dags queued behind it."""
2285+
broken_dag_id = "SchedulerJobTest.test_missing_serialized_dag_starvation_broken"
2286+
healthy_dag_id = "SchedulerJobTest.test_missing_serialized_dag_starvation_healthy"
2287+
2288+
with dag_maker(
2289+
dag_id=broken_dag_id,
2290+
session=session,
2291+
default_args={"max_active_tis_per_dag": 1, "priority_weight": 100},
2292+
):
2293+
EmptyOperator(task_id="broken")
2294+
broken_runs = [dag_maker.create_dagrun(run_id="broken_run_0", state=DagRunState.RUNNING)]
2295+
for run_number in range(1, 4):
2296+
broken_runs.append(
2297+
dag_maker.create_dagrun_after(
2298+
broken_runs[-1], run_id=f"broken_run_{run_number}", state=DagRunState.RUNNING
2299+
)
2300+
)
2301+
2302+
with dag_maker(dag_id=healthy_dag_id, session=session, default_args={"priority_weight": 1}):
2303+
EmptyOperator(task_id="healthy")
2304+
healthy_run = dag_maker.create_dagrun(run_id="healthy_run", state=DagRunState.RUNNING)
2305+
2306+
for dr in (*broken_runs, healthy_run):
2307+
for ti in dr.task_instances:
2308+
ti.state = State.SCHEDULED
2309+
session.merge(ti)
2310+
session.commit()
2311+
2312+
session.execute(delete(SerializedDagModel).where(SerializedDagModel.dag_id == broken_dag_id))
2313+
session.commit()
2314+
session.expire_all()
2315+
2316+
scheduler_job = Job()
2317+
self.job_runner = SchedulerJobRunner(job=scheduler_job)
2318+
2319+
# The broken Dag has more runs than one query can return, and its task instances outrank the
2320+
# healthy Dag's, so the healthy Dag is only reachable if every broken run is starved out over
2321+
# successive query iterations.
2322+
res = self.job_runner._executable_task_instances_to_queued(max_tis=2, session=session)
22152323
session.flush()
2216-
assert len(res) == 0
2217-
tis = dr.get_task_instances(session=session)
2218-
assert len(tis) == 2
2219-
assert all(ti.state == State.FAILED for ti in tis)
2324+
2325+
assert [(ti.dag_id, ti.task_id) for ti in res] == [(healthy_dag_id, "healthy")]
2326+
for dr in broken_runs:
2327+
assert all(ti.state == State.SCHEDULED for ti in dr.get_task_instances(session=session))
2328+
2329+
# One error for the Dag, not one per missing run.
2330+
errors = [entry for entry in caplog.entries if entry["log_level"] == "error"]
2331+
assert len(errors) == 1
2332+
assert f"Dag '{broken_dag_id}'" in errors[0]["event"]
22202333

22212334
def test_nonexistent_pool(self, dag_maker):
22222335
dag_id = "SchedulerJobTest.test_nonexistent_pool"

0 commit comments

Comments
 (0)