This is an automated email from the ASF dual-hosted git repository.
kaxil 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 22a339029e0 Fix ExternalTaskSensor link pointing to the wrong external
Dag run (#74528)
22a339029e0 is described below
commit 22a339029e0d3656452d1845416bdfae444278b7
Author: Kevin Yang <[email protected]>
AuthorDate: Mon Nov 9 11:35:59 2026 -0500
Fix ExternalTaskSensor link pointing to the wrong external Dag run (#74528)
The "External DAG" link reused the sensor's own run ID under the external
Dag, so it usually opened a run that does not exist: whenever
execution_delta or execution_date_fn targets a different logical date, and
whenever the two runs have different run types or run_after values, such as
a
backfilled or manually triggered sensor waiting on a scheduled run.
On Airflow 3 the link is built on the worker, which cannot query the
metadata database, so the sensor records the run IDs it matched through the
Execution API and the link reads them back. Until a run is matched the link
opens the external Dag instead of a guessed run.
* Remove redundant paragraph from ExternalDagLink docstring
* Keep ExternalTaskSensor outcome when storing matched run IDs fails
The run IDs for the External DAG link are stored before the sensor
raises or returns its result, so an Execution API error at that point
could turn a soft-fail skip or a success into a task failure. The link
is best effort and already falls back to the external Dag page.
The single-run link no longer depends on a core-internal URL helper.
---
.../providers/standard/sensors/external_task.py | 53 +++-
.../unit/standard/sensors/test_external_task.py | 281 +++++++++++++++++++--
2 files changed, 308 insertions(+), 26 deletions(-)
diff --git
a/providers/standard/src/airflow/providers/standard/sensors/external_task.py
b/providers/standard/src/airflow/providers/standard/sensors/external_task.py
index 034e86ca261..8f2b8bd58e4 100644
--- a/providers/standard/src/airflow/providers/standard/sensors/external_task.py
+++ b/providers/standard/src/airflow/providers/standard/sensors/external_task.py
@@ -22,6 +22,7 @@ import typing
import warnings
from collections.abc import Callable, Collection, Iterable, Sequence
from typing import TYPE_CHECKING, ClassVar
+from urllib.parse import urljoin
from airflow.exceptions import AirflowProviderDeprecationWarning
from airflow.models.dag import DagModel
@@ -29,6 +30,7 @@ from airflow.providers.common.compat.sdk import (
AirflowSkipException,
BaseOperatorLink,
BaseSensorOperator,
+ XCom,
conf,
)
from airflow.providers.standard.exceptions import (
@@ -65,6 +67,13 @@ if TYPE_CHECKING:
from airflow.providers.common.compat.sdk import Context, TaskInstanceKey
+XCOM_EXTERNAL_RUN_IDS = "external_run_ids"
+
+
+def _build_airflow_ui_url(path: str) -> str:
+ base_url = conf.get("api", "base_url", fallback="/")
+ return urljoin(base_url.rstrip("/") + "/", path)
+
class ExternalDagLink(BaseOperatorLink):
"""
@@ -97,9 +106,12 @@ class ExternalDagLink(BaseOperatorLink):
external_dag_id: str = template_fields.get("external_dag_id",
operator.external_dag_id) # type: ignore[no-redef]
if AIRFLOW_V_3_0_PLUS:
- from airflow.utils.helpers import build_airflow_dagrun_url
-
- return build_airflow_dagrun_url(dag_id=external_dag_id,
run_id=ti_key.run_id)
+ external_run_ids = XCom.get_value(ti_key=ti_key,
key=XCOM_EXTERNAL_RUN_IDS)
+ if not external_run_ids:
+ return _build_airflow_ui_url(f"dags/{external_dag_id}")
+ if len(external_run_ids) > 1:
+ return _build_airflow_ui_url(f"dags/{external_dag_id}/runs")
+ return
_build_airflow_ui_url(f"dags/{external_dag_id}/runs/{external_run_ids[0]}")
from airflow.utils.helpers import build_airflow_url_with_query #
type:ignore[attr-defined]
query = {"dag_id": external_dag_id, "run_id": ti_key.run_id}
@@ -378,16 +390,43 @@ class ExternalTaskSensor(BaseSensorOperator):
if self.failed_states:
count = _get_count(self.failed_states)
count_failed = self._calculate_count(count, dttm_filter)
+ if count_failed > 0:
+ self._push_external_run_ids(context, dttm_filter)
self._handle_failed_states(count_failed)
if self.skipped_states:
count = _get_count(self.skipped_states)
count_skipped = self._calculate_count(count, dttm_filter)
+ if count_skipped > 0:
+ self._push_external_run_ids(context, dttm_filter)
self._handle_skipped_states(count_skipped)
count = _get_count(self.allowed_states)
count_allowed = self._calculate_count(count, dttm_filter)
- return count_allowed == len(dttm_filter)
+ if count_allowed != len(dttm_filter):
+ return False
+ self._push_external_run_ids(context, dttm_filter)
+ return True
+
+ def _push_external_run_ids(self, context: Context, dttm_filter:
Sequence[datetime.datetime]) -> None:
+ """Store the run IDs of the external Dag runs matched by
``dttm_filter`` for ExternalDagLink."""
+ # Runs before the sensor raises or returns its result, so a failure
here must not replace that
+ # result. Without the XCom, ExternalDagLink falls back to the external
Dag page.
+ try:
+ # The Execution API has no lookup from logical date to run ID; the
task states map is keyed by run ID.
+ task_states = context["ti"].get_task_states(
+ dag_id=self.external_dag_id,
+ task_ids=list(self.external_task_ids) if
self.external_task_ids else None,
+ task_group_id=self.external_task_group_id,
+ logical_dates=list(dttm_filter),
+ )
+ context["ti"].xcom_push(key=XCOM_EXTERNAL_RUN_IDS,
value=sorted(task_states))
+ except Exception:
+ self.log.warning(
+ "Could not store the matched run IDs of external Dag %s for
the External DAG link",
+ self.external_dag_id,
+ exc_info=True,
+ )
def _calculate_count(self, count: int, dttm_filter:
Sequence[datetime.datetime]) -> float | int:
"""Calculate the normalized count based on the type of check."""
@@ -523,7 +562,11 @@ class ExternalTaskSensor(BaseSensorOperator):
raise ExternalTaskNotFoundError("No event received from trigger")
# Re-set as attribute after coming back from deferral - to be used by
listeners
- self.external_dates_filter =
self._serialize_dttm_filter(self._get_dttm_filter(context))
+ dttm_filter = self._get_dttm_filter(context)
+ self.external_dates_filter = self._serialize_dttm_filter(dttm_filter)
+
+ if AIRFLOW_V_3_0_PLUS and event["status"] in ("success", "skipped",
"failed"):
+ self._push_external_run_ids(context, dttm_filter)
if event["status"] == "success":
self.log.info("External tasks %s has executed successfully.",
self.external_task_ids)
diff --git
a/providers/standard/tests/unit/standard/sensors/test_external_task.py
b/providers/standard/tests/unit/standard/sensors/test_external_task.py
index 45c6c8329e2..949ca5012e2 100644
--- a/providers/standard/tests/unit/standard/sensors/test_external_task.py
+++ b/providers/standard/tests/unit/standard/sensors/test_external_task.py
@@ -17,6 +17,7 @@
# under the License.
from __future__ import annotations
+import contextlib
import itertools
import logging
import re
@@ -37,6 +38,7 @@ from airflow.providers.common.compat.sdk import (
AirflowSensorTimeout,
AirflowSkipException,
TaskDeferred,
+ XCom,
timezone,
)
from airflow.providers.standard.exceptions import (
@@ -52,7 +54,12 @@ from airflow.providers.standard.exceptions import (
from airflow.providers.standard.operators.bash import BashOperator
from airflow.providers.standard.operators.empty import EmptyOperator
from airflow.providers.standard.operators.python import PythonOperator
-from airflow.providers.standard.sensors.external_task import
ExternalTaskMarker, ExternalTaskSensor
+from airflow.providers.standard.sensors.external_task import (
+ XCOM_EXTERNAL_RUN_IDS,
+ ExternalDagLink,
+ ExternalTaskMarker,
+ ExternalTaskSensor,
+)
from airflow.providers.standard.sensors.time import TimeSensor
from airflow.providers.standard.triggers.external_task import WorkflowTrigger
from airflow.timetables.base import DataInterval
@@ -1357,11 +1364,15 @@ class TestExternalTaskSensorV3:
self.context["ti"].get_task_states.return_value = {"run_id":
{"test_group.task_id": State.SUCCESS}}
op.execute(context=self.context)
- self.context["ti"].get_task_states.assert_called_once_with(
- dag_id="test_dag_parent",
- logical_dates=[DEFAULT_DATE],
- task_group_id="test_group",
- )
+ assert self.context["ti"].get_task_states.call_args_list == [
+ mock.call(dag_id="test_dag_parent", logical_dates=[DEFAULT_DATE],
task_group_id="test_group"),
+ mock.call(
+ dag_id="test_dag_parent",
+ task_ids=None,
+ task_group_id="test_group",
+ logical_dates=[DEFAULT_DATE],
+ ),
+ ]
assert op.external_dates_filter == DEFAULT_DATE.isoformat()
@pytest.mark.execution_timeout(10)
@@ -1596,13 +1607,64 @@ class TestExternalTaskSensorV3:
with pytest.raises(ExternalTaskGroupFailedError):
op.execute(context=self.context)
- self.context["ti"].get_task_states.assert_called_once_with(
- dag_id="test_dag_parent",
- logical_dates=[DEFAULT_DATE],
- task_group_id="test_group",
- )
+ assert self.context["ti"].get_task_states.call_args_list == [
+ mock.call(dag_id="test_dag_parent", logical_dates=[DEFAULT_DATE],
task_group_id="test_group"),
+ mock.call(
+ dag_id="test_dag_parent",
+ task_ids=None,
+ task_group_id="test_group",
+ logical_dates=[DEFAULT_DATE],
+ ),
+ ]
assert op.external_dates_filter == DEFAULT_DATE.isoformat()
+ def test_poke_does_not_store_external_run_ids_while_waiting(self):
+ op = ExternalTaskSensor(
+ task_id=TASK_ID,
+ external_dag_id=EXTERNAL_DAG_ID,
+ external_task_id=EXTERNAL_TASK_ID,
+ failed_states=[State.FAILED],
+ skipped_states=[State.SKIPPED],
+ )
+ ti = mock.MagicMock(spec=["get_ti_count", "get_task_states",
"xcom_push"])
+ ti.get_ti_count.return_value = 0
+
+ assert op.poke(context={"logical_date": DEFAULT_DATE, "ti": ti}) is
False
+
+ ti.get_task_states.assert_not_called()
+ ti.xcom_push.assert_not_called()
+
+ @pytest.mark.parametrize(
+ ("sensor_kwargs", "expectation"),
+ [
+ pytest.param(
+ {"failed_states": [State.FAILED], "soft_fail": True},
+ pytest.raises(AirflowSkipException),
+ id="failed-soft-fail",
+ ),
+ pytest.param(
+ {"skipped_states": [State.SKIPPED]},
pytest.raises(AirflowSkipException), id="skipped"
+ ),
+ pytest.param({}, contextlib.nullcontext(), id="success"),
+ ],
+ )
+ def test_poke_keeps_outcome_when_storing_external_run_ids_fails(self,
sensor_kwargs, expectation):
+ op = ExternalTaskSensor(
+ task_id=TASK_ID,
+ external_dag_id=EXTERNAL_DAG_ID,
+ external_task_id=EXTERNAL_TASK_ID,
+ **sensor_kwargs,
+ )
+ ti = mock.MagicMock(spec=["get_ti_count", "get_task_states",
"xcom_push"])
+ ti.get_ti_count.return_value = 1
+ ti.get_task_states.side_effect = RuntimeError("Execution API
unavailable")
+
+ with expectation:
+ assert op.poke(context={"logical_date": DEFAULT_DATE, "ti": ti})
is True
+
+ ti.get_task_states.assert_called_once()
+ ti.xcom_push.assert_not_called()
+
def test_get_logical_date(self):
"""For AF 3, we check for logical date or dag_run.run_after in
context."""
@@ -1721,7 +1783,11 @@ class TestExternalTaskAsyncSensor:
deferrable=True,
)
- context = {"execution_date": DEFAULT_DATE, "logical_date":
DEFAULT_DATE}
+ context = {
+ "execution_date": DEFAULT_DATE,
+ "logical_date": DEFAULT_DATE,
+ "ti": mock.MagicMock(spec=["get_task_states", "xcom_push"]),
+ }
with mock.patch.object(sensor.log, "info") as mock_log_info:
sensor.execute_complete(
context=context,
@@ -1738,7 +1804,11 @@ class TestExternalTaskAsyncSensor:
deferrable=True,
)
- context = {"execution_date": DEFAULT_DATE, "logical_date":
DEFAULT_DATE}
+ context = {
+ "execution_date": DEFAULT_DATE,
+ "logical_date": DEFAULT_DATE,
+ "ti": mock.MagicMock(spec=["get_task_states", "xcom_push"]),
+ }
with pytest.raises(ExternalDagFailedError, match="External job has
failed."):
sensor.execute_complete(
context=context,
@@ -1755,7 +1825,11 @@ class TestExternalTaskAsyncSensor:
soft_fail=True,
)
- context = {"execution_date": DEFAULT_DATE, "logical_date":
DEFAULT_DATE}
+ context = {
+ "execution_date": DEFAULT_DATE,
+ "logical_date": DEFAULT_DATE,
+ "ti": mock.MagicMock(spec=["get_task_states", "xcom_push"]),
+ }
with pytest.raises(AirflowSkipException, match="External job has
failed skipping."):
sensor.execute_complete(
context=context,
@@ -1793,11 +1867,74 @@ class TestExternalTaskAsyncSensor:
)
assert sensor.external_dates_filter is None
- context = {"execution_date": DEFAULT_DATE, "logical_date":
DEFAULT_DATE}
+ context = {
+ "execution_date": DEFAULT_DATE,
+ "logical_date": DEFAULT_DATE,
+ "ti": mock.MagicMock(spec=["get_task_states", "xcom_push"]),
+ }
sensor.execute_complete(context=context, event={"status": "success"})
assert sensor.external_dates_filter == DEFAULT_DATE.isoformat()
+ @pytest.mark.skipif(not AIRFLOW_V_3_0_PLUS, reason="Run IDs are looked up
through the Task SDK")
+ @pytest.mark.parametrize(
+ ("status", "expectation"),
+ [
+ pytest.param("success", contextlib.nullcontext(), id="success"),
+ pytest.param("failed", pytest.raises(ExternalDagFailedError),
id="failed"),
+ pytest.param("skipped", pytest.raises(AirflowSkipException),
id="skipped"),
+ ],
+ )
+ def test_defer_execute_complete_pushes_matched_external_run_ids(self,
status, expectation):
+ sensor = ExternalTaskSensor(
+ task_id=TASK_ID,
+ external_task_id=EXTERNAL_TASK_ID,
+ external_dag_id=EXTERNAL_DAG_ID,
+ deferrable=True,
+ )
+ ti = mock.MagicMock(spec=["get_task_states", "xcom_push"])
+ ti.get_task_states.return_value = {"run_b": {EXTERNAL_TASK_ID:
status}, "run_a": {}}
+ context = {"logical_date": DEFAULT_DATE, "ti": ti}
+
+ with expectation:
+ sensor.execute_complete(context=context, event={"status": status})
+
+ ti.get_task_states.assert_called_once_with(
+ dag_id=EXTERNAL_DAG_ID,
+ task_ids=[EXTERNAL_TASK_ID],
+ task_group_id=None,
+ logical_dates=[DEFAULT_DATE],
+ )
+ ti.xcom_push.assert_called_once_with(key=XCOM_EXTERNAL_RUN_IDS,
value=["run_a", "run_b"])
+
+ @pytest.mark.skipif(not AIRFLOW_V_3_0_PLUS, reason="Run IDs are looked up
through the Task SDK")
+ @pytest.mark.parametrize(
+ ("status", "expectation"),
+ [
+ pytest.param("success", contextlib.nullcontext(), id="success"),
+ pytest.param("failed", pytest.raises(ExternalDagFailedError),
id="failed"),
+ pytest.param("skipped", pytest.raises(AirflowSkipException),
id="skipped"),
+ ],
+ )
+ def
test_defer_execute_complete_keeps_outcome_when_storing_external_run_ids_fails(
+ self, status, expectation
+ ):
+ sensor = ExternalTaskSensor(
+ task_id=TASK_ID,
+ external_task_id=EXTERNAL_TASK_ID,
+ external_dag_id=EXTERNAL_DAG_ID,
+ deferrable=True,
+ )
+ ti = mock.MagicMock(spec=["get_task_states", "xcom_push"])
+ ti.get_task_states.side_effect = RuntimeError("Execution API
unavailable")
+ context = {"logical_date": DEFAULT_DATE, "ti": ti}
+
+ with expectation:
+ sensor.execute_complete(context=context, event={"status": status})
+
+ ti.get_task_states.assert_called_once()
+ ti.xcom_push.assert_not_called()
+
def test_poke_interval_set_on_init(self):
"""Test that poke_interval is set on init and the deprecated
poll_interval attribute mirrors it."""
sensor = ExternalTaskSensor(
@@ -1821,7 +1958,9 @@ class TestExternalTaskAsyncSensor:
],
ids=["not_templated", "templated"],
)
[email protected](XCom, "get_value", autospec=True, return_value=None)
def test_external_task_sensor_extra_link(
+ mock_get_value,
external_dag_id,
external_task_id,
expected_external_dag_id,
@@ -1844,7 +1983,7 @@ def test_external_task_sensor_extra_link(
url = task.operator_extra_links[0].get_link(operator=task, ti_key=ti.key)
- assert f"/dags/{expected_external_dag_id}/runs" in url
+ assert url.endswith(f"/dags/{expected_external_dag_id}")
class TestExternalTaskMarker:
@@ -2420,9 +2559,8 @@ class TestExternalDagLink:
pytest.param(ExternalTaskMarker, id="marker"),
],
)
- def test_link_points_to_external_dag_run(self, operator_class, dag_maker):
- from airflow.configuration import conf
-
+ @mock.patch.object(XCom, "get_value", autospec=True, return_value=None)
+ def test_link_points_to_external_dag_without_matched_run(self,
mock_get_value, operator_class, dag_maker):
with dag_maker("test_external_dag_link", serialized=True):
task = operator_class(
task_id="task_with_link",
@@ -2439,5 +2577,106 @@ class TestExternalDagLink:
url = link.get_link(operator=task, ti_key=ti.key)
- base_url = conf.get("api", "base_url", fallback="/").lower()
- assert url == f"{base_url}dags/external_dag/runs/{dr.run_id}"
+ assert url.endswith("/dags/external_dag")
+
+ @pytest.mark.parametrize(
+ ("sensor_kwargs", "external_logical_date"),
+ [
+ pytest.param(
+ {"execution_delta": timedelta(minutes=5)},
+ DEFAULT_DATE - timedelta(minutes=5),
+ id="execution_delta",
+ ),
+ pytest.param(
+ {"execution_date_fn": lambda logical_date: logical_date -
timedelta(minutes=5)},
+ DEFAULT_DATE - timedelta(minutes=5),
+ id="execution_date_fn",
+ ),
+ pytest.param({}, DEFAULT_DATE,
id="same_logical_date_different_run_type"),
+ ],
+ )
+ def test_sensor_link_points_to_waited_external_dag_run(
+ self, sensor_kwargs, external_logical_date, dag_maker
+ ):
+ with dag_maker(
+ "external_dag", schedule=None, start_date=DEFAULT_DATE -
timedelta(days=1), serialized=True
+ ):
+ EmptyOperator(task_id="external_task")
+ external_dr = dag_maker.create_dagrun(
+ run_type=DagRunType.SCHEDULED,
+ logical_date=external_logical_date,
+ state=DagRunState.SUCCESS,
+ )
+
+ with dag_maker("sensor_dag", schedule=None, serialized=True):
+ ExternalTaskSensor(task_id="wait", external_dag_id="external_dag",
**sensor_kwargs)
+ sensor_dr = dag_maker.create_dagrun(run_type=DagRunType.MANUAL,
logical_date=DEFAULT_DATE)
+
+ ti = dag_maker.run_ti("wait", sensor_dr)
+ assert ti.state == State.SUCCESS
+
+ url = dag_maker.serialized_dag.get_task("wait").get_extra_links(ti,
ExternalDagLink.name)
+ assert url.endswith(f"/dags/external_dag/runs/{external_dr.run_id}")
+
+ def
test_sensor_link_points_to_external_dag_runs_when_several_runs_matched(self,
dag_maker):
+ external_logical_dates = [DEFAULT_DATE - timedelta(days=1),
DEFAULT_DATE]
+ with dag_maker(
+ "external_dag", schedule=None, start_date=DEFAULT_DATE -
timedelta(days=1), serialized=True
+ ):
+ EmptyOperator(task_id="external_task")
+ for logical_date in external_logical_dates:
+ dag_maker.create_dagrun(
+ run_type=DagRunType.SCHEDULED, logical_date=logical_date,
state=DagRunState.SUCCESS
+ )
+
+ with dag_maker("sensor_dag", schedule=None, serialized=True):
+ ExternalTaskSensor(
+ task_id="wait",
+ external_dag_id="external_dag",
+ execution_date_fn=lambda _: external_logical_dates,
+ )
+ sensor_dr = dag_maker.create_dagrun(run_type=DagRunType.MANUAL,
logical_date=DEFAULT_DATE)
+
+ ti = dag_maker.run_ti("wait", sensor_dr)
+ assert ti.state == State.SUCCESS
+
+ url = dag_maker.serialized_dag.get_task("wait").get_extra_links(ti,
ExternalDagLink.name)
+ assert url.endswith("/dags/external_dag/runs")
+
+ @pytest.mark.parametrize(
+ ("sensor_kwargs", "expectation", "expected_state"),
+ [
+ pytest.param(
+ {"failed_states": [DagRunState.FAILED]},
+ pytest.raises(ExternalDagFailedError),
+ State.FAILED,
+ id="failed_states",
+ ),
+ pytest.param(
+ {"skipped_states": [DagRunState.FAILED]},
+ contextlib.nullcontext(),
+ State.SKIPPED,
+ id="skipped_states",
+ ),
+ ],
+ )
+ def test_sensor_link_points_to_external_dag_run_that_ended_the_wait(
+ self, sensor_kwargs, expectation, expected_state, dag_maker
+ ):
+ with dag_maker("external_dag", schedule=None, serialized=True):
+ EmptyOperator(task_id="external_task")
+ external_dr = dag_maker.create_dagrun(
+ run_type=DagRunType.SCHEDULED, logical_date=DEFAULT_DATE,
state=DagRunState.FAILED
+ )
+
+ with dag_maker("sensor_dag", schedule=None, serialized=True):
+ ExternalTaskSensor(task_id="wait", external_dag_id="external_dag",
**sensor_kwargs)
+ sensor_dr = dag_maker.create_dagrun(run_type=DagRunType.MANUAL,
logical_date=DEFAULT_DATE)
+
+ with expectation:
+ dag_maker.run_ti("wait", sensor_dr)
+ ti = sensor_dr.get_task_instance("wait")
+ assert ti.state == expected_state
+
+ url = dag_maker.serialized_dag.get_task("wait").get_extra_links(ti,
ExternalDagLink.name)
+ assert url.endswith(f"/dags/external_dag/runs/{external_dr.run_id}")