This is an automated email from the ASF dual-hosted git repository.
vatsrahul1001 pushed a commit to branch v3-4-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-4-test by this push:
new 801d9a456a2 [v3-4-test] Clear language SDK task XComs in the
coordinator when a try starts (#74221) (#74936)
801d9a456a2 is described below
commit 801d9a456a2485f8ecb68de5ee25caa321c0a14f
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Sun Oct 18 11:28:16 2026 +0530
[v3-4-test] Clear language SDK task XComs in the coordinator when a try
starts (#74221) (#74936)
* Clear language SDK task XComs in the coordinator when a try starts
* Keep XCom deletion in the Go runtime until backports are merged
* Use httpx2 in the new XCom clearing tests
---------
(cherry picked from commit 4b464ee29bae582a09ffda951b783fe2031de1ce)
Signed-off-by: PoAn Yang <[email protected]>
Co-authored-by: PoAn Yang <[email protected]>
---
.../execution_api/routes/task_instances.py | 10 +-
.../src/airflow/sdk/coordinators/_subprocess.py | 41 ++++-
.../src/airflow/sdk/execution_time/supervisor.py | 16 ++
.../tests/task_sdk/coordinators/test_subprocess.py | 179 ++++++++++++++++++++-
.../task_sdk/execution_time/test_supervisor.py | 68 ++++++++
5 files changed, 305 insertions(+), 9 deletions(-)
diff --git
a/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py
b/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py
index 3e204488fd3..eb6686cfe97 100644
---
a/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py
+++
b/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py
@@ -310,10 +310,12 @@ def ti_run(
},
)
- # Send the keys to the SDK so that the client requests to clear those
XComs from the server.
- # The reason we cannot do this here in the server is because we need
to issue a purge on custom XCom backends
- # too. With the current assumption, the workers ONLY have access to
the custom XCom backends directly and they
- # can issue the purge.
+ # Send the keys to the worker, which deletes those XComs. The server
cannot delete them itself,
+ # because for a Python task the task runner also purges them from a
custom XCom backend, and only
+ # workers can reach that backend. For a language SDK task (Java, Go,
TypeScript), the supervisor
+ # deletes these XComs before the runtime starts the task and does not
purge them, because those
+ # runtimes push their XComs straight to the API server, so the XCom
values never pass through the
+ # backend.
# However, do not clear it for deferral
xcom_keys = []
diff --git a/task-sdk/src/airflow/sdk/coordinators/_subprocess.py
b/task-sdk/src/airflow/sdk/coordinators/_subprocess.py
index 32af0c556b6..424b10be916 100644
--- a/task-sdk/src/airflow/sdk/coordinators/_subprocess.py
+++ b/task-sdk/src/airflow/sdk/coordinators/_subprocess.py
@@ -37,6 +37,7 @@ import socket
import stat
import subprocess
import time
+from datetime import UTC, datetime
from pathlib import PurePosixPath
from typing import TYPE_CHECKING, NoReturn, TypeVar, cast
@@ -46,10 +47,11 @@ import structlog
from airflow.dag_processing.bundles.base import BundleVersionLock,
unpack_bundle_version # noqa: SDK002
from airflow.sdk._shared.module_loading import import_string
-from airflow.sdk.api.datamodels._generated import BundleInfo
+from airflow.sdk.api.datamodels._generated import BundleInfo, TaskInstanceState
from airflow.sdk.configuration import conf
from airflow.sdk.coordinators._dag_importer import find_claiming_importer
from airflow.sdk.execution_time.bundles import initialize_ti_bundle
+from airflow.sdk.execution_time.comms import RetryTask, TaskState
from airflow.sdk.execution_time.coordinator import BaseCoordinator,
TaskLaunchError
from airflow.sdk.execution_time.schema import get_schema_version_migrator
from airflow.sdk.execution_time.supervisor import ActivitySubprocess,
NeverRaised, ProcessTracker
@@ -63,7 +65,7 @@ if TYPE_CHECKING:
from airflow.dag_processing.bundles.base import BaseDagBundle # noqa:
SDK002
from airflow.sdk.api.client import Client
- from airflow.sdk.api.datamodels._generated import TaskInstance
+ from airflow.sdk.api.datamodels._generated import TaskInstance,
TIRunContext
Tracked = TypeVar("Tracked", socket.socket, subprocess.Popen)
@@ -403,6 +405,41 @@ class _PopenActivitySubprocess(ActivitySubprocess):
return self
+ def _prepare_ti_context(self, ti: TaskInstance, ti_context: TIRunContext)
-> TIRunContext | None:
+ """
+ Delete the XComs in ``ti_context.xcom_keys_to_clear`` so the launched
runtime does not have to.
+
+ Unlike ``XCom.delete``, this method does not purge the XCom backend,
for two reasons. XComs that a
+ runtime pushes go straight to the API server and never pass through
the backend. Also, a purge may
+ need a connection that a hook running in the supervisor process cannot
look up, such as a
+ connection that exists only in Airflow's metadata database. So if
Python code stored an XCom for
+ this task instance through the backend, deleting that XCom here leaves
the stored object in the
+ backend's storage.
+
+ If an XCom cannot be deleted, this try fails and the task does not run.
+ """
+ for key in ti_context.xcom_keys_to_clear or ():
+ self.process_log.debug("Clearing XCom with key", key=key)
+ try:
+ self.client.xcoms.delete(ti.dag_id, ti.run_id, ti.task_id,
key, ti.map_index)
+ except Exception as e:
+ self.process_log.exception("Failed to clear XCom; not running
the task", key=key)
+ end_date = datetime.now(tz=UTC)
+ reason = f"Failed to clear XCom {key!r}: {e}"
+ # wait() sends this outcome to the server after the runtime
process exits, the same
+ # way it sends an outcome that the runtime itself reported.
+ self._pending_terminal_state_msg = (
+ RetryTask(end_date=end_date, retry_reason=reason)
+ if ti_context.should_retry
+ else TaskState(state=TaskInstanceState.FAILED,
end_date=end_date, retry_reason=reason)
+ )
+ self.kill(signal.SIGKILL)
+ return None
+ # The supervisor has already deleted the listed XComs, so it sends the
runtime an empty list.
+ # If a runtime also deletes these XComs itself, the empty list keeps
the runtime from deleting
+ # them a second time.
+ return ti_context.model_copy(update={"xcom_keys_to_clear": []})
+
def wait(self) -> int:
code = super().wait()
self._close_unused_sockets(self._comm_server, self._logs_server)
diff --git a/task-sdk/src/airflow/sdk/execution_time/supervisor.py
b/task-sdk/src/airflow/sdk/execution_time/supervisor.py
index 5f870d7be78..78bba4c3642 100644
--- a/task-sdk/src/airflow/sdk/execution_time/supervisor.py
+++ b/task-sdk/src/airflow/sdk/execution_time/supervisor.py
@@ -190,6 +190,7 @@ if TYPE_CHECKING:
from structlog.typing import FilteringBoundLogger, WrappedLogger
from airflow.executors.workloads import BundleInfo
+ from airflow.sdk.api.datamodels._generated import TIRunContext
from airflow.sdk.bases.secrets_backend import BaseSecretsBackend
from airflow.sdk.definitions.connection import Connection
from airflow.sdk.types import RuntimeTaskInstanceProtocol as RuntimeTI
@@ -1719,6 +1720,10 @@ class ActivitySubprocess(WatchedSubprocess):
return
raise
+ if (prepared := self._prepare_ti_context(ti, ti_context)) is None:
+ return
+ ti_context = prepared
+
# ti_context.start_date is only populated by the server when resuming
from a deferral (to preserve the
# original start_date rather than using the resume time). We fall back
to now() otherwise. This ensures
# that `context["ti"].start_date` always reflects the *first* start
time. See TIRunContext.start_date
@@ -1743,6 +1748,17 @@ class ActivitySubprocess(WatchedSubprocess):
# Debug is fine, the process will have shown _something_ in it's
last_chance exception handler
log.debug("Couldn't send startup message to Subprocess - it died
very early", pid=self.pid)
+ def _prepare_ti_context(self, ti: TaskInstance, ti_context: TIRunContext)
-> TIRunContext | None:
+ """
+ Return the context the supervisor sends to the task process, or None
when the task must not run.
+
+ An implementation that returns None must first stop the task process
and record the outcome that
+ will be reported. This base version returns the context unchanged,
because the Python task runner
+ itself deletes the XComs in ``ti_context.xcom_keys_to_clear`` with
``XCom.delete``, which also
+ purges them from the XCom backend.
+ """
+ return ti_context
+
def wait(self) -> int:
if self._wait_completed and self._exit_code is not None:
return self._exit_code
diff --git a/task-sdk/tests/task_sdk/coordinators/test_subprocess.py
b/task-sdk/tests/task_sdk/coordinators/test_subprocess.py
index e150a0122d5..2b7615ea42c 100644
--- a/task-sdk/tests/task_sdk/coordinators/test_subprocess.py
+++ b/task-sdk/tests/task_sdk/coordinators/test_subprocess.py
@@ -19,6 +19,7 @@ from __future__ import annotations
import contextlib
import json
+import logging
import os
import pathlib
import signal
@@ -27,16 +28,20 @@ import subprocess
import sys
import threading
import time
+from datetime import UTC, datetime
from unittest.mock import ANY, MagicMock, call, patch
import attrs
+import httpx2
+import msgspec
import psutil
import pytest
+from task_sdk import FAKE_BUNDLE, make_client
from uuid6 import uuid7
from airflow.dag_processing.bundles.base import BaseDagBundle, BundleVersion
from airflow.sdk.api.client import Client, TaskInstanceOperations
-from airflow.sdk.api.datamodels._generated import BundleInfo, TaskInstance
+from airflow.sdk.api.datamodels._generated import BundleInfo, TaskInstance,
TIRunContext
from airflow.sdk.coordinators._dag_importer import CoordinatorDagImporter
from airflow.sdk.coordinators._subprocess import (
SubprocessCoordinator,
@@ -61,7 +66,7 @@ if not AIRFLOW_V_3_3_PLUS:
pytest.skip("Coordinator is only compatible with Airflow >= 3.3.0",
allow_module_level=True)
-def _make_ti(dag_id: str = "tutorial_dag", queue: str = "socket") ->
TaskInstance:
+def _make_ti(dag_id: str = "tutorial_dag", queue: str = "socket", map_index:
int = -1) -> TaskInstance:
return TaskInstance(
id=uuid7(),
dag_version_id=uuid7(),
@@ -69,7 +74,7 @@ def _make_ti(dag_id: str = "tutorial_dag", queue: str =
"socket") -> TaskInstanc
dag_id=dag_id,
run_id="run_1",
try_number=1,
- map_index=-1,
+ map_index=map_index,
queue=queue,
)
@@ -933,6 +938,174 @@ class TestPopenActivitySubprocessStart:
assert mock_register.mock_calls == [call(ANY, ANY, ANY, ANY, data=ANY)]
+# This script connects back to the supervisor the way a language SDK runtime
does, then waits for
+# StartupDetails. It exits with code 0 as soon as the supervisor sends
anything or closes the
+# connection. It exits with code 3 if 10 seconds pass and neither has happened.
+_RUNTIME_AWAITING_STARTUP = """
+import socket
+import sys
+
+def connect(flag):
+ host, _, port = next(arg for arg in sys.argv if
arg.startswith(flag)).split("=", 1)[1].rpartition(":")
+ return socket.create_connection((host, int(port)))
+
+comm = connect("--comm=")
+logs = connect("--logs=")
+comm.settimeout(10)
+try:
+ comm.recv(1)
+except TimeoutError:
+ sys.exit(3)
+"""
+
+
+class TestPopenActivitySubprocessClearXComs:
+ @pytest.mark.parametrize(
+ ("map_index", "query"),
+ [
+ pytest.param(-1, "", id="unmapped"),
+ pytest.param(0, "map_index=0", id="map-index-0"),
+ pytest.param(2, "map_index=2", id="map-index-2"),
+ ],
+ )
+ @pytest.mark.log_level(logging.DEBUG)
+ @patch.object(ActivitySubprocess, "_register_pipe_readers", autospec=True)
+ @patch("airflow.sdk.coordinators._subprocess._accept_connections",
autospec=True)
+ @patch("airflow.sdk.coordinators._subprocess.subprocess.Popen",
autospec=True)
+ def test_deletes_listed_xcoms_before_the_runtime_gets_the_task(
+ self, mock_popen, mock_accept, mock_register, make_ti_context_dict,
captured_logs, map_index, query
+ ):
+ ti = _make_ti(map_index=map_index)
+ ti_context = {
+ **make_ti_context_dict(),
+ "xcom_keys_to_clear": ["return_value", "skipmixin_key"],
+ "start_date": "2024-12-01T00:30:00Z",
+ "arg_bindings": [{"name": "country", "kind": "literal", "value":
"uk"}],
+ }
+ events: list = []
+
+ def logged_keys() -> list[tuple[str, str, str]]:
+ return [
+ (log["logger"], log["level"], log["key"])
+ for log in captured_logs
+ if log.get("event") == "Clearing XCom with key"
+ ]
+
+ def handle_request(request: httpx2.Request) -> httpx2.Response:
+ events.append((request.method, request.url.path,
request.url.query.decode(), logged_keys()))
+ if request.url.path.endswith("/run"):
+ return httpx2.Response(200, json=ti_context)
+ return httpx2.Response(200, json={})
+
+ def record_frame(frame: bytearray) -> None:
+ body = msgspec.msgpack.decode(frame[4:])[1]
+ events.append((body["type"],
TIRunContext.model_validate(body["ti_context"])))
+
+ comm_sock = MagicMock(spec=socket.socket)
+ comm_sock.sendall.side_effect = record_frame
+ mock_popen.return_value.pid = 12345
+ mock_accept.side_effect = lambda servers, drains, proc, **kw: (
+ {servers["comm"]: comm_sock, servers["logs"]:
MagicMock(spec=socket.socket)},
+ {soc: b"" for soc in drains.values()},
+ )
+
+ _PopenActivitySubprocess.start(
+ what=ti,
+ dag_rel_path="bundle",
+ bundle_info=FAKE_BUNDLE,
+ client=make_client(transport=httpx2.MockTransport(handle_request)),
+ command=["/bin/true"],
+ subprocess_logs_to_stdout=False,
+ )
+
+ first = ("task", "debug", "return_value")
+ second = ("task", "debug", "skipmixin_key")
+ assert events == [
+ ("PATCH", f"/task-instances/{ti.id}/run", "", []),
+ ("DELETE", "/xcoms/tutorial_dag/run_1/task_1/return_value", query,
[first]),
+ ("DELETE", "/xcoms/tutorial_dag/run_1/task_1/skipmixin_key",
query, [first, second]),
+ ("StartupDetails", TIRunContext.model_validate({**ti_context,
"xcom_keys_to_clear": []})),
+ ]
+
+ @pytest.mark.parametrize(
+ ("delete_error", "should_retry", "state", "error_text"),
+ [
+ pytest.param("forbidden", True, "up_for_retry", "Forbidden",
id="forbidden-retry"),
+ pytest.param("forbidden", False, "failed", "Forbidden",
id="forbidden-no-retry"),
+ pytest.param("unreachable", True, "up_for_retry", "Connection
refused", id="unreachable-retry"),
+ ],
+ )
+ # The patch turns off the API client's retry policy, so the case where the
API server is
+ # unreachable fails at once instead of after the client's retries and
their waits.
+ @patch.object(Client, "_request_with_retry", Client._request_without_retry)
+ @patch.object(ActivitySubprocess, "send_msg", autospec=True)
+ def
test_fails_the_try_without_starting_the_task_when_an_xcom_cannot_be_deleted(
+ self,
+ mock_send_msg,
+ make_ti_context_dict,
+ captured_logs,
+ time_machine,
+ delete_error,
+ should_retry,
+ state,
+ error_text,
+ ):
+ time_machine.move_to(datetime(2026, 1, 2, 3, 4, 5, tzinfo=UTC),
tick=False)
+ ti = _make_ti()
+ ti_context = {
+ **make_ti_context_dict(),
+ "xcom_keys_to_clear": ["return_value", "skipmixin_key", "summary"],
+ "should_retry": should_retry,
+ }
+ requests: list[tuple[str, str]] = []
+ reports: list[tuple[str, str, str]] = []
+
+ def handle_request(request: httpx2.Request) -> httpx2.Response:
+ requests.append((request.method, request.url.path))
+ if request.url.path.endswith("/run"):
+ return httpx2.Response(200, json=ti_context)
+ if request.method == "DELETE":
+ if not request.url.path.endswith("/skipmixin_key"):
+ return httpx2.Response(200, json={})
+ if delete_error == "unreachable":
+ raise httpx2.ConnectError("Connection refused",
request=request)
+ return httpx2.Response(403, json={"detail": "Forbidden"})
+ report = json.loads(request.content)
+ reports.append((report["state"], report["end_date"],
report["retry_reason"]))
+ return httpx2.Response(204)
+
+ proc = _PopenActivitySubprocess.start(
+ what=ti,
+ dag_rel_path="bundle",
+ bundle_info=FAKE_BUNDLE,
+ client=make_client(transport=httpx2.MockTransport(handle_request)),
+ command=[sys.executable, "-c", _RUNTIME_AWAITING_STARTUP],
+ subprocess_logs_to_stdout=False,
+ )
+
+ assert proc.wait() == -signal.SIGKILL
+ mock_send_msg.assert_not_called()
+ assert requests == [
+ ("PATCH", f"/task-instances/{ti.id}/run"),
+ ("DELETE", "/xcoms/tutorial_dag/run_1/task_1/return_value"),
+ ("DELETE", "/xcoms/tutorial_dag/run_1/task_1/skipmixin_key"),
+ ("PATCH", f"/task-instances/{ti.id}/state"),
+ ]
+ assert reports == [
+ (state, "2026-01-02T03:04:05Z", f"Failed to clear XCom
'skipmixin_key': {error_text}"),
+ ]
+ assert proc.final_state == state
+ assert {
+ "event": "Failed to clear XCom; not running the task",
+ "key": "skipmixin_key",
+ "level": "error",
+ "logger": "task",
+ "timestamp": ANY,
+ "exc_info": ANY,
+ "loc": ANY,
+ } in captured_logs
+
+
class TestResolveArtifactBundle:
"""Execute-time resolution of the Dag bundle holding the artifacts."""
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 7c3fee25cce..df7a8ba9a89 100644
--- a/task-sdk/tests/task_sdk/execution_time/test_supervisor.py
+++ b/task-sdk/tests/task_sdk/execution_time/test_supervisor.py
@@ -145,6 +145,7 @@ from airflow.sdk.execution_time.comms import (
SetTaskStateStore,
SetXCom,
SkipDownstreamTasks,
+ StartupDetails,
SucceedTask,
TaskBreadcrumbsResult,
TaskRescheduleStartDate,
@@ -1448,6 +1449,73 @@ class TestWatchedSubprocess:
target=subprocess_main,
)
+ def
test_prepare_ti_context_leaves_listed_xcoms_to_python_task_runner(self,
make_ti_context):
+ """
+ For Python tasks, the supervisor leaves the listed XComs to the Python
task runner.
+
+ The task runner deletes them with XCom.delete, a call that also purges
them from the XCom backend.
+ """
+ requests = []
+
+ def handle_request(request: httpx2.Request) -> httpx2.Response:
+ requests.append((request.method, request.url.path))
+ return httpx2.Response(200, json={})
+
+ proc = ActivitySubprocess(
+ process_log=MagicMock(spec=FilteringBoundLogger),
+ id=TI_ID,
+ pid=12345,
+ stdin=MagicMock(spec=socket.socket),
+ client=make_client(transport=httpx2.MockTransport(handle_request)),
+ process=MagicMock(spec=psutil.Process),
+ )
+ ti_context =
make_ti_context().model_copy(update={"xcom_keys_to_clear": ["return_value"]})
+ ti = TaskInstance(
+ id=TI_ID,
+ task_id="b",
+ dag_id="c",
+ run_id="d",
+ try_number=1,
+ dag_version_id=uuid7(),
+ queue="default",
+ )
+
+ try:
+ prepared = proc._prepare_ti_context(ti, ti_context)
+ finally:
+ proc.selector.close()
+ assert requests == []
+ assert prepared is not None
+ assert prepared.xcom_keys_to_clear == ["return_value"]
+
+ def test_python_task_process_gets_the_listed_xcom_keys(self, mocker,
make_ti_context):
+ mock_client = MagicMock(spec=sdk_client.Client)
+ mock_client.task_instances.start.return_value =
make_ti_context().model_copy(
+ update={"xcom_keys_to_clear": ["return_value"]}
+ )
+ mock_send = mocker.patch.object(ActivitySubprocess, "send_msg",
autospec=True)
+
+ proc = ActivitySubprocess.start(
+ dag_rel_path=os.devnull,
+ bundle_info=FAKE_BUNDLE,
+ what=TaskInstance(
+ id=uuid7(),
+ task_id="b",
+ dag_id="c",
+ run_id="d",
+ try_number=1,
+ dag_version_id=uuid7(),
+ queue="default",
+ ),
+ client=mock_client,
+ target=lambda: None,
+ )
+ assert proc.wait() == 0
+
+ (startup,) = [c.args[1] for c in mock_send.call_args_list if
isinstance(c.args[1], StartupDetails)]
+ assert startup.ti_context.xcom_keys_to_clear == ["return_value"]
+ mock_client.xcoms.delete.assert_not_called()
+
@pytest.mark.parametrize("captured_logs", [logging.WARNING], indirect=True)
def test_heartbeat_failures_handling(self, monkeypatch, mocker,
captured_logs, time_machine):
"""