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

vincbeck 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 9ef5f3d463f Add XCom read access to deadline callbacks run by 
executors (#74779)
9ef5f3d463f is described below

commit 9ef5f3d463f030884aeb9824dacd22a4d1784789
Author: Sean Ghaeli <[email protected]>
AuthorDate: Fri Oct 23 11:53:35 2026 -0700

    Add XCom read access to deadline callbacks run by executors (#74779)
    
    Executor-run deadline callbacks could read Connections and Variables
    through the callback supervisor but not XComs, so an alert could not
    include an upstream task's output. Serve GetXCom through the shared
    handle_get_xcom handler, and add context["xcom_pull"] bound to the
    callback's Dag run so a callback only names the task to pull from.
    
    Only reads are exposed. The request uses the execution token from the
    callback token exchange, so no API routes or token scopes change.
---
 airflow-core/docs/howto/deadline-alerts.rst        |  3 +
 .../sdk/execution_time/callback_supervisor.py      | 50 ++++++++++++++--
 .../execution_time/test_callback_supervisor.py     | 66 ++++++++++++++++++++++
 3 files changed, 113 insertions(+), 6 deletions(-)

diff --git a/airflow-core/docs/howto/deadline-alerts.rst 
b/airflow-core/docs/howto/deadline-alerts.rst
index 538173c3505..714397dafc0 100644
--- a/airflow-core/docs/howto/deadline-alerts.rst
+++ b/airflow-core/docs/howto/deadline-alerts.rst
@@ -387,6 +387,9 @@ as long as the variables being templated are included in 
the simplified context.
 ID and the calculated deadline time of the Deadline Alert as well as the data 
included in the ``GET`` REST API
 response for Dag Run. Support for more comprehensive context and templating 
will be added in future versions.
 
+Synchronous callbacks can also read XComs from tasks in the same Dag run 
through ``context["xcom_pull"]``,
+for example ``context["xcom_pull"]("extract")`` or 
``context["xcom_pull"]("extract", key="row_count")``.
+
 Deadline Calculation
 ^^^^^^^^^^^^^^^^^^^^
 
diff --git a/task-sdk/src/airflow/sdk/execution_time/callback_supervisor.py 
b/task-sdk/src/airflow/sdk/execution_time/callback_supervisor.py
index bc38e2abb20..a2861de413c 100644
--- a/task-sdk/src/airflow/sdk/execution_time/callback_supervisor.py
+++ b/task-sdk/src/airflow/sdk/execution_time/callback_supervisor.py
@@ -39,12 +39,14 @@ from airflow.sdk.execution_time.comms import (
     GetConnection,
     GetVariable,
     GetVariableKeys,
+    GetXCom,
     MaskSecret,
 )
 from airflow.sdk.execution_time.request_handlers import (
     handle_get_connection,
     handle_get_variable,
     handle_get_variable_keys,
+    handle_get_xcom,
     handle_mask_secret,
 )
 from airflow.sdk.execution_time.supervisor import (
@@ -78,14 +80,46 @@ log: FilteringBoundLogger = 
structlog.get_logger(logger_name="callback_superviso
 
 
 # The set of messages that a callback subprocess can send to the supervisor.
-# This is a minimal subset of ToSupervisor: read-only access to Connections
-# and Variables, plus MaskSecret for the secrets masker.
+# This is a minimal subset of ToSupervisor: read-only access to Connections,
+# Variables, and XCom values, plus MaskSecret for the secrets masker.
 CallbackToSupervisor = Annotated[
-    GetConnection | GetVariable | GetVariableKeys | MaskSecret,
+    GetConnection | GetVariable | GetVariableKeys | GetXCom | MaskSecret,
     Field(discriminator="type"),
 ]
 
 
+def _add_xcom_pull_to_context(callback_kwargs: dict) -> None:
+    """
+    Add an ``xcom_pull`` helper bound to the callback's Dag run to its context.
+
+    Callbacks have no task instance, so the helper takes ``dag_id`` and 
``dag_run_id`` from the
+    ``dag_run`` in the context and the callback only names the task to pull 
from.
+    """
+    context = callback_kwargs.get("context")
+    if not isinstance(context, dict):
+        return
+    dag_run = context.get("dag_run")
+    if not isinstance(dag_run, dict) or not dag_run.get("dag_id") or not 
dag_run.get("dag_run_id"):
+        log.warning("Callback context has no Dag run, so xcom_pull is not 
available")
+        return
+
+    def xcom_pull(
+        task_id: str, *, key: str = "return_value", map_index: int | None = 
None, default: Any = None
+    ) -> Any:
+        from airflow.sdk.execution_time.xcom import XCom
+
+        value = XCom.get_one(
+            key=key,
+            dag_id=dag_run["dag_id"],
+            run_id=dag_run["dag_run_id"],
+            task_id=task_id,
+            map_index=map_index,
+        )
+        return default if value is None else value
+
+    context["xcom_pull"] = xcom_pull
+
+
 SERDE_KWARGS_ENCODING = "serde"
 
 
@@ -209,9 +243,10 @@ class CallbackSubprocess(WatchedSubprocess):
     Uses the WatchedSubprocess infrastructure for fork/monitor/signal handling
     while keeping a simple lifecycle: start, run callback, exit.
 
-    Provides a limited set of comms channels (Connections and Variables) so
-    that callback code can access runtime services like
-    ``Connection.get()`` and ``Variable.get()`` via the supervisor's API 
client.
+    Provides a limited set of comms channels (Connections, Variables, and XCom)
+    so that callback code can access runtime services like
+    ``Connection.get()``, ``Variable.get()``, and ``context["xcom_pull"]()`` 
via
+    the supervisor's API client.
     """
 
     client: Client  # The HTTP client to use for communication with the API 
server.
@@ -270,6 +305,7 @@ class CallbackSubprocess(WatchedSubprocess):
                         exc_info=True,
                     )
 
+            _add_xcom_pull_to_context(callback_kwargs)
             success, error_msg = execute_callback(
                 callback_path=callback_path,
                 callback_kwargs=callback_kwargs,
@@ -391,6 +427,8 @@ class CallbackSubprocess(WatchedSubprocess):
             resp, dump_opts = handle_get_variable(self.client, msg)
         elif isinstance(msg, GetVariableKeys):
             resp, dump_opts = handle_get_variable_keys(self.client, msg)
+        elif isinstance(msg, GetXCom):
+            resp, dump_opts = handle_get_xcom(self.client, msg)
         elif isinstance(msg, MaskSecret):
             handle_mask_secret(msg)
         else:
diff --git a/task-sdk/tests/task_sdk/execution_time/test_callback_supervisor.py 
b/task-sdk/tests/task_sdk/execution_time/test_callback_supervisor.py
index 7eb5bc9c949..36c0cf52ee8 100644
--- a/task-sdk/tests/task_sdk/execution_time/test_callback_supervisor.py
+++ b/task-sdk/tests/task_sdk/execution_time/test_callback_supervisor.py
@@ -31,6 +31,7 @@ from unittest.mock import ANY, Mock, patch
 import pytest
 import structlog
 
+from airflow.sdk.api.datamodels._generated import XComResponse
 from airflow.sdk.execution_time.callback_supervisor import (
     CallbackSubprocess,
     Path,
@@ -43,9 +44,11 @@ from airflow.sdk.execution_time.comms import (
     GetConnection,
     GetVariable,
     GetVariableKeys,
+    GetXCom,
     MaskSecret,
     VariableKeysResult,
     VariableResult,
+    XComResult,
     _RequestFrame,
 )
 
@@ -320,6 +323,38 @@ class TestCallbackHandleRequest:
                 response=VariableKeysResult(keys=["test_key"], 
total_entries=1),
             ),
         ),
+        RequestCase(
+            message=GetXCom(
+                key="return_value",
+                dag_id="test_dag",
+                run_id="test_run_1",
+                task_id="upstream_task",
+            ),
+            test_id="get_xcom",
+            client_mock=ClientMock(
+                method_path="xcoms.get",
+                args=("test_dag", "test_run_1", "upstream_task", 
"return_value", None, False),
+                kwargs={"previous_iteration": False},
+                response=XComResponse(key="return_value", 
value="xcom_payload"),
+            ),
+        ),
+        RequestCase(
+            message=GetXCom(
+                key="custom_key",
+                dag_id="dag_a",
+                run_id="run_42",
+                task_id="task_b",
+                map_index=3,
+                include_prior_dates=True,
+            ),
+            test_id="get_xcom_with_map_index",
+            client_mock=ClientMock(
+                method_path="xcoms.get",
+                args=("dag_a", "run_42", "task_b", "custom_key", 3, True),
+                kwargs={"previous_iteration": False},
+                response=XComResponse(key="custom_key", value={"nested": 
"data"}),
+            ),
+        ),
         RequestCase(
             message=MaskSecret(value="super_secret", name="api_key"),
             test_id="mask_secret",
@@ -639,6 +674,37 @@ class TestCallbackSubprocessStart:
 
         assert exc_info.value.code == 1
 
+    def _run_target_with_context(self, base_start_kwargs, context):
+        CallbackSubprocess.start(**{**base_start_kwargs, "callback_kwargs": 
{"context": context}})
+        self.mock_super_start.call_args.kwargs["target"]()
+        return 
self.mock_execute_callback.call_args.kwargs["callback_kwargs"]["context"]
+
+    @pytest.mark.parametrize(
+        ("xcom_value", "expected"),
+        [
+            pytest.param("payload", "payload", id="value"),
+            pytest.param(None, "fallback", id="missing"),
+        ],
+    )
+    def test_context_xcom_pull_uses_dag_run_ids(self, base_start_kwargs, 
xcom_value, expected):
+        comms = self.mock_comms_decoder.__getitem__.return_value.return_value
+        comms.send.return_value = XComResult(key="out", value=xcom_value)
+
+        context = self._run_target_with_context(
+            base_start_kwargs, {"dag_run": {"dag_id": "dag_a", "dag_run_id": 
"run_1"}}
+        )
+
+        assert context["xcom_pull"]("upstream", key="out", map_index=2, 
default="fallback") == expected
+        comms.send.assert_called_once_with(
+            GetXCom(key="out", dag_id="dag_a", run_id="run_1", 
task_id="upstream", map_index=2)
+        )
+
+    def test_context_without_dag_run_has_no_xcom_pull(self, base_start_kwargs, 
caplog):
+        context = self._run_target_with_context(base_start_kwargs, 
{"deadline": {"id": "x"}})
+
+        assert "xcom_pull" not in context
+        assert "Callback context has no Dag run, so xcom_pull is not 
available" in caplog
+
     @pytest.mark.parametrize("option_value", ["True", "False"])
     def test_start_keeps_bare_fork_regardless_of_exec_option(self, 
base_start_kwargs, option_value):
         """

Reply via email to