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

Miretpl 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 b8cb62a651f Warm-shutdown supervisor on SIGTERM instead of killing the 
running task (#69034)
b8cb62a651f is described below

commit b8cb62a651ff6438f4d024a650f1118203c163c6
Author: Arkadiusz Bach <[email protected]>
AuthorDate: Wed Jul 1 19:47:05 2026 +0200

    Warm-shutdown supervisor on SIGTERM instead of killing the running task 
(#69034)
---
 chart/files/pod-template-file.kubernetes-helm-yaml |  10 ++
 chart/newsfragments/69034.significant.rst          |   7 ++
 .../airflow_aux/test_pod_template_file.py          |  11 ++
 docker-stack-docs/entrypoint.rst                   |   9 ++
 .../src/airflow/sdk/execution_time/coordinator.py  |  68 ++++++++--
 .../src/airflow/sdk/execution_time/supervisor.py   |  21 ----
 .../tests/task_sdk/dags/signal_forward_test.py     |  44 -------
 .../task_sdk/execution_time/test_coordinator.py    | 137 +++++++++++++++++++++
 .../task_sdk/execution_time/test_supervisor.py     |  49 +-------
 9 files changed, 231 insertions(+), 125 deletions(-)

diff --git a/chart/files/pod-template-file.kubernetes-helm-yaml 
b/chart/files/pod-template-file.kubernetes-helm-yaml
index a7c779d9635..76d7316ac15 100644
--- a/chart/files/pod-template-file.kubernetes-helm-yaml
+++ b/chart/files/pod-template-file.kubernetes-helm-yaml
@@ -106,6 +106,16 @@ spec:
       env:
         - name: AIRFLOW__CORE__EXECUTOR
           value: {{ .Values.executor | quote }}
+        # Deliver pod-termination signals only to the task supervisor 
(dumb-init's
+        # direct child) instead of broadcasting them to the whole process 
group.
+        # On graceful pod shutdown the supervisor's warm-shutdown handler then 
lets
+        # the running task finish -- the same mechanism Celery workers use -- 
rather
+        # than the task subprocess being killed directly by dumb-init's 
group-wide
+        # SIGTERM (which could also reach the subprocess before it installs 
its own
+        # signal handler). Hard kills (heartbeat loss / overtime / the 
post-grace
+        # SIGKILL) are unaffected.
+        - name: DUMB_INIT_SETSID
+          value: "0"
         {{- if or .Values.workers.kubernetes.kerberosSidecar.enabled 
.Values.workers.kubernetes.kerberosInitContainer.enabled }}
         - name: KRB5_CONFIG
           value:  {{ .Values.kerberos.configPath | quote }}
diff --git a/chart/newsfragments/69034.significant.rst 
b/chart/newsfragments/69034.significant.rst
new file mode 100644
index 00000000000..800d4c8d68c
--- /dev/null
+++ b/chart/newsfragments/69034.significant.rst
@@ -0,0 +1,7 @@
+Default ``DUMB_INIT_SETSID`` changed to ``"0"`` for KubernetesExecutor task 
pods.
+
+Pod-termination signals (e.g. SIGTERM on graceful shutdown) are now delivered 
only to the
+task supervisor (``dumb-init``'s direct child) instead of being broadcast to 
the whole
+process group. This lets a running task finish via the supervisor's 
warm-shutdown handler --
+the same behaviour Celery worker pods already had -- rather than the task 
subprocess being
+killed directly. Hard kills (heartbeat loss / overtime / the post-grace 
SIGKILL) are unaffected.
diff --git a/chart/tests/helm_tests/airflow_aux/test_pod_template_file.py 
b/chart/tests/helm_tests/airflow_aux/test_pod_template_file.py
index 95ea707f0a4..34d03fe7762 100644
--- a/chart/tests/helm_tests/airflow_aux/test_pod_template_file.py
+++ b/chart/tests/helm_tests/airflow_aux/test_pod_template_file.py
@@ -1111,6 +1111,17 @@ class TestPodTemplateFile:
             "valueFrom": {"configMapKeyRef": {"name": "my-config-map", "key": 
"my-key"}},
         } in jmespath.search("spec.containers[0].env", docs[0])
 
+    def test_should_set_dumb_init_setsid_for_warm_shutdown(self):
+        """Pod-termination signals must reach only the supervisor so a running 
task can warm-shut-down."""
+        docs = render_chart(
+            show_only=["templates/pod-template-file.yaml"],
+            chart_dir=self.temp_chart_dir,
+        )
+
+        assert {"name": "DUMB_INIT_SETSID", "value": "0"} in jmespath.search(
+            "spec.containers[0].env", docs[0]
+        )
+
     def test_should_add_component_specific_labels(self):
         docs = render_chart(
             values={
diff --git a/docker-stack-docs/entrypoint.rst b/docker-stack-docs/entrypoint.rst
index c2790039c81..04a9a9861d7 100644
--- a/docker-stack-docs/entrypoint.rst
+++ b/docker-stack-docs/entrypoint.rst
@@ -300,6 +300,15 @@ The table below summarizes ``DUMB_INIT_SETSID`` possible 
values and their use ca
 |                | If you are running it through ``["bash", "-c"]`` command,   
         |
 |                | you  need to start the worker via ``exec airflow celery 
worker``     |
 |                | as the last command executed.                               
         |
+|                |                                                             
         |
+|                | The same applies to KubernetesExecutor task pods. Here 
``dumb-init`` |
+|                | runs as the init process and its direct child is the task   
         |
+|                | *supervisor*, which supervises a single task subprocess. 
Setting the |
+|                | variable to 0 propagates a graceful SIGTERM only to the 
supervisor,  |
+|                | which then performs a warm shutdown and waits for the 
running task   |
+|                | to finish, instead of the signal being broadcast to the 
whole        |
+|                | process group and killing the task subprocess directly. The 
Airflow  |
+|                | Helm chart sets this on the KubernetesExecutor pod template 
for you. |
 
+----------------+----------------------------------------------------------------------+
 
 Additional quick test options
diff --git a/task-sdk/src/airflow/sdk/execution_time/coordinator.py 
b/task-sdk/src/airflow/sdk/execution_time/coordinator.py
index 55d1654a7fe..1469b27888c 100644
--- a/task-sdk/src/airflow/sdk/execution_time/coordinator.py
+++ b/task-sdk/src/airflow/sdk/execution_time/coordinator.py
@@ -40,6 +40,8 @@ from __future__ import annotations
 
 import contextlib
 import functools
+import os
+import signal
 from typing import TYPE_CHECKING, Any
 
 import attrs
@@ -50,7 +52,7 @@ from airflow.sdk._shared.module_loading import import_string
 from airflow.sdk.configuration import conf
 
 if TYPE_CHECKING:
-    from collections.abc import Mapping
+    from collections.abc import Generator, Mapping
     from os import PathLike
 
     from structlog.typing import FilteringBoundLogger
@@ -114,6 +116,43 @@ class _CoordinatorSpec(pydantic.BaseModel):
     extra: dict[str, Any] | None = None
 
 
[email protected]
+def _warm_shutdown_signals() -> Generator[None, None, None]:
+    """
+    Install SIGTERM/SIGINT warm-shutdown handlers for the duration of task 
supervision.
+
+    While supervising a task the supervisor must not be torn down by a
+    termination signal; instead it keeps running so the task can finish (or be
+    shut down gracefully) and its terminal state and logs are reported. The
+    handlers are installed around BOTH ``start()`` (which transitions the TI to
+    RUNNING) and ``wait()`` (which runs the task and then reports the terminal
+    state / uploads logs), so there is no window where Python's default SIGTERM
+    disposition could kill the supervisor and tear the just-started task down
+    with it.
+
+    The previous dispositions are restored on exit so a long-lived supervisor
+    process (e.g. a reused Celery prefork worker) does not leak the handler 
into
+    later tasks or clobber the worker's own signal handling.
+    """
+
+    def _warm_shutdown(signum, frame):
+        log.info(
+            "Received signal; warm shutdown in progress, waiting for the 
running task to complete.",
+            signal=signal.Signals(signum).name,
+            pid=os.getpid(),
+        )
+
+    prev_sigterm = signal.getsignal(signal.SIGTERM)
+    prev_sigint = signal.getsignal(signal.SIGINT)
+    signal.signal(signal.SIGTERM, _warm_shutdown)
+    signal.signal(signal.SIGINT, _warm_shutdown)
+    try:
+        yield
+    finally:
+        signal.signal(signal.SIGTERM, prev_sigterm)
+        signal.signal(signal.SIGINT, prev_sigint)
+
+
 class _PythonCoordinator(BaseCoordinator):
     """
     Coordinator implementation to execute Python tasks.
@@ -140,17 +179,22 @@ class _PythonCoordinator(BaseCoordinator):
         # process handling.
         from airflow.sdk.execution_time.supervisor import ActivitySubprocess
 
-        process = ActivitySubprocess.start(
-            dag_rel_path=dag_rel_path,
-            what=what,
-            client=client,
-            logger=logger,
-            bundle_info=bundle_info,
-            subprocess_logs_to_stdout=subprocess_logs_to_stdout,
-            sentry_integration=sentry_integration,
-        )
-        exit_code = process.wait()
-        return self.ExecutionResult(exit_code, process.final_state)
+        # Keep the warm-shutdown handlers installed across both start() (which
+        # transitions the TI to RUNNING) and wait() (which runs the task and
+        # reports its terminal state / uploads logs) so a SIGTERM at any point
+        # in this window can't kill the supervisor and tear the task down.
+        with _warm_shutdown_signals():
+            process = ActivitySubprocess.start(
+                dag_rel_path=dag_rel_path,
+                what=what,
+                client=client,
+                logger=logger,
+                bundle_info=bundle_info,
+                subprocess_logs_to_stdout=subprocess_logs_to_stdout,
+                sentry_integration=sentry_integration,
+            )
+            exit_code = process.wait()
+            return self.ExecutionResult(exit_code, process.final_state)
 
 
 @functools.cache
diff --git a/task-sdk/src/airflow/sdk/execution_time/supervisor.py 
b/task-sdk/src/airflow/sdk/execution_time/supervisor.py
index c533a9160f4..5a5e3852fd7 100644
--- a/task-sdk/src/airflow/sdk/execution_time/supervisor.py
+++ b/task-sdk/src/airflow/sdk/execution_time/supervisor.py
@@ -1421,31 +1421,10 @@ class ActivitySubprocess(WatchedSubprocess):
         if self._exit_code is not None:
             return self._exit_code
 
-        # Forward termination signals to the task subprocess so the operator's
-        # on_kill() hook runs on graceful shutdown (e.g. K8s pod SIGTERM).
-        # Without this the supervisor exits on SIGTERM without notifying the
-        # child, leaving spawned resources (pods, subprocesses, etc.) running.
-        prev_sigterm = signal.getsignal(signal.SIGTERM)
-        prev_sigint = signal.getsignal(signal.SIGINT)
-
-        def _forward_signal(signum, frame):
-            log.info(
-                "Received signal, forwarding to task subprocess",
-                signal=signal.Signals(signum).name,
-                pid=self.pid,
-            )
-            with suppress(ProcessLookupError):
-                os.kill(self.pid, signum)
-
-        signal.signal(signal.SIGTERM, _forward_signal)
-        signal.signal(signal.SIGINT, _forward_signal)
-
         try:
             self._monitor_subprocess()
         finally:
             self.selector.close()
-            signal.signal(signal.SIGTERM, prev_sigterm)
-            signal.signal(signal.SIGINT, prev_sigint)
 
         # self._monitor_subprocess() will set the exit code when the process 
has finished
         # If it hasn't, assume it's failed
diff --git a/task-sdk/tests/task_sdk/dags/signal_forward_test.py 
b/task-sdk/tests/task_sdk/dags/signal_forward_test.py
deleted file mode 100644
index ff85eda41a6..00000000000
--- a/task-sdk/tests/task_sdk/dags/signal_forward_test.py
+++ /dev/null
@@ -1,44 +0,0 @@
-# Licensed to the Apache Software Foundation (ASF) under one
-# or more contributor license agreements.  See the NOTICE file
-# distributed with this work for additional information
-# regarding copyright ownership.  The ASF licenses this file
-# to you under the Apache License, Version 2.0 (the
-# "License"); you may not use this file except in compliance
-# with the License.  You may obtain a copy of the License at
-#
-#   http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing,
-# software distributed under the License is distributed on an
-# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-# KIND, either express or implied.  See the License for the
-# specific language governing permissions and limitations
-# under the License.
-from __future__ import annotations
-
-import os
-import signal
-import time
-
-from airflow.sdk.bases.operator import BaseOperator
-from airflow.sdk.definitions.dag import dag
-
-
-class SignalForwardOperator(BaseOperator):
-    """Send SIGTERM to the supervisor parent process to exercise signal 
forwarding."""
-
-    def execute(self, context):
-        print("EXECUTE_STARTED", flush=True)
-        os.kill(os.getppid(), signal.SIGTERM)
-        time.sleep(2)
-
-    def on_kill(self) -> None:
-        print("ON_KILL_CALLED_VIA_SIGNAL_FORWARDING", flush=True)
-
-
-@dag()
-def signal_forward_test():
-    SignalForwardOperator(task_id="signal_task")
-
-
-signal_forward_test()
diff --git a/task-sdk/tests/task_sdk/execution_time/test_coordinator.py 
b/task-sdk/tests/task_sdk/execution_time/test_coordinator.py
index 73e737bfb8b..c4185c19390 100644
--- a/task-sdk/tests/task_sdk/execution_time/test_coordinator.py
+++ b/task-sdk/tests/task_sdk/execution_time/test_coordinator.py
@@ -19,6 +19,9 @@
 from __future__ import annotations
 
 import json
+import os
+import signal
+from unittest import mock
 
 import pytest
 
@@ -29,6 +32,7 @@ from airflow.sdk.execution_time.coordinator import (
     CoordinatorManager,
     InvalidCoordinatorError,
     _PythonCoordinator,
+    _warm_shutdown_signals,
     get_coordinator_manager,
     reset_coordinator_manager,
 )
@@ -281,3 +285,136 @@ class TestConfigYamlCoordinatorsExample:
         for queue, key in queue_to_coordinator.items():
             coordinator = manager.for_queue(queue)
             assert isinstance(coordinator, 
import_string(specs[key]["classpath"]))
+
+
+class TestWarmShutdownSignals:
+    """Tests for the warm-shutdown signal handling that wraps task 
supervision."""
+
+    @pytest.fixture(autouse=True)
+    def _restore_disposition(self):
+        """Guarantee SIGTERM/SIGINT dispositions are restored even if a test 
leaks one."""
+        original_term = signal.getsignal(signal.SIGTERM)
+        original_int = signal.getsignal(signal.SIGINT)
+        yield
+        signal.signal(signal.SIGTERM, original_term)
+        signal.signal(signal.SIGINT, original_int)
+
+    def test_installs_handlers_inside_context(self):
+        """While the context is active a warm-shutdown handler is installed 
for both signals."""
+        sentinel_term = signal.getsignal(signal.SIGTERM)
+        sentinel_int = signal.getsignal(signal.SIGINT)
+
+        with _warm_shutdown_signals():
+            inside_term = signal.getsignal(signal.SIGTERM)
+            inside_int = signal.getsignal(signal.SIGINT)
+
+        assert callable(inside_term)
+        assert callable(inside_int)
+        # The installed handler is the warm-shutdown closure, not the previous 
disposition.
+        assert inside_term is not sentinel_term
+        assert inside_int is not sentinel_int
+        # Both signals share the same warm-shutdown closure.
+        assert inside_term is inside_int
+
+    def test_restores_previous_dispositions_on_exit(self):
+        """The exact previous dispositions are restored when the context exits 
normally."""
+
+        def _prev_term(signum, frame):  # pragma: no cover - never invoked
+            pass
+
+        def _prev_int(signum, frame):  # pragma: no cover - never invoked
+            pass
+
+        signal.signal(signal.SIGTERM, _prev_term)
+        signal.signal(signal.SIGINT, _prev_int)
+
+        with _warm_shutdown_signals():
+            pass
+
+        assert signal.getsignal(signal.SIGTERM) is _prev_term
+        assert signal.getsignal(signal.SIGINT) is _prev_int
+
+    def test_restores_previous_dispositions_on_exception(self):
+        """Dispositions are restored even if the wrapped body raises."""
+
+        def _prev_term(signum, frame):  # pragma: no cover - never invoked
+            pass
+
+        signal.signal(signal.SIGTERM, _prev_term)
+
+        with pytest.raises(RuntimeError, match="boom"), 
_warm_shutdown_signals():
+            raise RuntimeError("boom")
+
+        assert signal.getsignal(signal.SIGTERM) is _prev_term
+
+    def test_sigterm_inside_context_does_not_kill(self):
+        """
+        A SIGTERM delivered while supervising must be swallowed, not kill the 
process.
+
+        This is the regression guard: with the default SIGTERM disposition 
(SIG_DFL)
+        in place as the *previous* handler, sending SIGTERM to ourselves would
+        terminate the process. The warm-shutdown handler installed by the 
context
+        manager must absorb it so the running task is allowed to finish.
+        """
+        # Make the pre-context disposition the default so a missing 
warm-shutdown
+        # handler would actually kill this process (and fail the test by 
dying).
+        signal.signal(signal.SIGTERM, signal.SIG_DFL)
+
+        reached_after_signal = False
+        with _warm_shutdown_signals():
+            os.kill(os.getpid(), signal.SIGTERM)
+            # If the handler did not absorb the signal, we never get here.
+            reached_after_signal = True
+
+        assert reached_after_signal
+        # And the default disposition is put back afterwards.
+        assert signal.getsignal(signal.SIGTERM) is signal.SIG_DFL
+
+
+class TestPythonCoordinatorWarmShutdown:
+    """The Python coordinator must wrap start() and wait() in the 
warm-shutdown handlers."""
+
+    def test_execute_task_wraps_start_and_wait(self, monkeypatch):
+        """
+        Handlers are installed for the whole start()+wait() window and 
restored after.
+
+        Capturing the SIGTERM disposition at the moment ``start()`` and 
``wait()``
+        run proves the handler spans the RUNNING transition (start) and the
+        terminal-state report / log upload (wait), with no window left 
uncovered.
+        """
+        original_term = signal.getsignal(signal.SIGTERM)
+        captured: dict[str, object] = {}
+
+        class _FakeProcess:
+            final_state = "success"
+
+            def wait(self_inner):
+                captured["wait"] = signal.getsignal(signal.SIGTERM)
+                return 0
+
+        def _fake_start(*args, **kwargs):
+            captured["start"] = signal.getsignal(signal.SIGTERM)
+            return _FakeProcess()
+
+        import airflow.sdk.execution_time.supervisor as supervisor_mod
+
+        monkeypatch.setattr(supervisor_mod.ActivitySubprocess, "start", 
staticmethod(_fake_start))
+
+        coordinator = _PythonCoordinator()
+        result = coordinator.execute_task(
+            what=mock.MagicMock(),
+            dag_rel_path="some_dag.py",
+            bundle_info=mock.MagicMock(),
+            client=mock.MagicMock(),
+            subprocess_logs_to_stdout=False,
+        )
+
+        assert result.exit_code == 0
+        assert result.final_state == "success"
+        # During both start() and wait() a warm-shutdown handler was 
installed...
+        assert callable(captured["start"])
+        assert callable(captured["wait"])
+        assert captured["start"] is not original_term
+        assert captured["start"] is captured["wait"]
+        # ...and the original disposition is restored once execute_task 
returns.
+        assert signal.getsignal(signal.SIGTERM) is original_term
diff --git a/task-sdk/tests/task_sdk/execution_time/test_supervisor.py 
b/task-sdk/tests/task_sdk/execution_time/test_supervisor.py
index 54a426450b3..491a1b0e3fd 100644
--- a/task-sdk/tests/task_sdk/execution_time/test_supervisor.py
+++ b/task-sdk/tests/task_sdk/execution_time/test_supervisor.py
@@ -27,9 +27,8 @@ import signal
 import socket
 import subprocess
 import sys
-import threading
 import time
-from contextlib import nullcontext, suppress
+from contextlib import nullcontext
 from dataclasses import dataclass, field
 from datetime import datetime, timezone as dt_timezone
 from operator import attrgetter
@@ -263,52 +262,6 @@ class TestSupervisor:
             with expectation:
                 supervise_task(**kw)
 
-    def test_on_kill_hook_called_when_supervisor_receives_sigterm(
-        self,
-        test_dags_dir,
-        captured_logs,
-        client_with_ti_start,
-    ):
-        """SIGTERM to the supervisor process is forwarded to the task 
subprocess."""
-        ti = TaskInstance(
-            id=uuid7(),
-            task_id="signal_task",
-            dag_id="signal_forward_test",
-            run_id="r",
-            try_number=1,
-            dag_version_id=uuid7(),
-            queue="default",
-        )
-        bundle_info = BundleInfo(name="my-bundle", version=None)
-
-        supervisor_pid = os.getpid()
-
-        def _kill_children():
-            for child in 
psutil.Process(supervisor_pid).children(recursive=True):
-                with suppress(psutil.NoSuchProcess):
-                    child.kill()
-
-        watchdog = threading.Timer(20.0, _kill_children)
-        watchdog.daemon = True
-        watchdog.start()
-
-        try:
-            with patch.dict(os.environ, local_dag_bundle_cfg(test_dags_dir, 
bundle_info.name)):
-                supervise_task(
-                    ti=ti,
-                    dag_rel_path="signal_forward_test.py",
-                    token="",
-                    dry_run=True,
-                    client=client_with_ti_start,
-                    bundle_info=bundle_info,
-                )
-        finally:
-            watchdog.cancel()
-
-        stdout_events = [entry["event"] for entry in captured_logs if 
entry.get("logger") == "task.stdout"]
-        assert "EXECUTE_STARTED" in stdout_events
-        assert "ON_KILL_CALLED_VIA_SIGNAL_FORWARDING" in stdout_events
-
 
 @pytest.mark.usefixtures("disable_capturing")
 class TestWatchedSubprocess:

Reply via email to