This is an automated email from the ASF dual-hosted git repository.
ashb 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 83b7552da0a Resolve a trigger's XCom requests as its deferred task
instance (#74772) (#74868)
83b7552da0a is described below
commit 83b7552da0ad8416b115d24184a83f1b76d072d2
Author: Ash Berlin-Taylor <[email protected]>
AuthorDate: Sat Oct 17 19:43:41 2026 +0100
Resolve a trigger's XCom requests as its deferred task instance (#74772)
(#74868)
* Resolve a trigger's XCom requests as its deferred task instance
Note: _None_ of this is a security boundary, and should not be treated as
such.
The Execution API resolves XCom reads and writes for a task inside a loop
to the caller's own iteration, using the task identity in the caller's JWT.
Triggers have no such identity: the triggerer talks to an in-process API
that treats every request as the all-zero task instance. A trigger's XCom
requests therefore resolve as an anonymous caller rather than as the
deferred task it runs for, so it can neither read (a 400) nor write the
XComs of a task inside a loop.
Triggers can also share one runner coroutine and one comms channel (the
"shared streams" idea), so only the asyncio task running a trigger's code
knows which trigger it is; a context variable carries that trigger's task
instance id to each request frame. Shared-stream groups are started from
whichever trigger subscribes first but serve several triggers, so they start
with it cleared rather than inheriting that trigger's id. This isn't ideal,
but will do "for now".
The trigger runner process (the one running async code) sets the task
instance id, and since that process runs user code a trigger can claim to be
any task instance. The (in-process) API therefore checks the claim. A write
acts as the task instance only when it is deferred and its trigger is
already assigned to the calling triggerer. A read acts as it whenever that
try is still live, because after a user marks a deferred task failed or
successful the trigger row is gone and the triggerer still runs the
trigger's on_kill(). The most a misbehaving trigger can do is write as
another task that is deferred on the same triggerer, or read as any live
task, and it can already read and write every task's XComs by coordinates
through the in-process API. As stated, this is _not_ a security boundary we
can rely on
Requests that fail the check are rejected rather than falling back to the
anonymous identity, deliberately: a trigger whose task instance is no
longer deferred, or whose trigger was reassigned, should not still be
writing its task's XComs, and falling back would only work for tasks
outside loops. Clearing a deferred task archives its try before on_kill()
runs, so on_kill() after a clear cannot read its task's XComs; a trigger
that needs a job id there should keep it on the trigger, as the in-tree
triggers already do.
Only the in-process API that the triggerer creates knows which triggerer
is calling, so every other API rejects the header rather than trusting it.
A triggerer that eventually talks to a real API server will need its own
credential before this can work there.
Only the XCom routes honour the trigger's task instance id for now.
get_previous_task_instance also resolves relative to the caller, so a
trigger asking for its previous task instance still gets the latest pass
rather than the one at its own loop position. Every other request from the
triggerer keeps its current identity, so access rules and the live-attempt
check on those routes are unchanged.
* fixup! Resolve a trigger's XCom requests as its deferred task instance
Pin the HEAD and DELETE rules in the trigger claim check and assert which
check denied each 403, so loosening the read-method test no longer passes.
* fixup! Resolve a trigger's XCom requests as its deferred task instance
Ignore a trigger failure reported by a triggerer that no longer owns the
trigger. The XCom write check now answers 403 to a stale copy on its
previous owner, which raises before yielding and would otherwise have
Trigger.submit_failure send the task to __fail__ while the new owner is
still running it.
(cherry picked from commit 61f1d4b85e)
Adapted to v3-4-test's CurrentTIToken: the typed execution tokens from the
Dag processor scoped access work (#74370) are not on this branch.
---
.../api_fastapi/execution_api/in_process.py | 15 +-
.../api_fastapi/execution_api/routes/xcoms.py | 135 +++++++++++---
.../airflow/api_fastapi/execution_api/security.py | 1 +
.../src/airflow/jobs/triggerer_job_runner.py | 58 +++++-
airflow-core/src/airflow/models/trigger.py | 18 +-
.../execution_api/versions/head/test_xcoms.py | 156 ++++++++++++++++
airflow-core/tests/unit/jobs/test_triggerer_job.py | 207 +++++++++++++++++++--
airflow-core/tests/unit/models/test_trigger.py | 27 ++-
task-sdk/src/airflow/sdk/execution_time/comms.py | 5 +
.../src/airflow/sdk/execution_time/supervisor.py | 10 +-
10 files changed, 556 insertions(+), 76 deletions(-)
diff --git a/airflow-core/src/airflow/api_fastapi/execution_api/in_process.py
b/airflow-core/src/airflow/api_fastapi/execution_api/in_process.py
index fe657354e02..8fd8d93e5ed 100644
--- a/airflow-core/src/airflow/api_fastapi/execution_api/in_process.py
+++ b/airflow-core/src/airflow/api_fastapi/execution_api/in_process.py
@@ -35,6 +35,8 @@ if TYPE_CHECKING:
logger = structlog.get_logger(logger_name=__name__)
+TRIGGER_TI_ID_HEADER = "Airflow-Trigger-Ti-Id"
+
# Finalizer arguments must not reference the transport, or it cannot be
garbage collected.
def _shutdown_loop(
@@ -51,12 +53,16 @@ def _shutdown_loop(
thread.join(timeout=5)
-def _configure_in_process_auth(app: FastAPI) -> None:
+def _configure_in_process_auth(app: FastAPI, *, triggerer_id: int | None =
None) -> None:
from airflow.api_fastapi.execution_api.datamodels.token import TIClaims,
TIToken
from airflow.api_fastapi.execution_api.routes.connections import
has_connection_access
from airflow.api_fastapi.execution_api.routes.variables import
has_variable_access
from airflow.api_fastapi.execution_api.routes.xcoms import has_xcom_access
- from airflow.api_fastapi.execution_api.security import
_IN_PROCESS_NON_TI_CALLER, _jwt_bearer
+ from airflow.api_fastapi.execution_api.security import (
+ _IN_PROCESS_NON_TI_CALLER,
+ _IN_PROCESS_TRIGGERER_ID,
+ _jwt_bearer,
+ )
async def always_allow(request: Request):
from uuid import UUID
@@ -71,6 +77,8 @@ def _configure_in_process_auth(app: FastAPI) -> None:
"task_instance_id" not in request.path_params
and "X-Airflow-In-Process-Attempt-Id" not in request.headers
)
+ if triggerer_id is not None:
+ request.scope[_IN_PROCESS_TRIGGERER_ID] = triggerer_id
claims = TIClaims(scope="execution")
return TIToken(id=ti_id, claims=claims)
@@ -90,6 +98,7 @@ class InProcessExecutionAPI:
"""
_app: FastAPI | None = None
+ triggerer_id: int | None = None
@cached_property
def app(self):
@@ -105,7 +114,7 @@ class InProcessExecutionAPI:
self._app =
create_task_execution_api_app(lifespan=attrs.evolve(lifespan,
registry=registry))
# Auth dependency overrides bypass validation, so the stub skips
validator setup in lifespan().
registry.register_value(JWTValidator, None)
- _configure_in_process_auth(self._app)
+ _configure_in_process_auth(self._app,
triggerer_id=self.triggerer_id)
return self._app
diff --git a/airflow-core/src/airflow/api_fastapi/execution_api/routes/xcoms.py
b/airflow-core/src/airflow/api_fastapi/execution_api/routes/xcoms.py
index 02fb6eceb6b..ad9d1d42939 100644
--- a/airflow-core/src/airflow/api_fastapi/execution_api/routes/xcoms.py
+++ b/airflow-core/src/airflow/api_fastapi/execution_api/routes/xcoms.py
@@ -24,6 +24,7 @@ from typing import TYPE_CHECKING, Annotated
from uuid import UUID
import attrs
+from cadwyn import current_dependency_solver
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query,
Request, Response, status
from pydantic import JsonValue
from sqlalchemy import select
@@ -33,12 +34,14 @@ from airflow.api_fastapi.common.dagbag import DagBagDep
from airflow.api_fastapi.common.db.common import SessionDep
from airflow.api_fastapi.core_api.base import BaseModel
from airflow.api_fastapi.core_api.openapi.exceptions import
create_openapi_http_exception_doc
+from airflow.api_fastapi.execution_api.datamodels.token import TIToken
from airflow.api_fastapi.execution_api.datamodels.xcom import (
XComResponse,
XComSequenceIndexResponse,
XComSequenceSliceResponse,
)
-from airflow.api_fastapi.execution_api.security import CurrentTIToken
+from airflow.api_fastapi.execution_api.in_process import TRIGGER_TI_ID_HEADER
+from airflow.api_fastapi.execution_api.security import
_IN_PROCESS_TRIGGERER_ID, CurrentTIToken
from airflow.api_fastapi.execution_api.versions.v2026_10_30 import
IdentifyArchivedTaskStateUpdates
from airflow.exceptions import TaskNotFound
from airflow.models.dagrun import DagRun, InvalidLoopDecision,
validate_loop_decision
@@ -55,6 +58,7 @@ from airflow.models.task_coordinates import (
enclosing_loop,
)
from airflow.models.taskinstance import TaskInstance
+from airflow.models.trigger import Trigger
from airflow.models.xcom import XCOM_RETURN_KEY, XComModel, xcom_entity
from airflow.utils.db import get_query_count
from airflow.utils.state import TaskInstanceState
@@ -64,7 +68,6 @@ if TYPE_CHECKING:
from sqlalchemy.orm.util import AliasedClass
from sqlalchemy.sql.elements import ColumnElement
- from airflow.api_fastapi.execution_api.datamodels.token import TIToken
from airflow.models.dagbag import DBDagBag
@@ -242,29 +245,79 @@ def _build_xcom_read(
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(error)) from error
-def xcom_query(
- dag_id: str,
- run_id: str,
- task_id: str,
- key: str,
+def _resolve_trigger_token(
+ request: Request,
session: SessionDep,
- dag_bag: DagBagDep,
- map_index: Annotated[int | None, Query()] = None,
- previous_iteration: bool = False,
- token=CurrentTIToken,
-) -> Select:
- return _build_xcom_read(
- dag_id=dag_id,
- run_id=run_id,
- task_id=task_id,
- key=key,
- session=session,
- dag_bag=dag_bag,
- token=token,
- map_index=map_index,
- previous_iteration=previous_iteration,
- sequence=True,
- ).statement
+ token: TIToken = CurrentTIToken,
+ dependency_solver=Depends(current_dependency_solver),
+) -> TIToken:
+ """
+ Return the identity an XCom request acts as.
+
+ A request made by a trigger's code carries its task instance's id in
``TRIGGER_TI_ID_HEADER``. The
+ triggerer subprocess can forge the header, so it is only honoured on the
triggerer's in-process API.
+
+ A write acts as the claimed task instance only while it is deferred on a
trigger assigned to the calling
+ triggerer. A read acts as it while that try is live, in any state, because
a trigger's ``on_kill`` runs
+ after the task instance leaves ``deferred`` and the trigger row is gone;
reads are not a security
+ boundary on this API, which already serves every XCom. Anything else gets
a 403.
+ """
+ header = request.headers.get(TRIGGER_TI_ID_HEADER)
+ # The endpoint runs with Cadwyn's re-solved dependencies, so checking
there reuses the endpoint's session.
+ if header is None or dependency_solver != "cadwyn":
+ return token
+
+ triggerer_id = request.scope.get(_IN_PROCESS_TRIGGERER_ID)
+ if triggerer_id is None:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail={
+ "reason": "access_denied",
+ "message": f"{TRIGGER_TI_ID_HEADER} is only accepted from the
triggerer",
+ },
+ )
+
+ try:
+ claimed = UUID(header)
+ except ValueError:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail={"reason": "invalid_request", "message":
f"{TRIGGER_TI_ID_HEADER} must be a UUID"},
+ )
+
+ if request.method in ("GET", "HEAD"):
+ permitted = (
+ session.scalar(
+ select(TaskInstance.working_set)
+ .where(TaskInstance.id == claimed)
+ .execution_options(include_all_attempts=True)
+ )
+ is True
+ )
+ denial = "Task instance is not a live attempt"
+ else:
+ permitted = (
+ session.scalar(
+ select(TaskInstance.id)
+ .join(Trigger, Trigger.id == TaskInstance.trigger_id)
+ .where(
+ TaskInstance.id == claimed,
+ Trigger.triggerer_id == triggerer_id,
+ TaskInstance.state == TaskInstanceState.DEFERRED,
+ )
+ )
+ is not None
+ )
+ denial = "Task instance is not deferred on a trigger assigned to this
triggerer"
+ if not permitted:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail={"reason": "access_denied", "message": denial},
+ )
+ return token.model_copy(update={"id": claimed})
+
+
+_TriggerAwareToken = Depends(_resolve_trigger_token)
@router.get(
@@ -286,7 +339,7 @@ def get_mapped_xcom_by_index(
session: SessionDep,
dag_bag: DagBagDep,
previous_iteration: bool = False,
- token=CurrentTIToken,
+ token=_TriggerAwareToken,
) -> XComSequenceIndexResponse:
xcom_read = _build_xcom_read(
dag_id=dag_id,
@@ -352,7 +405,7 @@ def get_mapped_xcom_by_slice(
params: Annotated[GetXComSliceFilterParams, Query()],
session: SessionDep,
dag_bag: DagBagDep,
- token=CurrentTIToken,
+ token=_TriggerAwareToken,
) -> XComSequenceSliceResponse:
xcom_read = _build_xcom_read(
dag_id=dag_id,
@@ -435,7 +488,10 @@ def get_mapped_xcom_by_slice(
"/{dag_id}/{run_id}/{task_id}/{key:path}",
responses={
**create_openapi_http_exception_doc(
- [(status.HTTP_400_BAD_REQUEST, "map_index cannot be specified in a
HEAD request")]
+ [
+ (status.HTTP_400_BAD_REQUEST, "map_index cannot be specified
in a HEAD request"),
+ (status.HTTP_409_CONFLICT, "Several live task instances
produce the requested value"),
+ ]
),
status.HTTP_200_OK: {
"description": "Metadata about the number of matching XCom values",
@@ -450,10 +506,16 @@ def get_mapped_xcom_by_slice(
description="Returns the count of mapped XCom values found in the
`Content-Range` response header",
)
def head_xcom(
+ dag_id: str,
+ run_id: str,
+ task_id: str,
+ key: str,
response: Response,
session: SessionDep,
- xcom_query: Annotated[Select, Depends(xcom_query)],
+ dag_bag: DagBagDep,
map_index: Annotated[int | None, Query()] = None,
+ previous_iteration: bool = False,
+ token=_TriggerAwareToken,
) -> None:
"""Get the count of XComs from database - not other XCom Backends."""
if map_index is not None:
@@ -462,6 +524,17 @@ def head_xcom(
detail={"reason": "invalid_request", "message": "Cannot specify
map_index in a HEAD request"},
)
+ xcom_query = _build_xcom_read(
+ dag_id=dag_id,
+ run_id=run_id,
+ task_id=task_id,
+ key=key,
+ session=session,
+ dag_bag=dag_bag,
+ token=token,
+ previous_iteration=previous_iteration,
+ sequence=True,
+ ).statement
count = get_query_count(xcom_query, session=session)
# Tell the caller how many items in this query. We define a custom range
unit (HTTP spec only defines
# "bytes" but we can add our own)
@@ -495,7 +568,7 @@ def get_xcom(
session: SessionDep,
params: Annotated[GetXcomFilterParams, Query()],
dag_bag: DagBagDep,
- token=CurrentTIToken,
+ token=_TriggerAwareToken,
) -> XComResponse:
"""Get an Airflow XCom from database - not other XCom Backends."""
owner = session.execute(
@@ -627,7 +700,7 @@ def set_xcom(
mapped_length: Annotated[
int | None, Query(ge=0, description="Number of mapped tasks this value
expands into")
] = None,
- token=CurrentTIToken,
+ token=_TriggerAwareToken,
):
"""Set an Airflow XCom."""
from airflow.configuration import conf
@@ -757,7 +830,7 @@ def delete_xcom(
task_id: str,
key: Annotated[str, Path(min_length=1)],
map_index: Annotated[int, Query()] = -1,
- token=CurrentTIToken,
+ token=_TriggerAwareToken,
):
"""Delete a single XCom Value."""
owner = _find_writer_id(
diff --git a/airflow-core/src/airflow/api_fastapi/execution_api/security.py
b/airflow-core/src/airflow/api_fastapi/execution_api/security.py
index 6efd453c4f8..2c121b6e4ca 100644
--- a/airflow-core/src/airflow/api_fastapi/execution_api/security.py
+++ b/airflow-core/src/airflow/api_fastapi/execution_api/security.py
@@ -95,6 +95,7 @@ VALID_TOKEN_TYPES: frozenset[str] =
frozenset(get_args(TokenScope))
_REQUEST_SCOPE_TOKEN_KEY = "ti_token"
_REQUEST_SCOPE_LIVE_ATTEMPT_KEY = "live_attempt_checked"
_IN_PROCESS_NON_TI_CALLER = "airflow_in_process_non_ti_caller"
+_IN_PROCESS_TRIGGERER_ID = "airflow_in_process_triggerer_id"
_SKIP_AUTO_TI_ATTEMPT_LIVE = "skip_auto_ti_attempt_live"
_P = ParamSpec("_P")
_R = TypeVar("_R")
diff --git a/airflow-core/src/airflow/jobs/triggerer_job_runner.py
b/airflow-core/src/airflow/jobs/triggerer_job_runner.py
index 9552f90d63b..24a1432a2d8 100644
--- a/airflow-core/src/airflow/jobs/triggerer_job_runner.py
+++ b/airflow-core/src/airflow/jobs/triggerer_job_runner.py
@@ -17,6 +17,7 @@
from __future__ import annotations
import asyncio
+import contextvars
import functools
import logging
import math
@@ -50,7 +51,7 @@ from airflow._shared.module_loading import import_string
from airflow._shared.observability.metrics import stats
from airflow._shared.timezones import timezone
from airflow.api_fastapi.execution_api.datamodels.taskinstance import
task_instance_to_runtime
-from airflow.api_fastapi.execution_api.in_process import InProcessExecutionAPI
+from airflow.api_fastapi.execution_api.in_process import TRIGGER_TI_ID_HEADER,
InProcessExecutionAPI
from airflow.configuration import conf
from airflow.exceptions import TaskNotFound
from airflow.executors import workloads
@@ -140,6 +141,9 @@ from airflow.utils.log.task_log_address import
prepare_task_log_contexts, render
from airflow.utils.session import create_session, provide_session
if TYPE_CHECKING:
+ from uuid import UUID
+
+ import httpx2
from opentelemetry.util._decorator import _AgnosticContextManager
from sqlalchemy.orm import Session
from structlog.typing import FilteringBoundLogger, WrappedLogger
@@ -160,6 +164,23 @@ _REASSIGNED_CANCEL_MSG = "__airflow_reassigned__"
_ON_CANCEL_TIMEOUT: int = conf.getint("triggerer", "on_kill_timeout",
fallback=30)
+# Task instance of the trigger whose code is running, so its SDK requests act
as that task instance; tasks
+# serving several triggers must not inherit it.
+_current_trigger_ti_id: contextvars.ContextVar[UUID | None] =
contextvars.ContextVar(
+ "airflow_current_trigger_ti_id", default=None
+)
+
+# Task instance of the trigger whose request the supervisor is handling, sent
to the API as
+# ``TRIGGER_TI_ID_HEADER``.
+_handling_trigger_ti_id: contextvars.ContextVar[UUID | None] =
contextvars.ContextVar(
+ "airflow_handling_trigger_ti_id", default=None
+)
+
+
+def _add_trigger_ti_id_header(request: httpx2.Request) -> None:
+ if (ti_id := _handling_trigger_ti_id.get()) is not None:
+ request.headers[TRIGGER_TI_ID_HEADER] = str(ti_id)
+
def _make_trigger_span(
ti: TaskInstanceDTO | None, trigger_id: int, name: str
@@ -581,11 +602,23 @@ class TriggerRunnerSupervisor(WatchedSubprocess):
"""
from airflow.sdk.api.client import Client
- client = Client(base_url=None, token="", dry_run=True,
transport=InProcessExecutionAPI().transport)
+ triggerer_id = self.job.id if self.job is not None else None
+ in_process_api = InProcessExecutionAPI(triggerer_id=triggerer_id)
+ client = Client(base_url=None, token="", dry_run=True,
transport=in_process_api.transport)
# Mypy is wrong -- the setter accepts a string on the property setter!
`URLType = URL | str`
client.base_url = "http://in-process.invalid./"
+ if triggerer_id is not None:
+ client.event_hooks["request"].append(_add_trigger_ti_id_header)
return client
+ @contextmanager
+ def _request_scope(self, request: _RequestFrame) -> Iterator[None]:
+ token = _handling_trigger_ti_id.set(request.ti_id)
+ try:
+ yield
+ finally:
+ _handling_trigger_ti_id.reset(token)
+
def _handle_request(self, msg: ToTriggerSupervisor, log:
FilteringBoundLogger, req_id: int) -> None:
resp: BaseModel | None = None
@@ -807,7 +840,9 @@ class TriggerRunnerSupervisor(WatchedSubprocess):
def on_trigger_failure(self, trigger_id: int, exc: list[str] | None) ->
None:
"""Record that a trigger failed."""
- Trigger.submit_failure(trigger_id=trigger_id, exc=exc)
+ Trigger.submit_failure(
+ trigger_id=trigger_id, exc=exc, triggerer_id=self.job.id if
self.job is not None else None
+ )
def metric_tags(self) -> dict[str, str]:
"""
@@ -1212,7 +1247,9 @@ class TriggerCommsDecoder(CommsDecoder[ToTriggerRunner,
ToTriggerSupervisor]):
# non-blocking for the foreign loop.
cf = asyncio.run_coroutine_threadsafe(self.asend(msg), self._loop)
return await asyncio.wrap_future(cf)
- frame = _RequestFrame(id=next(self.id_counter), body=msg.model_dump())
+ frame = _RequestFrame(
+ id=next(self.id_counter), body=msg.model_dump(),
ti_id=_current_trigger_ti_id.get()
+ )
future: asyncio.Future = current_loop.create_future()
self._pending[frame.id] = future
try:
@@ -1686,6 +1723,8 @@ class TriggerRunner:
await greenback.ensure_portal()
ti = trigger.task_instance
+ if ti is not None:
+ _current_trigger_ti_id.set(ti.id)
bind_log_contextvars(
trigger_id=trigger_id,
ti_id=str(ti.id) if ti else None,
@@ -1727,8 +1766,15 @@ class TriggerRunner:
shared_key = None
if shared_key is not None and event_trigger is not None:
- shared_stream = self._shared_streams.subscribe(
- trigger_id=trigger_id, trigger=event_trigger,
key=shared_key
+ # subscribe() may start the group's poll task, which
copies the current context but
+ # serves several triggers.
+ subscribe_ctx = contextvars.copy_context()
+ subscribe_ctx.run(_current_trigger_ti_id.set, None)
+ shared_stream = subscribe_ctx.run(
+ self._shared_streams.subscribe,
+ trigger_id=trigger_id,
+ trigger=event_trigger,
+ key=shared_key,
)
event_stream =
event_trigger.filter_shared_stream(shared_stream)
else:
diff --git a/airflow-core/src/airflow/models/trigger.py
b/airflow-core/src/airflow/models/trigger.py
index 624b09d276d..1c0e1461604 100644
--- a/airflow-core/src/airflow/models/trigger.py
+++ b/airflow-core/src/airflow/models/trigger.py
@@ -305,10 +305,15 @@ class Trigger(Base):
@classmethod
@provide_session
- def submit_failure(cls, trigger_id, exc=None, *, session: Session =
NEW_SESSION) -> None:
+ def submit_failure(
+ cls, trigger_id, exc=None, *, triggerer_id: int | None = None,
session: Session = NEW_SESSION
+ ) -> None:
"""
When a trigger has failed unexpectedly, mark everything that depended
on it as failed.
+ With ``triggerer_id``, only act while that triggerer still owns the
trigger: a reassigned
+ trigger is being run elsewhere, so a failure from its previous owner
must not fail the task.
+
Notably, we have to actually run the failure code from a worker as it
may
have linked callbacks, so hilariously we have to re-schedule the task
instances to a worker just so they can then fail.
@@ -317,11 +322,12 @@ class Trigger(Base):
the runtime code understands as immediate-fail, and pack the error into
next_kwargs.
"""
- for task_instance in session.scalars(
- select(TaskInstance).where(
- TaskInstance.trigger_id == trigger_id, TaskInstance.state ==
TaskInstanceState.DEFERRED
- )
- ):
+ query = select(TaskInstance).where(
+ TaskInstance.trigger_id == trigger_id, TaskInstance.state ==
TaskInstanceState.DEFERRED
+ )
+ if triggerer_id is not None:
+ query = query.join(cls, cls.id ==
TaskInstance.trigger_id).where(cls.triggerer_id == triggerer_id)
+ for task_instance in session.scalars(query):
# Add the error and set the next_method to the fail state
if isinstance(exc, BaseException):
traceback = format_exception(type(exc), exc, exc.__traceback__)
diff --git
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_xcoms.py
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_xcoms.py
index 5c8d49c3f1e..80f844eb3cf 100644
---
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_xcoms.py
+++
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_xcoms.py
@@ -25,6 +25,7 @@ from uuid import uuid4
import pytest
from fastapi import FastAPI, HTTPException, Path, Request, status
+from fastapi.testclient import TestClient
from sqlalchemy import delete, event, select, update
from sqlalchemy.orm import Session
@@ -32,12 +33,14 @@ from airflow._shared.timezones import timezone
from airflow.api_fastapi.auth.tokens import JWTValidator
from airflow.api_fastapi.execution_api.datamodels.token import TIClaims,
TIToken
from airflow.api_fastapi.execution_api.datamodels.xcom import XComResponse
+from airflow.api_fastapi.execution_api.in_process import TRIGGER_TI_ID_HEADER,
InProcessExecutionAPI
from airflow.api_fastapi.execution_api.security import _jwt_bearer,
require_auth
from airflow.exceptions import TaskNotFound
from airflow.models.dagrun import DagRun
from airflow.models.dynamic_region import SENTINEL_REGION_ID, DynamicRegion
from airflow.models.task_coordinates import TaskCoordinateResolver
from airflow.models.taskinstance import LegacyTaskDataOwner, TaskInstance
+from airflow.models.trigger import Trigger
from airflow.models.xcom import XCOM_RETURN_KEY, XComModel, XComModelV1,
XComModelV2, xcom_entity
from airflow.providers.standard.operators.empty import EmptyOperator
from airflow.providers.standard.operators.python import PythonOperator
@@ -49,9 +52,12 @@ from airflow.utils.state import DagRunState,
TaskInstanceState
from tests_common.test_utils.asserts import capture_orm_selects
from tests_common.test_utils.config import conf_vars
+from tests_common.test_utils.db import clear_db_triggers
pytestmark = pytest.mark.db_test
+TRIGGERER_ID = 7
+
@pytest.mark.parametrize("suffix", ["", "/item/0", "/item/-1", "/slice"])
def
test_regional_mapped_xcom_reads_resolve_live_producers_before_slicing(client,
dag_maker, session, suffix):
@@ -486,6 +492,156 @@ def
test_loop_task_reads_from_inside_the_loop_stay_on_one_pass(client, outside_l
assert client.get(f"{fanout_url}/item/-1").json() == "1-1"
[email protected]
+def deferred_loop_pass(session, outside_loop_run):
+ dr, _, _, steps, _ = outside_loop_run
+ trigger = Trigger(classpath="airflow.triggers.testing.SuccessTrigger",
kwargs={})
+ trigger.triggerer_id = TRIGGERER_ID
+ session.add(trigger)
+ session.flush()
+ steps[1].state = TaskInstanceState.DEFERRED
+ steps[1].trigger_id = trigger.id
+ session.commit()
+ yield dr, steps, trigger
+ clear_db_triggers()
+
+
+def test_trigger_in_a_loop_reads_and_writes_as_its_deferred_pass(session,
deferred_loop_pass):
+ dr, steps, _ = deferred_loop_pass
+ headers = {TRIGGER_TI_ID_HEADER: str(steps[1].id)}
+ step_url = f"/xcoms/{dr.dag_id}/{dr.run_id}/body.step/key"
+
+ sessions_begun = []
+
+ def record(api_session, *_):
+ sessions_begun.append(api_session)
+
+ with TestClient(InProcessExecutionAPI(triggerer_id=TRIGGERER_ID).app) as
api:
+ event.listen(Session, "after_begin", record)
+ try:
+ with capture_orm_selects("task_instance") as task_instance_selects:
+ assert api.get(step_url, headers=headers).json() == {"key":
"key", "value": "step-1"}
+ assert api.head(step_url,
headers=headers).headers["Content-Range"] == "map_indexes 1"
+ assert api.get(f"{step_url}/slice", headers=headers).json() ==
["step-1"]
+ assert api.get(f"{step_url}/item/0", headers=headers).json() ==
"step-1"
+ assert api.post(f"{step_url}-written", json="from-trigger",
headers=headers).status_code == 201
+ assert api.delete(step_url, headers=headers).status_code == 200
+ finally:
+ event.remove(Session, "after_begin", record)
+
+ liveness_checks = [
+ statement
+ for statement in task_instance_selects
+ if statement.startswith("SELECT task_instance.working_set FROM")
+ ]
+ assert len(liveness_checks) == 1
+ assert len(set(map(id, sessions_begun))) == 6
+ session.expire_all()
+ assert XComModelV2.get_for_attempt(steps[1].id, "key-written",
session=session).value == "from-trigger"
+ assert XComModelV2.get_for_attempt(steps[1].id, "key", session=session) is
None
+ assert XComModelV2.get_for_attempt(steps[0].id, "key",
session=session).value == "step-0"
+
+
[email protected](
+ ("api_triggerer_id", "header", "expected_status"),
+ [
+ pytest.param(None, None, 403, id="api-without-triggerer-id"),
+ pytest.param(TRIGGERER_ID, "not-a-uuid", 400, id="malformed-header"),
+ ],
+)
+def test_trigger_ti_id_header_rejected(deferred_loop_pass, api_triggerer_id,
header, expected_status):
+ dr, steps, _ = deferred_loop_pass
+
+ with TestClient(InProcessExecutionAPI(triggerer_id=api_triggerer_id).app)
as api:
+ response = api.get(
+ f"/xcoms/{dr.dag_id}/{dr.run_id}/body.step/key",
+ headers={TRIGGER_TI_ID_HEADER: header or str(steps[1].id)},
+ )
+
+ assert response.status_code == expected_status
+
+
+NOT_DEFERRED_ON_THIS_TRIGGERER = "Task instance is not deferred on a trigger
assigned to this triggerer"
+NOT_A_LIVE_ATTEMPT = "Task instance is not a live attempt"
+
+
[email protected](
+ ("method", "change", "expected_status", "expected_value",
"expected_denial"),
+ [
+ pytest.param(
+ "post",
+ {"triggerer_id": TRIGGERER_ID + 1},
+ 403,
+ None,
+ NOT_DEFERRED_ON_THIS_TRIGGERER,
+ id="write-trigger-assigned-to-another-triggerer",
+ ),
+ pytest.param(
+ "post",
+ {"state": TaskInstanceState.RUNNING},
+ 403,
+ None,
+ NOT_DEFERRED_ON_THIS_TRIGGERER,
+ id="write-task-not-deferred",
+ ),
+ pytest.param(
+ "delete",
+ {"state": TaskInstanceState.RUNNING},
+ 403,
+ None,
+ NOT_DEFERRED_ON_THIS_TRIGGERER,
+ id="delete-task-not-deferred",
+ ),
+ pytest.param(
+ "get",
+ {"state": TaskInstanceState.FAILED, "trigger_removed": True},
+ 200,
+ "step-1",
+ None,
+ id="read-live-task-no-longer-deferred",
+ ),
+ pytest.param(
+ "head",
+ {"state": TaskInstanceState.FAILED, "trigger_removed": True},
+ 200,
+ None,
+ None,
+ id="head-live-task-no-longer-deferred",
+ ),
+ pytest.param("get", {"archived": True}, 403, None, NOT_A_LIVE_ATTEMPT,
id="read-archived-try"),
+ ],
+)
+def test_trigger_ti_id_claim_check(
+ session, deferred_loop_pass, method, change, expected_status,
expected_value, expected_denial
+):
+ dr, steps, trigger = deferred_loop_pass
+ trigger.triggerer_id = change.get("triggerer_id", TRIGGERER_ID)
+ steps[1].state = change.get("state", TaskInstanceState.DEFERRED)
+ if change.get("trigger_removed"):
+ steps[1].trigger_id = None
+ session.delete(trigger)
+ session.commit()
+ if change.get("archived"):
+ steps[1].archive(reason="cleared", session=session)
+ session.commit()
+
+ with TestClient(InProcessExecutionAPI(triggerer_id=TRIGGERER_ID).app) as
api:
+ response = api.request(
+ method,
+ f"/xcoms/{dr.dag_id}/{dr.run_id}/body.step/key",
+ headers={TRIGGER_TI_ID_HEADER: str(steps[1].id)},
+ json="from-trigger" if method == "post" else None,
+ )
+
+ body = response.json() if response.content else {}
+ denial = body.get("detail", {}).get("message") if expected_status == 403
else None
+ assert (response.status_code, body.get("value"), denial) == (
+ expected_status,
+ expected_value,
+ expected_denial,
+ )
+
+
@pytest.mark.parametrize("key", ["_airflow_loop_decision",
"_airflow_loop_other"])
def test_normal_xcom_push_rejects_loop_reserved_prefix(client,
create_task_instance, key):
ti = create_task_instance()
diff --git a/airflow-core/tests/unit/jobs/test_triggerer_job.py
b/airflow-core/tests/unit/jobs/test_triggerer_job.py
index 674c3737f08..212a40aa529 100644
--- a/airflow-core/tests/unit/jobs/test_triggerer_job.py
+++ b/airflow-core/tests/unit/jobs/test_triggerer_job.py
@@ -35,6 +35,7 @@ from unittest import mock
from unittest.mock import ANY, AsyncMock, MagicMock, patch
import greenback
+import httpx2
import msgspec
import pendulum
import pytest
@@ -50,6 +51,7 @@ from sqlalchemy import delete, update
from structlog.typing import FilteringBoundLogger
from airflow._shared.timezones import timezone
+from airflow.api_fastapi.execution_api.in_process import TRIGGER_TI_ID_HEADER
from airflow.executors import workloads
from airflow.executors.workloads.task import TaskInstanceDTO
from airflow.executors.workloads.trigger import RunTrigger
@@ -65,6 +67,7 @@ from airflow.jobs.triggerer_job_runner import (
TriggerLoggingFactory,
TriggerRunner,
TriggerRunnerSupervisor,
+ _current_trigger_ti_id,
_make_trigger_span,
messages,
)
@@ -95,6 +98,7 @@ from airflow.sdk.execution_time.comms import (
ErrorResponse,
GetAssetStateStoreByName,
GetAssetStateStoreByUri,
+ GetXCom,
OKResponse,
SetAssetStateStoreByName,
SetAssetStateStoreByUri,
@@ -518,6 +522,40 @@ def
test_client_delegates_to_make_client_and_caches_result(supervisor_builder, m
make_client.assert_called_once_with(supervisor)
[email protected]("has_job", [True, False])
+def
test_handle_requests_sends_the_trigger_ti_id_header_only_for_the_frames_own_requests(
+ supervisor_builder, jobless_supervisor, mocker, has_job
+):
+ ti_id = uuid.uuid4()
+ expected = str(ti_id) if has_job else None
+ seen: dict[str, str | None] = {}
+ supervisor = supervisor_builder() if has_job else jobless_supervisor
+
+ def handler(request: httpx2.Request) -> httpx2.Response:
+ key = request.url.path.rsplit("/", 1)[-1]
+ seen[key] = request.headers.get(TRIGGER_TI_ID_HEADER)
+ if key == "with-trigger":
+ other_thread = threading.Thread(
+ target=lambda: supervisor.client.xcoms.get("d", "r", "t",
"from-thread")
+ )
+ other_thread.start()
+ other_thread.join()
+ return httpx2.Response(200, json={"key": key, "value": 1})
+
+ in_process_api =
mocker.patch("airflow.jobs.triggerer_job_runner.InProcessExecutionAPI",
autospec=True)
+ in_process_api.return_value.transport = httpx2.MockTransport(handler)
+ requests =
supervisor.handle_requests(log=mocker.Mock(spec=FilteringBoundLogger))
+ next(requests)
+
+ def get_xcom_body(key: str) -> dict:
+ return GetXCom(key=key, dag_id="d", run_id="r",
task_id="t").model_dump()
+
+ requests.send(_RequestFrame(id=1, body=get_xcom_body("with-trigger"),
ti_id=ti_id))
+ requests.send(_RequestFrame(id=2, body=get_xcom_body("without-trigger")))
+
+ assert seen == {"with-trigger": expected, "from-thread": None,
"without-trigger": None}
+
+
def test_run_context_exits_when_subprocess_dies(supervisor_builder, mocker):
"""Breaking out of the loop on a dead subprocess still unwinds
run_context."""
from contextlib import contextmanager
@@ -2331,6 +2369,36 @@ def test_failed_trigger(session, dag_maker,
supervisor_builder):
assert task_instance.next_kwargs["traceback"][-1] == "ModuleNotFoundError:
No module named 'fake'\n"
+def test_failure_from_triggerer_that_lost_the_trigger_leaves_task_deferred(
+ session, dag_maker, supervisor_builder
+):
+ trigger_orm = Trigger(classpath="fake.classpath", kwargs={})
+ session.add(trigger_orm)
+ session.flush()
+ with dag_maker(dag_id="test_reassigned_trigger", session=session):
+ EmptyOperator(task_id="dummy1")
+ task_instance = dag_maker.create_dagrun().task_instances[0]
+ task_instance.state = TaskInstanceState.DEFERRED
+ task_instance.trigger_id = trigger_orm.id
+ session.commit()
+ supervisor = supervisor_builder()
+ supervisor.load_triggers()
+ other_triggerer = Job()
+ session.add(other_triggerer)
+ session.flush()
+ session.execute(
+ update(Trigger).where(Trigger.id ==
trigger_orm.id).values(triggerer_id=other_triggerer.id)
+ )
+ session.commit()
+ supervisor.failed_triggers.append((trigger_orm.id, ["ModuleNotFoundError:
No module named 'fake'\n"]))
+
+ supervisor.handle_failed_triggers()
+
+ task_instance.refresh_from_db()
+ assert task_instance.state == TaskInstanceState.DEFERRED
+ assert task_instance.trigger_id == trigger_orm.id
+
+
class CustomTrigger(BaseTrigger):
"""Custom Trigger that will access one Variable and one Connection."""
@@ -3159,6 +3227,22 @@ class TestTriggererMessageTypes:
)
+def _make_ti_dto(task_id="my_task", map_index=-1, context_carrier=None):
+ return TaskInstanceDTO(
+ id=uuid.uuid4(),
+ dag_version_id=uuid.uuid4(),
+ task_id=task_id,
+ dag_id="test_dag",
+ run_id="test_run",
+ try_number=1,
+ map_index=map_index,
+ pool_slots=1,
+ queue="default",
+ priority_weight=1,
+ context_carrier=context_carrier,
+ )
+
+
class TestMakeTriggerSpan:
"""Tests for the _make_trigger_span helper in the triggerer job runner."""
@@ -3171,29 +3255,14 @@ class TestMakeTriggerSpan:
with mock.patch("airflow.jobs.triggerer_job_runner.tracer",
test_tracer):
yield
- def _make_ti_dto(self, task_id="my_task", map_index=-1,
context_carrier=None):
- return TaskInstanceDTO(
- id=uuid.uuid4(),
- dag_version_id=uuid.uuid4(),
- task_id=task_id,
- dag_id="test_dag",
- run_id="test_run",
- try_number=1,
- map_index=map_index,
- pool_slots=1,
- queue="default",
- priority_weight=1,
- context_carrier=context_carrier,
- )
-
def test_make_trigger_span_name_with_task_instance(self):
- ti = self._make_ti_dto(task_id="sensor_task", map_index=-1)
+ ti = _make_ti_dto(task_id="sensor_task", map_index=-1)
with _make_trigger_span(ti=ti, trigger_id=1, name="MySensor"):
pass
assert self.exporter.get_finished_spans()[0].name ==
"trigger.sensor_task"
def test_make_trigger_span_name_with_mapped_task(self):
- ti = self._make_ti_dto(task_id="sensor_task", map_index=2)
+ ti = _make_ti_dto(task_id="sensor_task", map_index=2)
with _make_trigger_span(ti=ti, trigger_id=1, name="MySensor"):
pass
assert self.exporter.get_finished_spans()[0].name ==
"trigger.sensor_task_2"
@@ -3213,7 +3282,7 @@ class TestMakeTriggerSpan:
TraceContextTextMapPropagator().inject(ti_carrier, context=parent_ctx)
expected_parent_span_id = parent_span.get_span_context().span_id
- ti = self._make_ti_dto(context_carrier=ti_carrier)
+ ti = _make_ti_dto(context_carrier=ti_carrier)
with _make_trigger_span(ti=ti, trigger_id=1, name="MySensor"):
pass
@@ -3223,7 +3292,7 @@ class TestMakeTriggerSpan:
assert spans[0].parent.span_id == expected_parent_span_id
def test_make_trigger_span_sets_attributes_with_ti(self):
- ti = self._make_ti_dto(task_id="my_task", map_index=1)
+ ti = _make_ti_dto(task_id="my_task", map_index=1)
with _make_trigger_span(ti=ti, trigger_id=5, name="MyTrigger"):
pass
@@ -3417,6 +3486,34 @@ async def
test_unknown_frame_id_doesnt_crash_reader(decoder_pair):
assert not decoder._reader_task.done(), "reader loop crashed unexpectedly"
[email protected]
[email protected]_timeout(15)
+async def test_asend_frame_carries_the_current_trigger_ti_id(decoder_pair):
+ decoder, server_sock = decoder_pair
+ frames: list[_RequestFrame] = []
+
+ def serve():
+ frame = _read_frame_sync(server_sock)
+ frames.append(frame)
+ server_sock.sendall(
+ _ResponseFrame(
+ id=frame.id, body={"type": "TriggerStateSync", "to_create":
[], "to_cancel": []}
+ ).as_bytes()
+ )
+
+ thread = threading.Thread(target=serve, daemon=True)
+ thread.start()
+ ti_id = uuid.uuid4()
+ token = _current_trigger_ti_id.set(ti_id)
+ try:
+ await decoder.asend(messages.TriggerStateChanges(events=None,
finished=[1], failures=None))
+ finally:
+ _current_trigger_ti_id.reset(token)
+ thread.join(timeout=5)
+
+ assert [frame.ti_id for frame in frames] == [ti_id]
+
+
def test_trigger_state_messages_round_trip_with_seqs():
"""Event triples and persist confirmations survive the wire
encode/decode."""
changes_adapter = TypeAdapter(ToTriggerSupervisor)
@@ -3624,6 +3721,78 @@ def
test_run_trigger_appends_none_seq_for_non_shared_trigger():
assert seq is None
[email protected]
[email protected]("has_task_instance", [True, False])
+async def
test_run_trigger_sets_current_trigger_ti_id_only_for_task_instance_triggers(has_task_instance):
+ ti = _make_ti_dto()
+ seen: list[uuid.UUID | None] = []
+
+ class _RecordingTrigger(BaseTrigger):
+ def serialize(self):
+ return (f"{type(self).__module__}.{type(self).__qualname__}", {})
+
+ async def run(self):
+ seen.append(_current_trigger_ti_id.get())
+ yield TriggerEvent(True)
+
+ trigger_runner = TriggerRunner()
+ trigger_runner.triggers = {
+ 5: {"task": MagicMock(spec=asyncio.Task), "is_watcher": False, "name":
"t", "events": 0}
+ }
+ trigger = _RecordingTrigger()
+ if has_task_instance:
+ trigger.task_instance = ti
+
+ await asyncio.create_task(trigger_runner.run_trigger(5, trigger))
+
+ assert seen == [ti.id if has_task_instance else None]
+
+
[email protected]
+async def
test_shared_stream_group_tasks_do_not_inherit_the_subscribing_trigger_ti_id():
+ ti = _make_ti_dto()
+ seen: dict[str, uuid.UUID | None] = {}
+
+ class _SharedTrigger(BaseEventTrigger):
+ def serialize(self):
+ return (f"{type(self).__module__}.{type(self).__qualname__}", {})
+
+ def shared_stream_key(self):
+ return "key"
+
+ @classmethod
+ async def open_shared_stream(cls, kwargs):
+ seen["group"] = _current_trigger_ti_id.get()
+ yield {}
+ await asyncio.Event().wait()
+
+ async def filter_shared_stream(self, shared_stream):
+ seen["trigger"] = _current_trigger_ti_id.get()
+ async for raw in shared_stream:
+ yield TriggerEvent(raw)
+
+ async def run(self): # pragma: no cover - replaced by
filter_shared_stream
+ yield TriggerEvent({})
+
+ trigger_runner = TriggerRunner()
+ trigger_runner.triggers = {
+ 5: {"task": MagicMock(spec=asyncio.Task), "is_watcher": True, "name":
"t", "events": 0}
+ }
+ trigger = _SharedTrigger()
+ trigger.task_instance = ti
+
+ run_task = asyncio.create_task(trigger_runner.run_trigger(5, trigger))
+ for _ in range(100):
+ await asyncio.sleep(0.01)
+ if trigger_runner.events:
+ break
+ run_task.cancel()
+ with contextlib.suppress(asyncio.CancelledError):
+ await run_task
+
+ assert seen == {"group": None, "trigger": ti.id}
+
+
@pytest.mark.asyncio
async def test_trigger_event_payload_not_logged_at_info(cap_structlog):
"""Ensure the full event payload is not logged at INFO level."""
diff --git a/airflow-core/tests/unit/models/test_trigger.py
b/airflow-core/tests/unit/models/test_trigger.py
index ed7e414e057..716c0f53bfb 100644
--- a/airflow-core/tests/unit/models/test_trigger.py
+++ b/airflow-core/tests/unit/models/test_trigger.py
@@ -341,24 +341,33 @@ def
test_handle_event_submit_fails_task_when_the_event_payload_cannot_be_seriali
assert "event payload could not be serialized" in
task_instance.next_kwargs["error"]
-def test_submit_failure(session, create_task_instance):
[email protected](
+ ("caller_triggerer_id", "expected_state", "expected_next_method"),
+ [
+ pytest.param(None, State.SCHEDULED, "__fail__", id="unguarded"),
+ pytest.param(1, State.SCHEDULED, "__fail__", id="owning-triggerer"),
+ pytest.param(2, State.DEFERRED, None, id="previous-owner"),
+ ],
+)
+def test_submit_failure(
+ session, create_task_instance, caller_triggerer_id, expected_state,
expected_next_method
+):
"""
Tests that failures submitted to a trigger fail their dependent
- task instances if not using a TaskEndEvent.
+ task instances if not using a TaskEndEvent, unless the caller no longer
owns the trigger.
"""
- # Make a trigger
trigger = Trigger(classpath="airflow.triggers.testing.SuccessTrigger",
kwargs={})
+ trigger.triggerer_id = 1
session.add(trigger)
- # Make a TaskInstance that's deferred and waiting on it
task_instance = create_task_instance(task_id="fake",
logical_date=timezone.utcnow(), state=State.DEFERRED)
task_instance.trigger_id = trigger.id
session.commit()
- # Call submit_event
- Trigger.submit_failure(trigger.id, session=session)
- # Check that the task instance is now scheduled to fail
+
+ Trigger.submit_failure(trigger.id, triggerer_id=caller_triggerer_id,
session=session)
+
updated_task_instance = session.scalar(select(TaskInstance))
- assert updated_task_instance.state == State.SCHEDULED
- assert updated_task_instance.next_method == "__fail__"
+ assert updated_task_instance.state == expected_state
+ assert updated_task_instance.next_method == expected_next_method
@pytest.mark.parametrize(
diff --git a/task-sdk/src/airflow/sdk/execution_time/comms.py
b/task-sdk/src/airflow/sdk/execution_time/comms.py
index 7c957027ba7..9c6993f2bdb 100644
--- a/task-sdk/src/airflow/sdk/execution_time/comms.py
+++ b/task-sdk/src/airflow/sdk/execution_time/comms.py
@@ -208,6 +208,11 @@ class _RequestFrame(_FrameMixin, msgspec.Struct,
array_like=True, frozen=True, o
calls, so that server-side spans (e.g. POST /xcoms/…) appear as children
of the correct task span
rather than under the supervisor's own span.
"""
+ ti_id: UUID | None = None
+ """Id of the task instance whose trigger made this request, set only by
the triggerer's trigger runner.
+
+ The supervisor forwards it so the API can act as that task instance.
+ """
class _ResponseFrame(_FrameMixin, msgspec.Struct, array_like=True,
frozen=True, omit_defaults=True): # type: ignore[call-arg]
diff --git a/task-sdk/src/airflow/sdk/execution_time/supervisor.py
b/task-sdk/src/airflow/sdk/execution_time/supervisor.py
index 7af3b98616d..d6ff0b03f40 100644
--- a/task-sdk/src/airflow/sdk/execution_time/supervisor.py
+++ b/task-sdk/src/airflow/sdk/execution_time/supervisor.py
@@ -35,7 +35,7 @@ import time
import weakref
from collections import deque
from collections.abc import Callable, Generator
-from contextlib import contextmanager, suppress
+from contextlib import contextmanager, nullcontext, suppress
from datetime import UTC, datetime
from enum import Enum, auto
from http import HTTPStatus
@@ -184,6 +184,7 @@ from opentelemetry.trace.propagation.tracecontext import
TraceContextTextMapProp
_trace_propagator = TraceContextTextMapPropagator()
if TYPE_CHECKING:
+ from contextlib import AbstractContextManager
from typing import Self
from structlog.typing import FilteringBoundLogger, WrappedLogger
@@ -1140,7 +1141,8 @@ class WatchedSubprocess:
if request.context_carrier:
ctx = _trace_propagator.extract(request.context_carrier)
token = otel_context.attach(ctx)
- self._handle_request(msg, log, request.id)
+ with self._request_scope(request):
+ self._handle_request(msg, log, request.id)
except ServerResponseError as e:
error_details = e.response.json() if e.response else None
log.error(
@@ -1189,6 +1191,10 @@ class WatchedSubprocess:
if token is not None:
otel_context.detach(token)
+ def _request_scope(self, request: _RequestFrame) ->
AbstractContextManager[None]:
+ """Return a context manager that wraps the handling of ``request``;
subclasses override to scope API calls."""
+ return nullcontext()
+
def _handle_request(self, msg, log: FilteringBoundLogger, req_id: int) ->
None:
if self._request_handlers is None:
raise NotImplementedError(f"{type(self).__name__} must declare its
request handlers")