This is an automated email from the ASF dual-hosted git repository.
dheerajturaga 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 9bf9408d07e Edge worker: bound drain with SIGTERM/SIGKILL escalation
(#65838)
9bf9408d07e is described below
commit 9bf9408d07e5c4eadf6bb96d242bb0f852e11606
Author: Dheeraj Turaga <[email protected]>
AuthorDate: Mon Apr 27 07:36:14 2026 -0500
Edge worker: bound drain with SIGTERM/SIGKILL escalation (#65838)
---
providers/edge3/provider.yaml | 19 ++++
.../src/airflow/providers/edge3/cli/worker.py | 123 +++++++++++++++++++--
.../airflow/providers/edge3/get_provider_info.py | 14 +++
.../edge3/tests/unit/edge3/cli/test_worker.py | 63 +++++++++++
4 files changed, 209 insertions(+), 10 deletions(-)
diff --git a/providers/edge3/provider.yaml b/providers/edge3/provider.yaml
index f296400fdad..c160b23b278 100644
--- a/providers/edge3/provider.yaml
+++ b/providers/edge3/provider.yaml
@@ -116,6 +116,25 @@ config:
type: integer
example: "10"
default: "30"
+ drain_timeout_sec:
+ description: |
+ Maximum seconds the worker waits for running jobs to finish after
entering drain
+ (shutdown requested via SIGINT / SIGTERM / SHUTDOWN_REQUEST /
version mismatch).
+ Once this timeout elapses the worker sends SIGTERM to each running
job, then SIGKILL
+ after ``drain_kill_grace_sec``, and exits regardless of remaining
jobs.
+ Set to 0 (the default) to wait indefinitely and preserve legacy
behavior.
+ version_added: 3.6.0
+ type: integer
+ example: "3600"
+ default: "0"
+ drain_kill_grace_sec:
+ description: |
+ Grace period in seconds after SIGTERM before the worker escalates to
SIGKILL and exits.
+ Only used when ``drain_timeout_sec`` is greater than 0.
+ version_added: 3.6.0
+ type: integer
+ example: "30"
+ default: "30"
worker_concurrency:
description: |
The concurrency defines the default max parallel running task
instances and can also be set during
diff --git a/providers/edge3/src/airflow/providers/edge3/cli/worker.py
b/providers/edge3/src/airflow/providers/edge3/cli/worker.py
index f5396127219..799b34aefd4 100644
--- a/providers/edge3/src/airflow/providers/edge3/cli/worker.py
+++ b/providers/edge3/src/airflow/providers/edge3/cli/worker.py
@@ -20,8 +20,9 @@ import logging
import os
import signal
import sys
+import time
import traceback
-from asyncio import Task, create_task, get_running_loop, sleep
+from asyncio import Task, create_task, gather, get_running_loop, sleep
from collections.abc import Awaitable, Callable
from contextlib import suppress
from datetime import datetime
@@ -105,6 +106,10 @@ class EdgeWorker:
"""List of jobs that the worker is running currently."""
drain: bool = False
"""Flag if job processing should be completed and no new jobs fetched for
a graceful stop/shutdown."""
+ drain_started_at: float | None = None
+ """``time.monotonic()`` timestamp of when drain was first requested."""
+ drain_timed_out: bool = False
+ drain_kill_deadline: float | None = None
maintenance_mode: bool = False
"""Flag if job processing should be completed and no new jobs fetched for
maintenance mode. """
maintenance_comments: str | None = None
@@ -142,6 +147,8 @@ class EdgeWorker:
self.job_poll_interval = self.conf.getint("edge", "job_poll_interval")
self.hb_interval = self.conf.getint("edge", "heartbeat_interval")
+ self.drain_timeout_sec = self.conf.getint("edge", "drain_timeout_sec")
+ self.drain_kill_grace_sec = self.conf.getint("edge",
"drain_kill_grace_sec")
self.base_log_folder: str = (
self.conf.get("logging", "base_log_folder", fallback="NOT
AVAILABLE") or ""
)
@@ -210,18 +217,112 @@ class EdgeWorker:
)
def signal_drain(self):
- self.drain = True
- logger.info("Request to shut down Edge Worker received, waiting for
jobs to complete.")
+ if self._start_draining():
+ logger.info(
+ "Request to shut down Edge Worker received, waiting for jobs
to complete. %s",
+ self._drain_policy_description(),
+ )
def shutdown_handler(self):
- if self.drain:
+ if not self._start_draining():
return
- self.drain = True
logger.info("SIGTERM received. Sending SIGTERM to all jobs and quit")
+ try:
+ loop = get_running_loop()
+ except RuntimeError:
+ pass
+ else:
+ task = loop.create_task(
+ self._push_drain_notice_to_all_jobs(
+ "Edge worker received external SIGTERM; terminating task
supervisor."
+ )
+ )
+ self.background_tasks.add(task)
+ task.add_done_callback(self.background_tasks.discard)
+ self._terminate_jobs(signal.SIGTERM)
+
+ def _start_draining(self) -> bool:
+ """Mark drain start. Returns ``True`` on the first call, ``False`` on
subsequent calls."""
+ if self.drain:
+ return False
+ self.drain = True
+ self.drain_started_at = time.monotonic()
+ return True
+
+ def _drain_policy_description(self) -> str:
+ """One-line description of the configured drain-timeout policy for
logging."""
+ if self.drain_timeout_sec <= 0:
+ return "Drain timeout disabled; will wait indefinitely."
+ return (
+ f"Drain timeout: {self.drain_timeout_sec}s (then SIGTERM), "
+ f"kill grace: {self.drain_kill_grace_sec}s (then SIGKILL)."
+ )
+
+ def _terminate_jobs(self, sig: int) -> None:
+ """Send ``sig`` to every running job process. Safe to call
repeatedly."""
for job in self.jobs:
if job.process.pid:
with suppress(ProcessLookupError, PermissionError):
- os.kill(job.process.pid, signal.SIGTERM)
+ os.kill(job.process.pid, sig)
+
+ async def _push_drain_notice_to_all_jobs(self, message: str) -> None:
+ """Best-effort push of ``message`` into each running job's task log
stream."""
+
+ async def push_one(job: Job) -> None:
+ try:
+ await logs_push(
+ task=job.edge_job.key,
+ log_chunk_time=timezone.utcnow(),
+ log_chunk_data=f"{message}\n",
+ )
+ except Exception:
+ logger.exception("Failed to push drain notice to task log for
%s", job.edge_job.identifier)
+
+ await gather(*(push_one(job) for job in list(self.jobs)))
+
+ async def _enforce_drain_timeout(self) -> bool:
+ """
+ Apply drain-timeout policy when configured.
+
+ Two-phase escalation: once ``drain_timeout_sec`` elapses, SIGTERM
remaining jobs;
+ after ``drain_kill_grace_sec`` more, SIGKILL and return ``True`` so
the loop exits.
+ Returns ``False`` otherwise (not configured, not draining, deadline
not hit, or waiting out grace).
+ """
+ if self.drain_timeout_sec <= 0:
+ return False
+ if not self.drain or not self.jobs or self.drain_started_at is None:
+ return False
+ now = time.monotonic()
+ if now - self.drain_started_at < self.drain_timeout_sec:
+ return False
+ if not self.drain_timed_out:
+ self.drain_timed_out = True
+ self.drain_kill_deadline = now + self.drain_kill_grace_sec
+ logger.warning(
+ "Drain timeout of %ds exceeded with %d job(s) still running.
Sending SIGTERM.",
+ self.drain_timeout_sec,
+ len(self.jobs),
+ )
+ await self._push_drain_notice_to_all_jobs(
+ f"Edge worker drain timeout of {self.drain_timeout_sec}s
expired; "
+ f"sending SIGTERM to task supervisor. "
+ f"Will escalate to SIGKILL after {self.drain_kill_grace_sec}s
grace."
+ )
+ self._terminate_jobs(signal.SIGTERM)
+ return False
+ if self.drain_kill_deadline is not None and now >=
self.drain_kill_deadline:
+ logger.warning(
+ "Drain kill grace of %ds exceeded with %d job(s) still
running. Sending SIGKILL and exiting.",
+ self.drain_kill_grace_sec,
+ len(self.jobs),
+ )
+ await self._push_drain_notice_to_all_jobs(
+ f"Edge worker drain kill-grace of {self.drain_kill_grace_sec}s
expired; "
+ f"sending SIGKILL and exiting worker."
+ )
+ self._terminate_jobs(signal.SIGKILL)
+ return True
+ return False
async def _get_sysinfo(self) -> dict[str, str | int | float | datetime]:
"""Produce the sysinfo from worker to post to central site."""
@@ -406,6 +507,9 @@ class EdgeWorker:
else:
logger.info("%i %s running", len(self.jobs), "job is" if
len(self.jobs) == 1 else "jobs are")
+ if await self._enforce_drain_timeout():
+ break
+
await self.interruptible_sleep()
async def fetch_and_run_job(self) -> None:
@@ -459,7 +563,7 @@ class EdgeWorker:
await logs_push(
task=job.edge_job.key,
log_chunk_time=timezone.utcnow(),
- log_chunk_data=f"Error starting job:\n{ex_txt}",
+ log_chunk_data=f"Error executing job:\n{ex_txt}",
)
await jobs_set_state(job.edge_job.key, TaskInstanceState.FAILED)
@@ -498,14 +602,13 @@ class EdgeWorker:
self.maintenance_comments = worker_info.maintenance_comments
else:
self.maintenance_comments = None
- if worker_info.state == EdgeWorkerState.SHUTDOWN_REQUEST:
+ if worker_info.state == EdgeWorkerState.SHUTDOWN_REQUEST and
self._start_draining():
logger.info("Shutdown requested!")
- self.drain = True
worker_state_changed = worker_info.state != state
except EdgeWorkerVersionException:
logger.info("Version mismatch of Edge worker and Core. Shutting
down worker.")
- self.drain = True
+ self._start_draining()
return worker_state_changed
async def interruptible_sleep(self):
diff --git a/providers/edge3/src/airflow/providers/edge3/get_provider_info.py
b/providers/edge3/src/airflow/providers/edge3/get_provider_info.py
index 441cee6b65a..7e0085982b2 100644
--- a/providers/edge3/src/airflow/providers/edge3/get_provider_info.py
+++ b/providers/edge3/src/airflow/providers/edge3/get_provider_info.py
@@ -67,6 +67,20 @@ def get_provider_info():
"example": "10",
"default": "30",
},
+ "drain_timeout_sec": {
+ "description": "Maximum seconds the worker waits for
running jobs to finish after entering drain\n(shutdown requested via SIGINT /
SIGTERM / SHUTDOWN_REQUEST / version mismatch).\nOnce this timeout elapses the
worker sends SIGTERM to each running job, then SIGKILL\nafter
``drain_kill_grace_sec``, and exits regardless of remaining jobs.\nSet to 0
(the default) to wait indefinitely and preserve legacy behavior.\n",
+ "version_added": "3.6.0",
+ "type": "integer",
+ "example": "3600",
+ "default": "0",
+ },
+ "drain_kill_grace_sec": {
+ "description": "Grace period in seconds after SIGTERM
before the worker escalates to SIGKILL and exits.\nOnly used when
``drain_timeout_sec`` is greater than 0.\n",
+ "version_added": "3.6.0",
+ "type": "integer",
+ "example": "30",
+ "default": "30",
+ },
"worker_concurrency": {
"description": "The concurrency defines the default
max parallel running task instances and can also be set during\nstart of worker
with the ``airflow edge worker`` command parameter. The size of the
workers\nand the resources must support the nature of your tasks. The
parameter\nworks together with the concurrency_slots parameter of a task.\n",
"version_added": None,
diff --git a/providers/edge3/tests/unit/edge3/cli/test_worker.py
b/providers/edge3/tests/unit/edge3/cli/test_worker.py
index 62565cdf98c..be33ea68d36 100644
--- a/providers/edge3/tests/unit/edge3/cli/test_worker.py
+++ b/providers/edge3/tests/unit/edge3/cli/test_worker.py
@@ -464,6 +464,69 @@ class TestEdgeWorker:
await worker_with_job.heartbeat()
assert worker_with_job.drain
+ @patch("airflow.providers.edge3.cli.worker.os.kill")
+ @patch("airflow.providers.edge3.cli.worker.time.monotonic")
+ async def test_enforce_drain_timeout_disabled(
+ self, mock_monotonic, mock_kill, worker_with_job: EdgeWorker
+ ):
+ worker_with_job.drain_timeout_sec = 0
+ worker_with_job.drain = True
+ worker_with_job.drain_started_at = 0.0
+ mock_monotonic.return_value = 9999.0
+ assert await worker_with_job._enforce_drain_timeout() is False
+ mock_kill.assert_not_called()
+
+ @patch("airflow.providers.edge3.cli.worker.os.kill")
+ @patch("airflow.providers.edge3.cli.worker.time.monotonic")
+ async def test_enforce_drain_timeout_within_deadline(
+ self, mock_monotonic, mock_kill, worker_with_job: EdgeWorker
+ ):
+ worker_with_job.drain_timeout_sec = 60
+ worker_with_job.drain = True
+ worker_with_job.drain_started_at = 100.0
+ worker_with_job.jobs[0].process = mock.MagicMock(pid=4242)
+ mock_monotonic.return_value = 120.0 # 20s < 60s
+ assert await worker_with_job._enforce_drain_timeout() is False
+ mock_kill.assert_not_called()
+
+ @patch("airflow.providers.edge3.cli.worker.logs_push")
+ @patch("airflow.providers.edge3.cli.worker.os.kill")
+ @patch("airflow.providers.edge3.cli.worker.time.monotonic")
+ async def test_enforce_drain_timeout_sigterm_on_deadline(
+ self, mock_monotonic, mock_kill, mock_logs_push, worker_with_job:
EdgeWorker
+ ):
+ worker_with_job.drain_timeout_sec = 60
+ worker_with_job.drain_kill_grace_sec = 30
+ worker_with_job.drain = True
+ worker_with_job.drain_started_at = 100.0
+ worker_with_job.jobs[0].process = mock.MagicMock(pid=4242)
+ mock_monotonic.return_value = 170.0 # 70s >= 60s, first trigger
+ assert await worker_with_job._enforce_drain_timeout() is False
+ mock_kill.assert_called_once_with(4242, signal.SIGTERM)
+ assert worker_with_job.drain_timed_out is True
+ assert worker_with_job.drain_kill_deadline == 200.0
+ mock_logs_push.assert_called_once()
+ assert "SIGTERM" in mock_logs_push.call_args.kwargs["log_chunk_data"]
+
+ @patch("airflow.providers.edge3.cli.worker.logs_push")
+ @patch("airflow.providers.edge3.cli.worker.os.kill")
+ @patch("airflow.providers.edge3.cli.worker.time.monotonic")
+ async def test_enforce_drain_timeout_sigkill_after_grace(
+ self, mock_monotonic, mock_kill, mock_logs_push, worker_with_job:
EdgeWorker
+ ):
+ worker_with_job.drain_timeout_sec = 60
+ worker_with_job.drain_kill_grace_sec = 30
+ worker_with_job.drain = True
+ worker_with_job.drain_started_at = 100.0
+ worker_with_job.drain_timed_out = True
+ worker_with_job.drain_kill_deadline = 200.0
+ worker_with_job.jobs[0].process = mock.MagicMock(pid=4242)
+ mock_monotonic.return_value = 210.0 # past kill deadline
+ assert await worker_with_job._enforce_drain_timeout() is True
+ mock_kill.assert_called_once_with(4242, signal.SIGKILL)
+ mock_logs_push.assert_called_once()
+ assert "SIGKILL" in mock_logs_push.call_args.kwargs["log_chunk_data"]
+
@pytest.mark.parametrize(
"http_error",
[