This is an automated email from the ASF dual-hosted git repository.
vatsrahul1001 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 b00cfeb11f0 Drain result_queue before dispatch in LocalExecutor to
prevent runtime dispatch deadlock (#70545)
b00cfeb11f0 is described below
commit b00cfeb11f0f7f45f03317a57cb4364f33c09f49
Author: Saksham Kapoor <[email protected]>
AuthorDate: Sun Oct 18 02:15:25 2026 -0400
Drain result_queue before dispatch in LocalExecutor to prevent runtime
dispatch deadlock (#70545)
Fixes the runtime/dispatch-path deadlock reported in apache/airflow#70526:
during
_process_workloads the scheduler could block in activity_queue.put() while
workers
were blocked writing results into a full result_queue pipe, a circular
wait. Draining
the result queue before each put breaks the cycle. The end()/join path is
already
handled by #67881; this covers normal dispatch. Adds a call-order unit test
and a
bounded real-queue regression test.
---
.../src/airflow/executors/local_executor.py | 1 +
.../tests/unit/executors/test_local_executor.py | 80 ++++++++++++++++++++++
2 files changed, 81 insertions(+)
diff --git a/airflow-core/src/airflow/executors/local_executor.py
b/airflow-core/src/airflow/executors/local_executor.py
index 9e1f1f8bbf8..aebadbf66ad 100644
--- a/airflow-core/src/airflow/executors/local_executor.py
+++ b/airflow-core/src/airflow/executors/local_executor.py
@@ -337,6 +337,7 @@ class LocalExecutor(BaseExecutor):
def _process_workloads(self, workload_list):
for workload in workload_list:
key = self.get_workload_key(workload)
+ self._read_results()
self.activity_queue.put(workload)
removed = self.executor_queues[workload.type].pop(key, None)
if not removed:
diff --git a/airflow-core/tests/unit/executors/test_local_executor.py
b/airflow-core/tests/unit/executors/test_local_executor.py
index 9f5d196d6cd..0b4916c1390 100644
--- a/airflow-core/tests/unit/executors/test_local_executor.py
+++ b/airflow-core/tests/unit/executors/test_local_executor.py
@@ -21,6 +21,7 @@ import gc
import multiprocessing
import os
import signal
+import threading
import time
from pathlib import Path
from unittest import mock
@@ -106,6 +107,17 @@ def _write_large_results_to_queue(result_queue,
activity_queue, unread_messages,
result_queue.put((os.getpid(), key, State.SUCCESS, payload))
+def _refill_worker(activity_queue, result_queue, barrier, count, payload_size):
+ """Stand-in worker: consume activity items and emit large results over
real pipes."""
+ payload = b"x" * payload_size
+ barrier.wait()
+ for _ in range(count):
+ item = activity_queue.get()
+ if item is None:
+ return
+ result_queue.put((os.getpid(), None, None, payload))
+
+
def _make_workload(kind):
if kind == "task":
return _make_task_workload()
@@ -443,6 +455,74 @@ class TestLocalExecutor:
assert not executor._worker_tasks
assert executor._unread_messages.value == 0
+ @pytest.mark.parametrize("num_workloads", [1, 3])
+ def test_process_workloads_drains_results_before_put(self, num_workloads):
+ """#70545: _process_workloads must drain the result queue before each
activity_queue.put."""
+ executor = LocalExecutor(parallelism=1)
+ executor.activity_queue = mock.MagicMock()
+ executor._read_results = mock.MagicMock()
+ executor._unread_messages = multiprocessing.Value("I", 0)
+ executor._check_workers = mock.MagicMock()
+
+ call_order = []
+ executor._read_results.side_effect = lambda:
call_order.append("_read_results")
+ executor.activity_queue.put.side_effect = lambda *args, **kwargs:
call_order.append("put")
+
+ workloads_list = []
+ for _ in range(num_workloads):
+ workload = _make_task_workload()
+ key = executor.get_workload_key(workload)
+ executor.executor_queues.setdefault(workload.type, {})[key] =
workload
+ workloads_list.append(workload)
+
+ executor._process_workloads(workloads_list)
+
+ assert call_order == ["_read_results", "put"] * num_workloads
+
+ def test_process_workloads_progresses_under_concurrent_result_refill(self,
mocker):
+ """#70545: with real queues, _process_workloads must keep draining so
a concurrently
+ refilling worker cannot deadlock the dispatch loop via a full
result_queue pipe."""
+ ctx = multiprocessing.get_context("fork")
+ executor = LocalExecutor(parallelism=1)
+ executor.activity_queue = ctx.SimpleQueue()
+ executor.result_queue = ctx.SimpleQueue()
+ executor._unread_messages = multiprocessing.Value("I", 0)
+ executor._worker_tasks = {}
+ executor._dispatch_counts = {}
+ mocker.patch.object(executor, "_check_workers", autospec=True)
+
+ count = 200
+ submitted = [_make_task_workload() for _ in range(count)]
+ for workload in submitted:
+ executor.executor_queues.setdefault(workload.type,
{})[executor.get_workload_key(workload)] = (
+ workload
+ )
+
+ barrier = ctx.Barrier(2)
+ proc = ctx.Process(
+ target=_refill_worker,
+ args=(executor.activity_queue, executor.result_queue, barrier,
count, 16 * 1024),
+ )
+ proc.start()
+ executor.workers = {proc.pid: proc}
+ barrier.wait(timeout=30)
+
+ completed = threading.Event()
+
+ def _run():
+ try:
+ executor._process_workloads(submitted)
+ finally:
+ completed.set()
+
+ runner = threading.Thread(target=_run, daemon=True)
+ runner.start()
+ finished = completed.wait(timeout=30)
+ proc.terminate()
+ proc.join(timeout=5)
+
+ assert finished, "dispatch did not complete within 30s (result-queue
refill deadlock)"
+
@pytest.mark.parametrize(
("conf_values", "expected_server"),
[