Skip to content

Commit 1e4e436

Browse files
committed
Return each attempt's own URL from operator extra links
An operator extra link is cached as a single XCom row under the link's xcom_key, and XComOperatorLink.get_link ignores the try_number on the TaskInstanceKey it is given. A task's XComs are also cleared before every attempt, so that row only ever holds whichever attempt ran last. Asking for an earlier attempt's link therefore returns the latest attempt's URL, and the original attempt's logs cannot be reached from the UI at all. The worker now also writes each attempt's rendered link to the task state store, which a retry does not clear, and the reader prefers that row. The XCom row is left in place and is still the answer for links written before this change. Reading goes through get_state_backend(), the same resolver the execution API uses, so a deployment pointing [state_store] backend elsewhere keeps working. Closes: #71471 Signed-off-by: 1fanwang <1fannnw@gmail.com>
1 parent e8c0081 commit 1e4e436

5 files changed

Lines changed: 182 additions & 13 deletions

File tree

airflow-core/src/airflow/serialization/definitions/operatorlink.py

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@
2424
import attrs
2525

2626
from airflow.models.xcom import XComModel
27+
from airflow.sdk.bases.operatorlink import attempt_link_state_key
28+
from airflow._shared.state import TaskScope
29+
from airflow.state import get_state_backend
2730
from airflow.utils.log.logging_mixin import LoggingMixin
2831
from airflow.utils.session import create_session
2932

@@ -43,19 +46,28 @@ class XComOperatorLink(LoggingMixin):
4346
name: str
4447
xcom_key: str
4548

