This is an automated email from the ASF dual-hosted git repository.

o-nikolas pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 670785d1ce8 Drain result_queue in LocalExecutor to prevent process 
join deadlock (#67881)
670785d1ce8 is described below

commit 670785d1ce8cf24c94c45367234e8598c00b5025
Author: Saksham Kapoor <[email protected]>
AuthorDate: Thu Jul 2 23:17:35 2026 +0530

    Drain result_queue in LocalExecutor to prevent process join deadlock 
(#67881)
    
    If a worker has written enough results to the result_queue to fill the 
OS-level pipe
    buffer (typically 64KB), the worker process blocks indefinitely on its 
put() call.
    Because the parent scheduler process is blocked on the unbounded proc.join()
    and not reading from result_queue, a classic multiprocessing deadlock 
occurs. The
    scheduler hangs indefinitely, stopping heartbeats and preventing
    systemd/Kubernetes from restarting the process (as it never exits).
    Changes
    
    - Draining during Join: Updated the shutdown loop in LocalExecutor.end() to
      continuously drain the result_queue using _read_results() while waiting 
for
      worker processes to join with a small timeout.
    - Robust Queue Reading: Wrapped the queue drainage loop in _read_results()
      with a try/except (OSError, EOFError) block to gracefully handle cases 
where
      pipes are already broken or closed during task exit.
    - Forced Terminate: Implemented the LocalExecutor.terminate() method to
      forcefully kill any remaining workers when a hard stop is requested.
---
 .../src/airflow/executors/local_executor.py        | 57 ++++++++++----
 .../tests/unit/executors/test_local_executor.py    | 89 ++++++++++++++++++++++
 2 files changed, 133 insertions(+), 13 deletions(-)

diff --git a/airflow-core/src/airflow/executors/local_executor.py 
b/airflow-core/src/airflow/executors/local_executor.py
index a4643042409..24ab737f5ab 100644
--- a/airflow-core/src/airflow/executors/local_executor.py
+++ b/airflow-core/src/airflow/executors/local_executor.py
@@ -25,6 +25,7 @@ LocalExecutor.
 
 from __future__ import annotations
 
+import contextlib
 import ctypes
 import multiprocessing
 import multiprocessing.sharedctypes
@@ -243,10 +244,12 @@ class LocalExecutor(BaseExecutor):
         self._check_workers()
 
     def _read_results(self):
-        while not self.result_queue.empty():
-            key, state, exc = self.result_queue.get()
-
-            self.change_state(key, state)
+        try:
+            while not self.result_queue.empty():
+                key, state, exc = self.result_queue.get()
+                self.change_state(key, state)
+        except (OSError, EOFError):
+            self.log.exception("Error reading from result queue")
 
     def end(self) -> None:
         """End the executor."""
@@ -263,19 +266,47 @@ class LocalExecutor(BaseExecutor):
             if proc.is_alive():
                 self.activity_queue.put(None)
 
-        for proc in self.workers.values():
-            if proc.is_alive():
-                proc.join()
-            proc.close()
+        # To prevent deadlock, we should consume results from result_queue 
while waiting for processes to join.
+        # Otherwise, a worker blocked on putting results into a full 
result_queue pipe will never exit,
+        # and an unbounded proc.join() will hang the scheduler indefinitely.
+        try:
+            for proc in self.workers.values():
+                while proc.is_alive():
+                    self._read_results()
+                    proc.join(timeout=0.05)
+        except (KeyboardInterrupt, SystemExit):
+            self.log.error("KeyboardInterrupt received during shutdown. Force 
terminating workers.")
+            for proc in self.workers.values():
+                self._terminate_worker_process(proc)
+            raise
+        finally:
+            # Process any extra results before closing
+            self._read_results()
 
-        # Process any extra results before closing
-        self._read_results()
+            for proc in self.workers.values():
+                with contextlib.suppress(ValueError):
+                    proc.close()
 
-        self.activity_queue.close()
-        self.result_queue.close()
+            self.activity_queue.close()
+            self.result_queue.close()
 
     def terminate(self):
-        """Terminate the executor is not doing anything."""
+        """Terminate all worker processes under control of the executor 
forcefully."""
+        self.log.info("Terminating all LocalExecutor worker processes.")
+        for proc in self.workers.values():
+            self._terminate_worker_process(proc)
+
+    def _terminate_worker_process(self, proc: multiprocessing.Process) -> None:
+        """Terminate a worker process, escalating to kill if it stays alive."""
+        if not proc.is_alive():
+            return
+
+        proc.terminate()
+        proc.join(timeout=0.2)
+        if proc.is_alive():
+            self.log.warning("Worker process %s did not stop after SIGTERM. 
Sending SIGKILL.", proc.pid)
+            proc.kill()
+            proc.join(timeout=0.2)
 
     def _process_workloads(self, workload_list):
         for workload in workload_list:
diff --git a/airflow-core/tests/unit/executors/test_local_executor.py 
b/airflow-core/tests/unit/executors/test_local_executor.py
index 113740bb0c3..c4e3506f795 100644
--- a/airflow-core/tests/unit/executors/test_local_executor.py
+++ b/airflow-core/tests/unit/executors/test_local_executor.py
@@ -35,6 +35,7 @@ from airflow.executors.workloads.base import BundleInfo
 from airflow.executors.workloads.callback import CallbackDTO
 from airflow.executors.workloads.task import TaskInstanceDTO
 from airflow.models.callback import CallbackFetchMethod
+from airflow.models.taskinstancekey import TaskInstanceKey
 from airflow.settings import Session
 from airflow.utils.state import State
 
@@ -90,6 +91,13 @@ def _make_task_workload():
     )
 
 
+def _write_large_results_to_queue(result_queue, result_count, payload_size):
+    payload = RuntimeError("x" * payload_size)
+    for index in range(result_count):
+        key = TaskInstanceKey("test_dag", f"test_task_{index}", "test_run")
+        result_queue.put((key, State.SUCCESS, payload))
+
+
 class TestLocalExecutor:
     """
     When the executor is started, end() must be called before the test 
finishes.
@@ -274,6 +282,87 @@ class TestLocalExecutor:
         finally:
             executor.end()
 
+    def test_end_drains_results_while_joining_workers(self):
+        executor = LocalExecutor(parallelism=1)
+        executor.activity_queue = mock.MagicMock()
+        executor.result_queue = mock.MagicMock()
+        proc = mock.MagicMock(spec=multiprocessing.Process)
+        proc.is_alive.side_effect = [True, True, True, False]
+        executor.workers = {1: proc}
+
+        with mock.patch.object(executor, "_read_results") as mock_read_results:
+            executor.end()
+
+        executor.activity_queue.put.assert_called_once_with(None)
+        assert proc.join.call_args_list == [mock.call(timeout=0.05), 
mock.call(timeout=0.05)]
+        assert mock_read_results.call_count == 3
+        proc.close.assert_called_once()
+        executor.activity_queue.close.assert_called_once()
+        executor.result_queue.close.assert_called_once()
+
+    def test_end_terminates_workers_and_closes_resources_on_interrupt(self):
+        executor = LocalExecutor(parallelism=1)
+        executor.activity_queue = mock.MagicMock()
+        executor.result_queue = mock.MagicMock()
+        proc = mock.MagicMock(spec=multiprocessing.Process)
+        proc.is_alive.side_effect = [True, True]
+        executor.workers = {1: proc}
+
+        with (
+            mock.patch.object(executor, "_read_results", 
side_effect=[KeyboardInterrupt, None]),
+            mock.patch.object(executor, "_terminate_worker_process") as 
mock_terminate_worker_process,
+            pytest.raises(KeyboardInterrupt),
+        ):
+            executor.end()
+
+        mock_terminate_worker_process.assert_called_once_with(proc)
+        proc.close.assert_called_once()
+        executor.activity_queue.close.assert_called_once()
+        executor.result_queue.close.assert_called_once()
+
+    def test_terminate_joins_worker_after_sigterm(self):
+        executor = LocalExecutor(parallelism=1)
+        proc = mock.MagicMock(spec=multiprocessing.Process)
+        proc.is_alive.side_effect = [True, False]
+        executor.workers = {1: proc}
+
+        executor.terminate()
+
+        proc.terminate.assert_called_once_with()
+        proc.join.assert_called_once_with(timeout=0.2)
+        proc.kill.assert_not_called()
+
+    def test_terminate_kills_worker_that_ignores_sigterm(self):
+        executor = LocalExecutor(parallelism=1)
+        proc = mock.MagicMock(spec=multiprocessing.Process)
+        proc.pid = 123
+        proc.is_alive.side_effect = [True, True]
+        executor.workers = {1: proc}
+
+        executor.terminate()
+
+        proc.terminate.assert_called_once_with()
+        proc.kill.assert_called_once_with()
+        assert proc.join.call_args_list == [mock.call(timeout=0.2), 
mock.call(timeout=0.2)]
+
+    @pytest.mark.execution_timeout(10)
+    def test_end_drains_result_queue_to_avoid_join_deadlock(self):
+        executor = LocalExecutor(parallelism=1)
+        executor.activity_queue = multiprocessing.SimpleQueue()
+        executor.result_queue = multiprocessing.SimpleQueue()
+        result_count = 8
+        payload_size = 128 * 1024
+        proc = multiprocessing.Process(
+            target=_write_large_results_to_queue,
+            args=(executor.result_queue, result_count, payload_size),
+        )
+        proc.start()
+        executor.workers = {proc.pid: proc}
+
+        executor.end()
+
+        assert len(executor.event_buffer) == result_count
+
     @pytest.mark.parametrize(
         ("conf_values", "expected_server"),
         [

Reply via email to