This is an automated email from the ASF dual-hosted git repository.
jscheffl 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 466eb1c0ffb Fix deadlock and add unit test (#66144)
466eb1c0ffb is described below
commit 466eb1c0ffb8374aeabb4d733db74a675ea11406
Author: AutomationDev85 <[email protected]>
AuthorDate: Thu Apr 30 22:07:31 2026 +0200
Fix deadlock and add unit test (#66144)
Co-authored-by: AutomationDev85 <AutomationDev85>
---
.../src/airflow/providers/edge3/cli/worker.py | 22 ++++---
.../edge3/tests/unit/edge3/cli/test_worker.py | 70 +++++++++++++++++++++-
2 files changed, 83 insertions(+), 9 deletions(-)
diff --git a/providers/edge3/src/airflow/providers/edge3/cli/worker.py
b/providers/edge3/src/airflow/providers/edge3/cli/worker.py
index 799b34aefd4..c47c2063aad 100644
--- a/providers/edge3/src/airflow/providers/edge3/cli/worker.py
+++ b/providers/edge3/src/airflow/providers/edge3/cli/worker.py
@@ -388,6 +388,7 @@ class EdgeWorker:
server=self._execution_api_server_url,
log_path=workload.log_path,
)
+ results_queue.put("OK")
return 0
except Exception as e:
logger.exception("Task execution failed")
@@ -540,30 +541,35 @@ class EdgeWorker:
self.background_tasks.add(task)
task.add_done_callback(self.background_tasks.discard)
- while job.is_running:
+ while job.is_running and results_queue.empty():
await self._push_logs_in_chunks(job)
for _ in range(0, self.job_poll_interval * 10):
await sleep(0.1)
if not job.is_running:
break
await self._push_logs_in_chunks(job)
+ supervisor_msg = (
+ "(Unknown error, no exception details available)"
+ if results_queue.empty()
+ else results_queue.get()
+ )
+ # Ensure that supervisor really ended after we grabbed results from
queue
+ while job.is_running:
+ await sleep(0.1)
self.jobs.remove(job)
if job.is_success:
logger.info("Job completed: %s", job.edge_job.identifier)
await jobs_set_state(job.edge_job.key, TaskInstanceState.SUCCESS)
else:
- if results_queue.empty():
- ex_txt = "(Unknown error, no exception details available)"
- else:
- ex = results_queue.get()
- ex_txt = "\n".join(traceback.format_exception(ex))
- logger.error("Job failed: %s with:\n%s", job.edge_job.identifier,
ex_txt)
+ if isinstance(supervisor_msg, Exception):
+ supervisor_msg =
"\n".join(traceback.format_exception(supervisor_msg))
+ logger.error("Job failed: %s with:\n%s", job.edge_job.identifier,
supervisor_msg)
# Push it upwards to logs for better diagnostic as well
await logs_push(
task=job.edge_job.key,
log_chunk_time=timezone.utcnow(),
- log_chunk_data=f"Error executing job:\n{ex_txt}",
+ log_chunk_data=f"Error executing job:\n{supervisor_msg}",
)
await jobs_set_state(job.edge_job.key, TaskInstanceState.FAILED)
diff --git a/providers/edge3/tests/unit/edge3/cli/test_worker.py
b/providers/edge3/tests/unit/edge3/cli/test_worker.py
index be33ea68d36..d056d3af1e1 100644
--- a/providers/edge3/tests/unit/edge3/cli/test_worker.py
+++ b/providers/edge3/tests/unit/edge3/cli/test_worker.py
@@ -16,6 +16,7 @@
# under the License.
from __future__ import annotations
+import asyncio
import contextlib
import importlib
import json
@@ -81,6 +82,7 @@ MOCK_COMMAND = {
class _MockProcess(Process):
def __init__(self, returncode=None):
self.generated_returncode = None
+ self._is_alive = False
def poll(self):
pass
@@ -89,6 +91,18 @@ class _MockProcess(Process):
def returncode(self):
return self.generated_returncode
+ def is_alive(self):
+ return self._is_alive
+
+ def terminate(self):
+ self._is_alive = False
+
+ def kill(self):
+ self._is_alive = False
+
+ def join(self, timeout=None):
+ pass
+
class TestEdgeWorker:
@pytest.fixture(autouse=True)
@@ -219,7 +233,7 @@ class TestEdgeWorker:
result = worker_with_job._run_job_via_supervisor(edge_job.command, q)
assert result == 0
- q.put.assert_not_called()
+ q.put.assert_called_once()
@patch("airflow.sdk.execution_time.supervisor.supervise")
@pytest.mark.asyncio
@@ -296,6 +310,60 @@ class TestEdgeWorker:
assert len(worker_with_job.jobs) == 1 # no new job added (was removed
at the end...)
mock_logs_push.assert_not_called()
+ @patch("airflow.sdk.execution_time.supervisor.supervise")
+ @patch("airflow.providers.edge3.cli.worker.jobs_fetch")
+ @patch("airflow.providers.edge3.cli.worker.jobs_set_state")
+
@patch("airflow.providers.edge3.cli.worker.EdgeWorker._push_logs_in_chunks")
+ @patch("airflow.providers.edge3.cli.worker.logs_push")
+ @pytest.mark.asyncio
+ async def test_fetch_and_run_job_possible_deadlock(
+ self,
+ mock_logs_push,
+ mock_push_log_chunks,
+ mock_jobs_set_state,
+ mock_jobs_fetch,
+ mock_supervise,
+ worker_with_job: EdgeWorker,
+ ):
+ """Verify that a large exception from the subprocess does not deadlock
fetch_and_run_job."""
+
+ large_exception = Exception(f"Task execution failed with large error
message {'-' * 66000}")
+ mock_supervise.side_effect = large_exception
+
+ mock_jobs_fetch.side_effect = [
+ EdgeJobFetched(
+ dag_id="test",
+ task_id="test",
+ run_id="test",
+ map_index=-1,
+ try_number=1,
+ concurrency_slots=1,
+ command=MOCK_COMMAND, # type: ignore[arg-type]
+ ),
+ None,
+ ]
+ worker_with_job.__dict__["_execution_api_server_url"] =
"https://mock-server/execution"
+ worker_with_job.concurrency = 1 # only one job at a time
+ assert worker_with_job.free_concurrency == 0
+
+ try:
+ await asyncio.wait_for(worker_with_job.fetch_and_run_job(),
timeout=10.0)
+ except asyncio.TimeoutError:
+ # Clean up any hanging subprocess to prevent blocking pytest
+ for job in list(worker_with_job.jobs):
+ if job.process.is_alive():
+ job.process.terminate()
+ job.process.join(timeout=1.0)
+ if job.process.is_alive():
+ job.process.kill()
+ job.process.join()
+ pytest.fail("fetch_and_run_job timed out after 10s - DEADLOCK
DETECTED. ")
+
+ # If we reach here without timeout, the deadlock was not triggered
+ assert mock_jobs_set_state.call_count >= 1
+ mock_push_log_chunks.assert_called()
+ assert len(worker_with_job.jobs) <= 1 # new job removed, original
fixture job still there
+
@patch("airflow.providers.edge3.cli.worker.jobs_fetch")
@patch("airflow.providers.edge3.cli.worker.EdgeWorker._launch_job",
return_value=(Process(), Queue()))
@patch("airflow.providers.edge3.cli.worker.jobs_set_state")