Skip to content

Commit 4083290

Browse files
authored
Improve LocalExecutor bookkeeping: correctly add tasks to the running list (#74142)
* Improve LocalExecutor bookkeeping: correctly add tasks to the `running` list Since #73916 landed, BaseExecutor now keeps a UUID-to-coordinates map for each attempt and drops an entry once the key is no longer queued, running or in the event buffer. The issue was that LocalExecutor never added dispatched work to `running`, so the entry was dropped while the task was still executing. The scheduler then received the final event with no coordinates ("Received executor event with state success for task instance <uuid> (coordinates=None)"). This PR fixes that, and addresses a few gotchas that could, in edge cases around callbacks or post-task finalization, lead to a forever-dead/locked worker slot. Holding the key in `running` until the worker finishes needs LocalExecutor to know when that is, and workers share one activity queue so the parent cannot tell which worker took which workload. Results now carry the worker's pid and START tells the parent which pid owns the key. That lets it: - release the slot only on a terminal result from the owning worker, ignoring results from unknown pids or pids that don't own the key - fail the workload when its worker dies, instead of leaving it in `running` until the scheduler's heartbeat timeout A resumed task reuses its key, so a per-key dispatch count stops the old run's terminal result from releasing the new run's slot. * fixup! Improve LocalExecutor bookkeeping: correctly add tasks to the `running` list * fixup! Improve LocalExecutor bookkeeping: correctly add tasks to the `running` list
1 parent a44fd6d commit 4083290

4 files changed

Lines changed: 520 additions & 24 deletions

File tree

‎airflow-core/src/airflow/executors/local_executor.py‎

Lines changed: 64 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838

3939
from airflow.executors.base_executor import BaseExecutor, get_execution_api_server_url
4040
from airflow.executors.workloads import WorkloadType
41+
from airflow.executors.workloads.types import state_class_for_key
4142

4243
# add logger to parameter of setproctitle to support logging
4344
if sys.platform == "darwin":
@@ -49,7 +50,10 @@
4950

5051
if TYPE_CHECKING:
5152
from airflow.executors.workloads import ExecutorWorkload
52-
from airflow.executors.workloads.types import WorkloadResultType
53+
from airflow.executors.workloads.types import WorkloadKey, WorkloadState
54+
from airflow.models.taskinstance import TaskInstance
55+
56+
LocalResult = tuple[int, WorkloadKey, WorkloadState | None, Exception | None]
5357

5458

5559
def _get_executor_process_title_prefix(team_name: str | None) -> str:
@@ -65,7 +69,7 @@ def _get_executor_process_title_prefix(team_name: str | None) -> str:
6569
def _run_worker(
6670
logger_name: str,
6771
input: SimpleQueue[ExecutorWorkload | None],
68-
output: Queue[WorkloadResultType],
72+
output: Queue[LocalResult],
6973
unread_messages: multiprocessing.sharedctypes.Synchronized[int],
7074
team_conf,
7175
):
@@ -98,8 +102,7 @@ def _run_worker(
98102
unread_messages.value -= 1
99103

100104
key = LocalExecutor.get_workload_key(workload)
101-
if workload.running_state is not None:
102-
output.put((key, workload.running_state, None))
105+
output.put((os.getpid(), key, workload.running_state, None))
103106

104107
try:
105108
BaseExecutor.run_workload(
@@ -108,10 +111,10 @@ def _run_worker(
108111
proctitle=f"{_get_executor_process_title_prefix(team_conf.team_name)} {workload.display_name}",
109112
subprocess_logs_to_stdout=True,
110113
)
111-
output.put((key, workload.success_state, None))
114+
output.put((os.getpid(), key, workload.success_state, None))
112115
except Exception as e:
113116
log.exception("Workload execution failed.", workload_type=type(workload).__name__)
114-
output.put((key, workload.failure_state, e))
117+
output.put((os.getpid(), key, workload.failure_state, e))
115118

116119

117120
class LocalExecutor(BaseExecutor):
@@ -136,12 +139,14 @@ class LocalExecutor(BaseExecutor):
136139
)
137140

138141
activity_queue: SimpleQueue[ExecutorWorkload | None]
139-
result_queue: SimpleQueue[WorkloadResultType]
142+
result_queue: SimpleQueue[LocalResult]
140143
workers: dict[int, multiprocessing.Process]
141144
_unread_messages: multiprocessing.sharedctypes.Synchronized[int]
142145

143146
def __init__(self, *args, **kwargs):
144147
super().__init__(*args, **kwargs)
148+
self._worker_tasks: dict[int, WorkloadKey] = {}
149+
self._dispatch_counts: dict[WorkloadKey, int] = {}
145150

146151
# Resolve the start method at instantiation, not at import: the component CLI entry may have
147152
# set it via [<component>]/[core] mp_start_method before the executor is created.
@@ -165,6 +170,8 @@ def start(self) -> None:
165170
self.activity_queue = SimpleQueue()
166171
self.result_queue = SimpleQueue()
167172
self.workers = {}
173+
self._worker_tasks.clear()
174+
self._dispatch_counts.clear()
168175

169176
# Mypy sees this value as `SynchronizedBase[c_uint]`, but that isn't the right runtime type behaviour
170177
# (it looks like an int to python)
@@ -177,10 +184,16 @@ def start(self) -> None:
177184
self._spawn_workers_with_gc_freeze(self.parallelism)
178185

179186
def _check_workers(self):
187+
self._read_results()
180188
# Reap any dead workers
181189
to_remove = set()
182190
for pid, proc in self.workers.items():
183191
if not proc.is_alive():
192+
self._read_results()
193+
# A worker killed between dequeue and START has no entry here; the scheduler's
194+
# stuck-in-queued handling releases that workload through revoke_task.
195+
if (key := self._worker_tasks.pop(pid, None)) is not None:
196+
self._finish_dispatch(key, state_class_for_key(key).FAILED)
184197
to_remove.add(pid)
185198
proc.close()
186199

@@ -246,14 +259,21 @@ def _spawn_workers_with_gc_freeze(self, spawn_number):
246259

247260
def sync(self) -> None:
248261
"""Sync will get called periodically by the heartbeat method."""
249-
self._read_results()
250262
self._check_workers()
251263

252264
def _read_results(self):
253265
try:
254266
while not self.result_queue.empty():
255-
key, state, exc = self.result_queue.get()
256-
self.change_state(key, state)
267+
pid, key, state, exc = self.result_queue.get()
268+
if pid not in self.workers or key not in self.running:
269+
continue
270+
if state is None or state == "running":
271+
self._worker_tasks[pid] = key
272+
if state is not None:
273+
self.change_state(key, state, remove_running=False)
274+
elif self._worker_tasks.get(pid) == key:
275+
del self._worker_tasks[pid]
276+
self._finish_dispatch(key, state)
257277
except (OSError, EOFError):
258278
self.log.exception("Error reading from result queue")
259279

@@ -316,11 +336,44 @@ def _terminate_worker_process(self, proc: multiprocessing.Process) -> None:
316336

317337
def _process_workloads(self, workload_list):
318338
for workload in workload_list:
319-
self.activity_queue.put(workload)
320339
key = self.get_workload_key(workload)
340+
self.activity_queue.put(workload)
321341
removed = self.executor_queues[workload.type].pop(key, None)
322342
if not removed:
323343
raise KeyError(f"Workload {key} was not found in any queue")
344+
self.running.add(key)
345+
self._dispatch_counts[key] = self._dispatch_counts.get(key, 0) + 1
324346
with self._unread_messages:
325347
self._unread_messages.value += len(workload_list)
326348
self._check_workers()
349+
350+
def _finish_dispatch(self, key: WorkloadKey, state: WorkloadState) -> None:
351+
# A resumed attempt reuses its key, so the previous dispatch can finish while the next one is live.
352+
remaining = self._dispatch_counts.pop(key, 1) - 1
353+
if remaining > 0:
354+
self._dispatch_counts[key] = remaining
355+
super().change_state(key, state, remove_running=remaining <= 0)
356+
357+
def _forget_workload(self, key: WorkloadKey) -> None:
358+
self._dispatch_counts.pop(key, None)
359+
self._worker_tasks = {
360+
pid: task_key for pid, task_key in self._worker_tasks.items() if task_key != key
361+
}
362+
363+
def change_state(self, key, state, info=None, remove_running=True) -> None:
364+
if remove_running:
365+
self._forget_workload(key)
366+
super().change_state(key, state, info=info, remove_running=remove_running)
367+
368+
def fail_connection_test(self, key) -> None:
369+
self._forget_workload(key)
370+
super().fail_connection_test(key)
371+
372+
def revoke_task(self, *, ti: TaskInstance) -> None:
373+
key = self.get_task_key(ti)
374+
self.executor_queues[WorkloadType.EXECUTE_TASK].pop(key, None)
375+
for pid, task_key in self._worker_tasks.items():
376+
if task_key == key:
377+
self._terminate_worker_process(self.workers[pid])
378+
self._forget_workload(key)
379+
self.running.discard(key)

0 commit comments

Comments
 (0)