46-
def get_link(self, operator: Operator, *, ti_key: TaskInstanceKey) -> str:
47-
"""
48-
Retrieve the link from the XComs.
49+
def _stored_link(self, ti_key: TaskInstanceKey) -> str | None:
50+
"""Return the stored link for ``ti_key``'s attempt, or None.
4951
50-
:param operator: The Airflow operator object this link is associated to.
51-
:param ti_key: TaskInstance ID to return link for.
52-
:return: link to external system, but by pulling it from XComs
52+
The state store is read first because it is the only place an earlier attempt's link
53+
survives: a task's XComs are cleared before every attempt, so the XCom row holds
54+
whichever attempt ran last. That row is still the answer for links written before
55+
per-attempt rows existed.
5356
"""
54-
self.log.info(
55-
"Attempting to retrieve link from XComs with key: %s for task id: %s", self.xcom_key, ti_key
57+
scope = TaskScope(
58+
dag_id=ti_key.dag_id,
59+
run_id=ti_key.run_id,
60+
task_id=ti_key.task_id,
61+
map_index=ti_key.map_index,
5662
)
5763
with create_session() as session:
58-
result = session.execute(
64+
stored = get_state_backend().get(
65+
scope, attempt_link_state_key(self.xcom_key, ti_key.try_number), session=session
66+
)
67+
if stored is not None:
68+
return stored
69+
70+
row = session.execute(
5971
XComModel.get_many(
6072
key=self.xcom_key,
6173
run_id=ti_key.run_id,
@@ -64,9 +76,21 @@ def get_link(self, operator: Operator, *, ti_key: TaskInstanceKey) -> str:
6476
map_indexes=ti_key.map_index,
6577
).with_only_columns(XComModel.value)
6678
).first()
67-
if not result:
79+
return row.value if row else None
80+
81+
def get_link(self, operator: Operator, *, ti_key: TaskInstanceKey) -> str:
82+
"""
83+
Retrieve the link from the XComs.
84+
85+
:param operator: The Airflow operator object this link is associated to.
86+
:param ti_key: TaskInstance ID to return link for.
87+
:return: link to external system, but by pulling it from XComs
88+
"""
89+
self.log.info("Attempting to retrieve link with key: %s for task id: %s", self.xcom_key, ti_key)
90+
raw_value = self._stored_link(ti_key)
91+
if raw_value is None:
6892
self.log.debug(
69-
"No link with name: %s present in XCom as key: %s, returning empty link",
93+
"No link with name: %s present for key: %s, returning empty link",
7094
self.name,
7195
self.xcom_key,
7296
)
@@ -78,10 +102,10 @@ def get_link(self, operator: Operator, *, ti_key: TaskInstanceKey) -> str:
78102
)
79103

80104
try:
81-
parsed_value = json.loads(result.value)
105+
parsed_value = json.loads(raw_value)
82106
except (ValueError, TypeError):
83107
# Handling for cases when types do not need to be deserialized (e.g. when value is a simple string link)
84-
parsed_value = result.value
108+
parsed_value = raw_value
85109

86110
try:
87111
return str(stringify_xcom(parsed_value))
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
from __future__ import annotations
18+
19+
import json
20+
21+
import pytest
22+
23+
from airflow._shared.state import TaskScope
24+
from airflow.models.xcom import XComModel
25+
from airflow.sdk.bases.operatorlink import attempt_link_state_key
26+
from airflow.serialization.definitions.operatorlink import XComOperatorLink
27+
from airflow.state import get_state_backend
28+
29+
pytestmark = pytest.mark.db_test
30+
31+
XCOM_KEY = "_link_MyLink"
32+
33+
34+
@pytest.fixture
35+
def link():
36+
return XComOperatorLink(name="My Link", xcom_key=XCOM_KEY)
37+
38+
39+
@pytest.fixture
40+
def store_link(session):
41+
def write(ti, try_number, value):
42+
get_state_backend().set(
43+
TaskScope(dag_id=ti.dag_id, run_id=ti.run_id, task_id=ti.task_id, map_index=ti.map_index),
44+
attempt_link_state_key(XCOM_KEY, try_number),
45+
json.dumps(value),
46+
session=session,
47+
)
48+
49+
return write
50+
51+
52+
@pytest.fixture
53+
def xcom_link(session):
54+
def write(ti, value):
55+
XComModel.set(
56+
key=XCOM_KEY,
57+
value=value,
58+
dag_id=ti.dag_id,
59+
task_id=ti.task_id,
60+
run_id=ti.run_id,
61+
map_index=ti.map_index,
62+
session=session,
63+
)
64+
65+
return write
66+
67+
68+
class TestXComOperatorLinkPerAttempt:
69+
def test_returns_the_requested_attempts_link(
70+
self, session, create_task_instance, link, store_link, xcom_link
71+
):
72+
ti = create_task_instance(task_id="link_per_attempt")
73+
store_link(ti, 1, "https://logs/attempt-1")
74+
store_link(ti, 2, "https://logs/attempt-2")
75+
xcom_link(ti, "https://logs/attempt-2")
76+
session.commit()
77+
78+
assert link.get_link(ti.task, ti_key=ti.key._replace(try_number=1)) == "https://logs/attempt-1"
79+
assert link.get_link(ti.task, ti_key=ti.key._replace(try_number=2)) == "https://logs/attempt-2"
80+
81+
def test_falls_back_to_xcom(self, session, create_task_instance, link, xcom_link):
82+
"""Links written before per-attempt rows existed only have the XCom row."""
83+
ti = create_task_instance(task_id="link_fallback")
84+
xcom_link(ti, "https://logs/only-one")
85+
session.commit()
86+
87+
assert link.get_link(ti.task, ti_key=ti.key._replace(try_number=1)) == "https://logs/only-one"
88+
89+
def test_returns_empty_when_nothing_stored(self, session, create_task_instance, link):
90+
ti = create_task_instance(task_id="link_missing")
91+
session.commit()
92+
93+
assert link.get_link(ti.task, ti_key=ti.key) == ""
94+
95+
def test_state_store_wins_over_xcom(self, session, create_task_instance, link, store_link, xcom_link):
96+
"""The XCom row is the latest attempt, so it must not answer for an earlier one."""
97+
ti = create_task_instance(task_id="link_precedence")
98+
store_link(ti, 1, "https://logs/attempt-1")
99+
xcom_link(ti, "https://logs/attempt-2")
100+
session.commit()
101+
102+
assert link.get_link(ti.task, ti_key=ti.key._replace(try_number=1)) == "https://logs/attempt-1"

task-sdk/src/airflow/sdk/bases/operatorlink.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@
2727
from airflow.sdk.types import TaskInstanceKey
2828

2929

30+
ATTEMPT_LINK_STATE_KEY_PREFIX = "_link_attempt_"
31+
"""Prefix for the task-state-store keys holding one attempt's rendered operator link."""
32+
33+
34+
def attempt_link_state_key(xcom_key: str, try_number: int) -> str:
35+
"""Return the state-store key holding ``xcom_key``'s link as rendered for ``try_number``."""
36+
return f"{ATTEMPT_LINK_STATE_KEY_PREFIX}{try_number}_{xcom_key}"
37+
38+
3039
@attrs.define()
3140
class BaseOperatorLink(metaclass=ABCMeta):
3241
"""Abstract base class that defines how we get an operator link."""

