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

kaxilnaik pushed a commit to branch v3-0-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-0-test by this push:
     new f92f5c8fa63 [v3-0-test] Prevent CPU spike in task supervisor when 
heartbeat timeout exceeded (#51023) (#51047)
f92f5c8fa63 is described below

commit f92f5c8fa63066f2d0c3857ebf11cb2e1b0e6af0
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Mon Jun 2 23:37:50 2025 +0530

    [v3-0-test] Prevent CPU spike in task supervisor when heartbeat timeout 
exceeded (#51023) (#51047)
    
    (cherry picked from commit beb7b629161c0e51f7995ee630ec53820a41b6f0)
    
    Co-authored-by: Kaxil Naik <[email protected]>
---
 .../src/airflow/sdk/execution_time/supervisor.py   |  4 +-
 .../task_sdk/execution_time/test_supervisor.py     | 67 ++++++++++++++++++++++
 2 files changed, 70 insertions(+), 1 deletion(-)

diff --git a/task-sdk/src/airflow/sdk/execution_time/supervisor.py 
b/task-sdk/src/airflow/sdk/execution_time/supervisor.py
index 0328525b0c8..fcbf7126d97 100644
--- a/task-sdk/src/airflow/sdk/execution_time/supervisor.py
+++ b/task-sdk/src/airflow/sdk/execution_time/supervisor.py
@@ -685,7 +685,9 @@ class WatchedSubprocess:
         :param expect_signal: Signal not to log if the task exits with this 
code.
         :returns: The process exit code, or None if it's still alive
         """
-        events = self.selector.select(timeout=max_wait_time)
+        # Ensure minimum timeout to prevent CPU spike with tight loop when 
timeout is 0 or negative
+        timeout = max(0.01, max_wait_time)
+        events = self.selector.select(timeout=timeout)
         for key, _ in events:
             # Retrieve the handler responsible for processing this file object 
(e.g., stdout, stderr)
             socket_handler = key.data
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 1d19d19021e..7861a9d5997 100644
--- a/task-sdk/tests/task_sdk/execution_time/test_supervisor.py
+++ b/task-sdk/tests/task_sdk/execution_time/test_supervisor.py
@@ -958,6 +958,73 @@ class TestWatchedSubprocessKill:
         # Validate that `_check_subprocess_exit` is called
         mock_process.wait.assert_called_once_with(timeout=0)
 
+    def test_max_wait_time_prevents_cpu_spike(self, watched_subprocess, 
mock_process, monkeypatch):
+        """Test that max_wait_time calculation prevents CPU spike when 
heartbeat timeout is reached."""
+        # Mock the configuration to reproduce the CPU spike scenario
+        # Set heartbeat timeout to be very small relative to 
MIN_HEARTBEAT_INTERVAL
+        
monkeypatch.setattr("airflow.sdk.execution_time.supervisor.HEARTBEAT_TIMEOUT", 
1)
+        
monkeypatch.setattr("airflow.sdk.execution_time.supervisor.MIN_HEARTBEAT_INTERVAL",
 10)
+
+        # Set up a scenario where the last successful heartbeat was a long 
time ago
+        # This will cause the heartbeat calculation to result in a negative 
value
+        mock_process._last_successful_heartbeat = time.monotonic() - 100  # 
100 seconds ago
+
+        # Mock process to still be alive (not exited)
+        mock_process.wait.side_effect = psutil.TimeoutExpired(pid=12345, 
seconds=0)
+
+        # Call _service_subprocess which is used in _monitor_subprocess
+        # This tests the max_wait_time calculation directly
+        watched_subprocess._service_subprocess(max_wait_time=0.005)  # Very 
small timeout to verify our fix
+
+        # Verify that selector.select was called with a minimum timeout of 0.01
+        # This proves our fix prevents the timeout=0 scenario that causes CPU 
spike
+        watched_subprocess.selector.select.assert_called_once()
+        call_args = watched_subprocess.selector.select.call_args
+        timeout_arg = call_args[1]["timeout"] if "timeout" in call_args[1] 
else call_args[0][0]
+
+        # The timeout should be at least 0.01 (our minimum), never 0
+        assert timeout_arg >= 0.01, f"Expected timeout >= 0.01, got 
{timeout_arg}"
+
+    @pytest.mark.parametrize(
+        ["heartbeat_timeout", "min_interval", "heartbeat_ago", 
"expected_min_timeout"],
+        [
+            # Normal case: heartbeat is recent, should use calculated value
+            pytest.param(30, 5, 5, 0.01, id="normal_heartbeat"),
+            # Edge case: heartbeat timeout exceeded, should use minimum
+            pytest.param(10, 20, 50, 0.01, id="heartbeat_timeout_exceeded"),
+            # Bug reproduction case: timeout < interval, heartbeat very old
+            pytest.param(5, 10, 100, 0.01, id="cpu_spike_scenario"),
+        ],
+    )
+    def test_max_wait_time_calculation_edge_cases(
+        self,
+        watched_subprocess,
+        mock_process,
+        monkeypatch,
+        heartbeat_timeout,
+        min_interval,
+        heartbeat_ago,
+        expected_min_timeout,
+    ):
+        """Test max_wait_time calculation in various edge case scenarios."""
+        
monkeypatch.setattr("airflow.sdk.execution_time.supervisor.HEARTBEAT_TIMEOUT", 
heartbeat_timeout)
+        
monkeypatch.setattr("airflow.sdk.execution_time.supervisor.MIN_HEARTBEAT_INTERVAL",
 min_interval)
+
+        watched_subprocess._last_successful_heartbeat = time.monotonic() - 
heartbeat_ago
+        mock_process.wait.side_effect = psutil.TimeoutExpired(pid=12345, 
seconds=0)
+
+        # Call the method and verify timeout is never less than our minimum
+        watched_subprocess._service_subprocess(
+            max_wait_time=999
+        )  # Large value, should be overridden by calculation
+
+        # Extract the timeout that was actually used
+        watched_subprocess.selector.select.assert_called_once()
+        call_args = watched_subprocess.selector.select.call_args
+        actual_timeout = call_args[1]["timeout"] if "timeout" in call_args[1] 
else call_args[0][0]
+
+        assert actual_timeout >= expected_min_timeout
+
 
 class TestHandleRequest:
     @pytest.fixture

Reply via email to