@@ -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