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

ashb 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 61340ea9a73 Separate task outcomes from report delivery in TaskSDK 
Supervisor (#73249)
61340ea9a73 is described below

commit 61340ea9a73eb82228ac8c24fc366cd265de6e7d
Author: Ash Berlin-Taylor <[email protected]>
AuthorDate: Wed Sep 23 21:25:19 2026 +0100

    Separate task outcomes from report delivery in TaskSDK Supervisor (#73249)
    
    The supervisor uses _terminal_state inconsistently: failed and skipped
    outcomes are recorded when the worker reports them, while other outcomes
    are recorded only after the API call succeeds. This couples the known
    task outcome to API availability and makes deferred reporting harder to
    reason about.
    
    This changes things so we record the worker’s outcome immediately on an
    instance field and use the existing pending message to track delivery.
    Failed API calls retain that message and keep heartbeats running. Upon
    task process exit pending reports take precedence over exit-code
    inference. Server-directed termination (zombie detection, external
    clearing, etc.) overrides pending reporting and any worker outcomes
    received during shutdown.
    
    The previous tests required _terminal_state to remain unset after an API
    failure because setting it stopped heartbeats. That assertion reflected
    the old coupling between outcome and delivery. The updated tests check
    the underlying guarantees: retain the report, continue heartbeats, and
    recover through the correct endpoint.
    
    Post-task finalization timeouts now depend on the recorded completion
    timestamp, independently of whether reporting succeeds. This also bounds
    finalization when the initial API call fails.
---
 .../src/airflow/sdk/execution_time/supervisor.py   |  97 +++++-------
 .../task_sdk/execution_time/test_supervisor.py     | 171 +++++++++++++++++++--
 2 files changed, 199 insertions(+), 69 deletions(-)

diff --git a/task-sdk/src/airflow/sdk/execution_time/supervisor.py 
b/task-sdk/src/airflow/sdk/execution_time/supervisor.py
index 7c28f0aeaca..d722f7d5a55 100644
--- a/task-sdk/src/airflow/sdk/execution_time/supervisor.py
+++ b/task-sdk/src/airflow/sdk/execution_time/supervisor.py
@@ -1598,18 +1598,9 @@ class ActivitySubprocess(WatchedSubprocess):
     """The HTTP client to use for communication with the API server."""
 
     _terminal_state: str | None = attrs.field(default=None, init=False)
-    _final_state: str | None = attrs.field(default=None, init=False)
-    # The terminal-state message currently being processed by 
`_handle_request`,
-    # captured BEFORE the dedicated API call (succeed / retry / defer /
-    # reschedule). If the API call raises (network blip, server 5xx, etc.),
-    # this attribute stays set and the dispatcher in
-    # `update_task_state_if_needed` re-issues the matching API call on
-    # subprocess exit — re-attempting the original transition rather than
-    # falling back to `finish()`, which doesn't accept SUCCESS / DEFERRED /
-    # SERVER_TERMINATED on the server side. Cleared (and `_terminal_state`
-    # set) only after the API call returns successfully.
+    # Retain the full report until delivery succeeds, including reports 
deferred until process exit.
     _pending_terminal_state_msg: (
-        SucceedTask | RetryTask | DeferTask | RescheduleTask | AwaitInputTask 
| None
+        TaskState | SucceedTask | RetryTask | DeferTask | RescheduleTask | 
AwaitInputTask | None
     ) = attrs.field(default=None, init=False)
 
     _last_successful_heartbeat: float = attrs.field(default=0, init=False)
@@ -1736,23 +1727,18 @@ class ActivitySubprocess(WatchedSubprocess):
         return self._exit_code
 
     def update_task_state_if_needed(self):
-        # If a direct-state API call (succeed / retry / defer / reschedule)
-        # was attempted but raised, `_pending_terminal_state_msg` still holds
-        # the original request. Re-issue the matching dedicated API call so
-        # the server learns the terminal state we couldn't deliver earlier.
-        # Without this recovery, a transient API failure during the direct
-        # call would leave the TI stuck RUNNING on the server — `finish()`
-        # cannot substitute because the server-side `finish` endpoint does
-        # not accept SUCCESS / DEFERRED / SERVER_TERMINATED transitions.
+        if self._terminal_state == SERVER_TERMINATED:
+            self._pending_terminal_state_msg = None
+            return
+
         if self._pending_terminal_state_msg is not None:
-            self._replay_pending_terminal_state_msg()
+            if isinstance(self._pending_terminal_state_msg, TaskState):
+                self._send_terminal_state_msg(self._pending_terminal_state_msg)
+            else:
+                self._replay_pending_terminal_state_msg()
             return
 
-        # If the process has finished in a non-directly-patched state (e.g.
-        # FAILED, or SKIPPED reported via a TaskState message), `finish()` is
-        # the dedicated endpoint for those transitions. For states already in
-        # STATES_SENT_DIRECTLY whose direct API call succeeded, no further
-        # action is needed.
+        # Without a worker outcome, only report inferred states that finish() 
accepts.
         if self.final_state not in STATES_SENT_DIRECTLY:
             self.client.task_instances.finish(
                 id=self.id,
@@ -1762,14 +1748,20 @@ class ActivitySubprocess(WatchedSubprocess):
             )
 
     def _send_terminal_state_msg(
-        self, msg: SucceedTask | RetryTask | DeferTask | RescheduleTask | 
AwaitInputTask
+        self, msg: TaskState | SucceedTask | RetryTask | DeferTask | 
RescheduleTask | AwaitInputTask
     ) -> None:
-        # Capture the message BEFORE the API call so the recovery dispatcher
-        # in `update_task_state_if_needed` can re-issue it if the call raises
-        # (network blip, transient server 5xx). Clear the pending slot and
-        # record the resulting state only after the call returns successfully.
+        if self._terminal_state == SERVER_TERMINATED:
+            return
+        self._terminal_state = msg.state
         self._pending_terminal_state_msg = msg
-        if isinstance(msg, SucceedTask):
+        if isinstance(msg, TaskState):
+            self.client.task_instances.finish(
+                id=self.id,
+                state=msg.state,
+                when=msg.end_date or datetime.now(tz=timezone.utc),
+                rendered_map_index=self._rendered_map_index,
+            )
+        elif isinstance(msg, SucceedTask):
             self.client.task_instances.succeed(
                 id=self.id,
                 when=msg.end_date,
@@ -1777,7 +1769,6 @@ class ActivitySubprocess(WatchedSubprocess):
                 outlet_events=msg.outlet_events,
                 rendered_map_index=self._rendered_map_index,
             )
-            self._terminal_state = msg.state
         elif isinstance(msg, RetryTask):
             self.client.task_instances.retry(
                 id=self.id,
@@ -1786,16 +1777,12 @@ class ActivitySubprocess(WatchedSubprocess):
                 retry_delay_seconds=getattr(msg, "retry_delay_seconds", None),
                 retry_reason=getattr(msg, "retry_reason", None),
             )
-            self._terminal_state = msg.state
         elif isinstance(msg, DeferTask):
             self.client.task_instances.defer(self.id, msg)
-            self._terminal_state = TaskInstanceState.DEFERRED
         elif isinstance(msg, RescheduleTask):
             self.client.task_instances.reschedule(self.id, msg)
-            self._terminal_state = TaskInstanceState.UP_FOR_RESCHEDULE
         elif isinstance(msg, AwaitInputTask):
             self.client.task_instances.await_input(self.id, msg)
-            self._terminal_state = TaskInstanceState.AWAITING_INPUT
         self._pending_terminal_state_msg = None
 
     def _replay_pending_terminal_state_msg(self) -> None:
@@ -1883,13 +1870,9 @@ class ActivitySubprocess(WatchedSubprocess):
 
     def _handle_process_overtime_if_needed(self):
         """Handle termination of auxiliary processes if the task exceeds the 
configured overtime."""
-        # If the task has reached a terminal state, we can start monitoring 
the overtime
-        if not self._terminal_state:
+        if self._task_end_time_monotonic is None:
             return
-        if (
-            self._task_end_time_monotonic
-            and (time.monotonic() - self._task_end_time_monotonic) > 
TASK_OVERTIME_THRESHOLD
-        ):
+        if (time.monotonic() - self._task_end_time_monotonic) > 
TASK_OVERTIME_THRESHOLD:
             log.warning(
                 "Task success overtime reached; terminating process. "
                 "Modify `task_success_overtime` setting in [core] section of "
@@ -1904,9 +1887,9 @@ class ActivitySubprocess(WatchedSubprocess):
         if (time.monotonic() - self._last_heartbeat_attempt) < 
MIN_HEARTBEAT_INTERVAL:
             return
 
-        if self._terminal_state:
-            # If the task has finished, and we are in "overtime" (running OL 
listeners etc) we shouldn't
-            # heartbeat
+        if self._terminal_state == SERVER_TERMINATED:
+            return
+        if self._terminal_state and self._pending_terminal_state_msg is None:
             return
 
         self._last_heartbeat_attempt = time.monotonic()
@@ -1929,9 +1912,11 @@ class ActivitySubprocess(WatchedSubprocess):
                     "Server indicated the task shouldn't be running anymore. 
Terminating process",
                     detail=e.detail,
                 )
+                # kill() drains worker messages while waiting for the process 
to exit.
+                self._terminal_state = SERVER_TERMINATED
+                self._pending_terminal_state_msg = None
                 self.kill(signal.SIGTERM, force=True)
                 self.process_log.error("Task killed!")
-                self._terminal_state = SERVER_TERMINATED
             else:
                 # If we get any other error, we'll just log it and try again 
next time
                 self._handle_heartbeat_failures(e)
@@ -1962,14 +1947,17 @@ class ActivitySubprocess(WatchedSubprocess):
 
         By default, this will be derived from the exit code of the task
         (0=success, failed otherwise) but can be changed by the subprocess
-        sending a TaskState message, as long as the process exits with 0
+        sending a TaskState message, as long as the process exits with 0.
+
+        A nonzero exit code can override the worker outcome here. Deliver 
reports
+        using the message state, not this inferred state.
 
         Not valid before the process has finished.
         """
+        if self._terminal_state == SERVER_TERMINATED:
+            return self._terminal_state
         if self._exit_code == 0:
             return self._terminal_state or TaskInstanceState.SUCCESS
-        if self._exit_code != 0 and self._terminal_state == SERVER_TERMINATED:
-            return SERVER_TERMINATED
 
         # Any non zero exit code indicates a failure
         # If retries are configured, mark as UP_FOR_RETRY
@@ -1989,12 +1977,11 @@ class ActivitySubprocess(WatchedSubprocess):
         super()._handle_request(msg, log, req_id)
 
     def _handle_task_state(self, msg: TaskState, log: FilteringBoundLogger, 
req_id: int) -> RequestResult:
-        # No direct API call here — the recovery path in
-        # `update_task_state_if_needed` will call `finish()` for
-        # non-direct states (FAILED, etc.) once the subprocess exits.
-        self._terminal_state = msg.state
-        self._task_end_time_monotonic = time.monotonic()
-        self._rendered_map_index = msg.rendered_map_index
+        if self._terminal_state != SERVER_TERMINATED:
+            self._terminal_state = msg.state
+            self._pending_terminal_state_msg = msg
+            self._task_end_time_monotonic = time.monotonic()
+            self._rendered_map_index = msg.rendered_map_index
         return None, {}
 
     def _handle_finished_task(
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 e94b901179d..a55cef8aa58 100644
--- a/task-sdk/tests/task_sdk/execution_time/test_supervisor.py
+++ b/task-sdk/tests/task_sdk/execution_time/test_supervisor.py
@@ -35,7 +35,7 @@ from operator import attrgetter
 from random import randint
 from textwrap import dedent
 from time import sleep
-from typing import TYPE_CHECKING, Any, get_args
+from typing import TYPE_CHECKING, Any, get_args, get_type_hints
 from unittest import mock
 from unittest.mock import MagicMock, patch
 
@@ -1091,6 +1091,9 @@ class TestWatchedSubprocess:
                 False,
                 id="no_terminal_state",
             ),
+            pytest.param(None, None, 10, False, id="execution_still_running"),
+            pytest.param(None, 9.0, 10, True, 
id="finalization_without_reported_state"),
+            pytest.param(None, 0.0, 10, True, 
id="finalization_started_at_zero"),
             pytest.param(TaskInstanceState.SUCCESS, 15.0, 10, False, 
id="below_threshold"),
             pytest.param(TaskInstanceState.SUCCESS, 9.0, 10, True, 
id="above_threshold"),
             pytest.param(TaskInstanceState.FAILED, 9.0, 10, True, 
id="above_threshold_failed_state"),
@@ -3330,6 +3333,119 @@ REQUEST_TEST_CASES = [
 
 
 class TestHandleRequest:
+    @pytest.mark.parametrize("arrival", ["during_kill", "after_kill"])
+    @pytest.mark.parametrize(
+        ("msg", "api_method"),
+        [
+            (TaskState(state=TaskInstanceState.FAILED), "finish"),
+            (SucceedTask(end_date=timezone.parse("2024-10-31T12:00:00Z")), 
"succeed"),
+            (RetryTask(end_date=timezone.parse("2024-10-31T12:00:00Z")), 
"retry"),
+            (DeferTask(next_method="execute_complete", 
classpath="test.Trigger", trigger_kwargs={}), "defer"),
+            (
+                RescheduleTask(
+                    end_date=timezone.parse("2024-10-31T12:00:00Z"),
+                    reschedule_date=timezone.parse("2024-10-31T12:01:00Z"),
+                ),
+                "reschedule",
+            ),
+            (AwaitInputTask(next_method="execute_complete"), "await_input"),
+        ],
+    )
+    def test_server_termination_ignores_late_outcome(
+        self, watched_subprocess, mocker, arrival, msg, api_method
+    ):
+        process, _ = watched_subprocess
+        process._handle_request(TaskState(state=TaskInstanceState.FAILED), 
structlog.get_logger(), req_id=1)
+        process.client.task_instances.heartbeat.side_effect = 
ServerResponseError.from_response(
+            httpx.Response(
+                409,
+                request=httpx.Request("PUT", "http://server/heartbeat";),
+                json={"detail": "already stopped"},
+            )
+        )
+        observed_at_kill = []
+
+        def terminate(self, signal_to_send, force):
+            observed_at_kill.append((self._terminal_state, 
self._pending_terminal_state_msg))
+            if arrival == "during_kill":
+                self._handle_request(msg, structlog.get_logger(), req_id=2)
+            self._exit_code = -signal.SIGTERM
+
+        mocker.patch.object(ActivitySubprocess, "kill", autospec=True, 
side_effect=terminate)
+        process._should_retry = True
+
+        process._send_heartbeat_if_needed()
+        if arrival == "after_kill":
+            process._handle_request(msg, structlog.get_logger(), req_id=2)
+        process.update_task_state_if_needed()
+
+        assert observed_at_kill == [(supervisor.SERVER_TERMINATED, None)]
+        assert process.final_state == supervisor.SERVER_TERMINATED
+        assert process._pending_terminal_state_msg is None
+        getattr(process.client.task_instances, api_method).assert_not_called()
+        process.client.task_instances.finish.assert_not_called()
+
+    @pytest.mark.parametrize("exit_code", [0, -signal.SIGTERM])
+    def test_server_termination_cancels_pending_report(self, 
watched_subprocess, exit_code):
+        process, _ = watched_subprocess
+        process._handle_request(TaskState(state=TaskInstanceState.FAILED), 
structlog.get_logger(), req_id=1)
+        process._terminal_state = supervisor.SERVER_TERMINATED
+        process._exit_code = exit_code
+
+        process._send_heartbeat_if_needed()
+        process.update_task_state_if_needed()
+
+        process.client.task_instances.heartbeat.assert_not_called()
+        process.client.task_instances.finish.assert_not_called()
+        assert process._pending_terminal_state_msg is None
+        assert process.final_state == supervisor.SERVER_TERMINATED
+
+    @pytest.mark.parametrize(
+        "state", [TaskInstanceState.FAILED, TaskInstanceState.SKIPPED, 
TaskInstanceState.REMOVED]
+    )
+    @pytest.mark.parametrize("exit_code", [0, 1, -signal.SIGTERM])
+    @pytest.mark.parametrize("should_retry", [False, True])
+    @pytest.mark.parametrize("end_date", [None, 
timezone.parse("2024-10-31T12:00:00Z")])
+    def test_task_state_waits_for_exit_and_keeps_heartbeating(
+        self, watched_subprocess, time_machine, state, exit_code, 
should_retry, end_date
+    ):
+        process, _ = watched_subprocess
+        process._should_retry = should_retry
+        msg = TaskState(state=state, rendered_map_index="label", 
end_date=end_date)
+
+        process._handle_request(msg, structlog.get_logger(), req_id=1)
+
+        assert process._terminal_state == state
+        assert process._pending_terminal_state_msg is msg
+        process.client.task_instances.finish.assert_not_called()
+        process._send_heartbeat_if_needed()
+        process.client.task_instances.heartbeat.assert_called_once()
+        process._exit_code = exit_code
+        report_time = timezone.parse("2024-10-31T12:01:00Z")
+        time_machine.move_to(report_time, tick=False)
+        process.update_task_state_if_needed()
+
+        assert process.client.task_instances.finish.call_args.kwargs["state"] 
== state
+        assert process.client.task_instances.finish.call_args.kwargs["when"] 
== (end_date or report_time)
+        assert 
process.client.task_instances.finish.call_args.kwargs["rendered_map_index"] == 
"label"
+        assert process._pending_terminal_state_msg is None
+
+    def test_failed_finish_retains_pending_report(self, watched_subprocess, 
mocker):
+        process, _ = watched_subprocess
+        msg = TaskState(state=TaskInstanceState.FAILED)
+        process._handle_request(msg, structlog.get_logger(), req_id=1)
+        process._exit_code = 0
+        process.client.task_instances.finish = mocker.Mock(
+            spec=sdk_client.TaskInstanceOperations.finish,
+            side_effect=httpx.ConnectError("connection refused"),
+        )
+
+        with pytest.raises(httpx.ConnectError):
+            process.update_task_state_if_needed()
+
+        assert process._terminal_state == TaskInstanceState.FAILED
+        assert process._pending_terminal_state_msg is msg
+
     class _OverrideActivitySubprocess(ActivitySubprocess):
         def _handle_set_rendered_map_index(
             self, msg: SetRenderedMapIndex, log: FilteringBoundLogger, req_id: 
int
@@ -3711,32 +3827,35 @@ class TestHandleRequest:
                 TaskInstanceState.UP_FOR_RESCHEDULE,
                 id="reschedule",
             ),
+            pytest.param(
+                AwaitInputTask(next_method="execute_complete"),
+                "await_input",
+                TaskInstanceState.AWAITING_INPUT,
+                id="await_input",
+            ),
         ],
     )
-    def test_terminal_state_not_set_when_direct_api_fails(
+    def test_worker_outcome_retained_when_direct_api_fails(
         self, watched_subprocess, mocker, msg, api_method, expected_state
     ):
-        """`_terminal_state` must NOT be set when the dedicated terminal-state
-        API raises.
-
-        The original message is captured in `_pending_terminal_state_msg`
-        BEFORE the API call so the recovery dispatcher in
-        `update_task_state_if_needed` can re-issue it on subprocess exit.
-        Covers all four terminal-state message types.
-        """
         watched_subprocess, _ = watched_subprocess
         setattr(
             watched_subprocess.client.task_instances,
             api_method,
-            mocker.Mock(side_effect=httpx.ConnectError("connection refused")),
+            mocker.Mock(
+                spec=getattr(sdk_client.TaskInstanceOperations, api_method),
+                side_effect=httpx.ConnectError("connection refused"),
+            ),
         )
 
         with pytest.raises(httpx.ConnectError):
-            watched_subprocess._handle_request(msg, mocker.Mock(), req_id=1)
+            watched_subprocess._handle_request(msg, structlog.get_logger(), 
req_id=1)
 
-        assert watched_subprocess._terminal_state is None
+        assert watched_subprocess._terminal_state == expected_state
         # Pending msg preserved so the recovery dispatcher can re-issue.
         assert watched_subprocess._pending_terminal_state_msg is msg
+        watched_subprocess._send_heartbeat_if_needed()
+        watched_subprocess.client.task_instances.heartbeat.assert_called_once()
 
     @pytest.mark.parametrize(
         ("msg", "api_method", "expected_state"),
@@ -3772,6 +3891,12 @@ class TestHandleRequest:
                 TaskInstanceState.UP_FOR_RESCHEDULE,
                 id="reschedule",
             ),
+            pytest.param(
+                AwaitInputTask(next_method="execute_complete"),
+                "await_input",
+                TaskInstanceState.AWAITING_INPUT,
+                id="await_input",
+            ),
         ],
     )
     def test_update_task_state_replays_pending_terminal_state_call(
@@ -3780,7 +3905,7 @@ class TestHandleRequest:
         """If a direct terminal-state API call was attempted and raised, the
         recovery dispatcher must re-issue the dedicated endpoint (not
         `finish()`, which the server-side endpoint refuses for SUCCESS /
-        DEFERRED / SERVER_TERMINATED). Covers all four message types.
+        DEFERRED / SERVER_TERMINATED).
         """
         watched_subprocess, _ = watched_subprocess
         watched_subprocess._exit_code = 0
@@ -3809,6 +3934,24 @@ class TestHandleRequest:
         watched_subprocess.client.task_instances.succeed.assert_not_called()
 
 
[email protected](
+    "test_name",
+    [
+        "test_worker_outcome_retained_when_direct_api_fails",
+        "test_update_task_state_replays_pending_terminal_state_call",
+    ],
+)
+def test_terminal_message_parameter_coverage(test_name):
+    marks = getattr(TestHandleRequest, test_name).pytestmark
+    cases = next(mark.args[1] for mark in marks if mark.name == "parametrize")
+    message_types = 
set(get_args(get_type_hints(ActivitySubprocess._send_terminal_state_msg)["msg"]))
+    assert {type(case.values[0]) for case in cases} == message_types - 
{TaskState}
+
+    marks = 
TestHandleRequest.test_task_state_waits_for_exit_and_keeps_heartbeating.pytestmark
+    states = next(mark.args[1] for mark in marks if mark.name == "parametrize" 
and mark.args[0] == "state")
+    assert set(states) == 
set(get_args(TaskState.model_fields["state"].annotation))
+
+
 class TestSetSupervisorComms:
     class DummyComms:
         pass

Reply via email to