task-sdk/src/airflow/sdk/execution_time/task_runner.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
TIRunContext,
5959
)
6060
from airflow.sdk.bases.operator import BaseOperator, ExecutorSafeguard
61+
from airflow.sdk.bases.operatorlink import attempt_link_state_key
6162
from airflow.sdk.bases.xcom import BaseXCom
6263
from airflow.sdk.configuration import conf
6364
from airflow.sdk.definitions._internal.dag_parsing_context import _airflow_parsing_context_manager
@@ -2329,6 +2330,10 @@ def finalize(
23292330
link, xcom_key = oe.get_link(operator=task, ti_key=ti), oe.xcom_key # type: ignore[arg-type]
23302331
log.debug("Setting xcom for operator extra link", link=link, xcom_key=xcom_key)
23312332
_xcom_push_to_db(ti, key=xcom_key, value=link)
2333+
# The task's XComs are cleared before the next attempt, so this attempt's link
2334+
# goes to the state store, which is not.
2335+
if (store := context.get("task_state_store")) is not None:
2336+
store.set(attempt_link_state_key(xcom_key, ti.try_number), link)
23322337
except Exception:
23332338
log.exception(
23342339
"Failed to push an xcom for task operator extra link",

task-sdk/tests/task_sdk/execution_time/test_task_runner.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1196,6 +1196,35 @@ def execute(self, context):
11961196
assert counted.count("operator_failures") == 1
11971197

11981198

1199+
def test_finalize_stores_the_operator_link_per_attempt(
1200+
mocked_parse, create_runtime_ti, mock_supervisor_comms
1201+
):
1202+
"""A retry clears the task's XComs, so each attempt's link also goes to the state store."""
1203+
from airflow.sdk.bases.operatorlink import attempt_link_state_key
1204+
1205+
class MyLink(BaseOperatorLink):
1206+
name = "My Link"
1207+
1208+
def get_link(self, operator, *, ti_key):
1209+
return f"https://logs/attempt-{ti_key.try_number}"
1210+
1211+
class CustomOperator(BaseOperator):
1212+
operator_extra_links = (MyLink(),)
1213+
1214+
def execute(self, context):
1215+
return None
1216+
1217+
ti = create_runtime_ti(task=CustomOperator(task_id="link_per_attempt"))
1218+
ti.try_number = 2
1219+
context = ti.get_template_context()
1220+
store = mock.MagicMock()
1221+
context["task_state_store"] = store
1222+
1223+
finalize(ti, context=context, log=mock.MagicMock(), state=TaskInstanceState.SUCCESS)
1224+
1225+
store.set.assert_called_once_with(attempt_link_state_key("_link_MyLink", 2), "https://logs/attempt-2")
1226+
1227+
11991228
def test_run_downstream_skipped(mocked_parse, create_runtime_ti, mock_supervisor_comms, listener_manager):
12001229
listener = TestTaskRunnerCallsListeners.CustomListener()
12011230
listener_manager(listener)

0 commit comments

Comments
 (0)