kaxil commented on code in PR #75010:
URL: https://github.com/apache/airflow/pull/75010#discussion_r4323364825


##########
airflow-core/src/airflow/jobs/scheduler_job_runner.py:
##########
@@ -3994,6 +3994,11 @@ def _find_task_instances_without_heartbeats(self, *, 
session: Session) -> list[T
             session.execute(with_row_locks(run_query, of=DagRun, 
session=session, skip_locked=True))
         )
         query = query.where(or_(~regional_expression(TI), tuple_(TI.dag_id, 
TI.run_id).in_(locked_runs)))
+        # Bound the batch (oldest heartbeat first) so a mass heartbeat-loss 
event does not purge every
+        # stale row, each with per-TI work under FOR UPDATE, in one 
scheduler-stalling transaction.
+        query = query.order_by(TI.last_heartbeat_at)
+        if self.job.max_tis_per_query:
+            query = query.limit(self.job.max_tis_per_query)

Review Comment:
   With the defaults this drains 16 TIs every 10s, since the purge runs on the 
`task_instance_heartbeat_timeout_detection_interval` timer rather than once per 
scheduler loop. Until a TI is purged it stays in `executor.running` (so it 
counts against `slots_occupied`) and keeps its pool slot, so a deployment at 
`parallelism = 1024` that loses a worker node with ~1000 running tasks now 
needs about 63 passes (~10 minutes) to get that capacity back, where it used to 
take one. Also, `handle_failure()` already commits per TI through 
`save_to_db()`, so the scan's FOR UPDATE locks are released after the first TI 
either way; what the limit mostly bounds is how long one timer call holds the 
loop. Could a full batch schedule a follow-up pass on the next loop instead of 
waiting the full interval, or use a larger dedicated cap like the `.limit(100)` 
in the AWAITING_INPUT sweep?



##########
airflow-core/tests/unit/jobs/test_scheduler_job.py:
##########
@@ -918,6 +918,30 @@ def 
test_heartbeat_scan_waits_for_a_locked_dagrun_only_for_regional_task_instanc
 
         assert [row.id for row in 
runner._find_task_instances_without_heartbeats(session=session)] == [ti.id]
 
+    def 
test_find_task_instances_without_heartbeats_is_bounded_by_max_tis_per_query(self,
 dag_maker, session):
+        """A mass heartbeat-loss event must be purged in bounded batches, 
oldest heartbeat first."""
+        with dag_maker(serialized=True):
+            EmptyOperator(task_id="task")
+        runner = SchedulerJobRunner(Job(), 
executors=[MockExecutor(do_update=False)])
+        session.add(runner.job)
+        session.flush()
+        runner.job.max_tis_per_query = 2
+
+        ids_oldest_first = []
+        for i in range(3):
+            dr = dag_maker.create_dagrun(run_id=f"run_{i}", 
logical_date=DEFAULT_DATE + timedelta(days=i))
+            ti = dr.get_task_instance("task", session=session)
+            ti.state = State.RUNNING
+            ti.region_index = -1
+            ti.last_heartbeat_at = DEFAULT_DATE + timedelta(minutes=i)

Review Comment:
   Heartbeat order matches insertion order here (and the uuid7 `id` order), so 
this still passes with the `order_by` removed. Reversing it, e.g. `DEFAULT_DATE 
- timedelta(minutes=i)` with the expected list built in heartbeat order, would 
make the test pin oldest-first. A `max_tis_per_query = 0` case would cover the 
unbounded branch too.



##########
airflow-core/src/airflow/jobs/scheduler_job_runner.py:
##########
@@ -3994,6 +3994,11 @@ def _find_task_instances_without_heartbeats(self, *, 
session: Session) -> list[T
             session.execute(with_row_locks(run_query, of=DagRun, 
session=session, skip_locked=True))
         )
         query = query.where(or_(~regional_expression(TI), tuple_(TI.dag_id, 
TI.run_id).in_(locked_runs)))
+        # Bound the batch (oldest heartbeat first) so a mass heartbeat-loss 
event does not purge every
+        # stale row, each with per-TI work under FOR UPDATE, in one 
scheduler-stalling transaction.
+        query = query.order_by(TI.last_heartbeat_at)
+        if self.job.max_tis_per_query:

Review Comment:
   The config description says `0` means "use `[core] parallelism`", and 
`_executable_task_instances_to_queued` (line 1306) does that; only the 
`schedule_tis` chunking treats `0` as unbounded. Here `0` removes the limit 
entirely, which is the one setting where this PR gives no protection. The 
`max_tis_per_query` description in config.yml also still only mentions 
scheduling. Could we map `0` to `self._parallelism` here, or at least document 
the purge use and what `0` means for it?



##########
airflow-core/src/airflow/jobs/scheduler_job_runner.py:
##########
@@ -3994,6 +3994,11 @@ def _find_task_instances_without_heartbeats(self, *, 
session: Session) -> list[T
             session.execute(with_row_locks(run_query, of=DagRun, 
session=session, skip_locked=True))
         )
         query = query.where(or_(~regional_expression(TI), tuple_(TI.dag_id, 
TI.run_id).in_(locked_runs)))
+        # Bound the batch (oldest heartbeat first) so a mass heartbeat-loss 
event does not purge every
+        # stale row, each with per-TI work under FOR UPDATE, in one 
scheduler-stalling transaction.
+        query = query.order_by(TI.last_heartbeat_at)

Review Comment:
   Ordering oldest-first gives the `continue` at line 4094 a new consequence. A 
TI that hits it (no `dag_version_id` and no `DagVersion` for its Dag, e.g. an 
adopted Airflow 2 TI whose Dag was never reparsed) stays RUNNING with its old 
heartbeat, so it sorts to the front of every batch. With 16 of those, this 
scheduler never purges anything else. Should that branch fail the TI without 
the callback, the way a RESTARTING TI is already released?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to