This is an automated email from the ASF dual-hosted git repository. ashb pushed a commit to branch store-historic-ti-ownership-data in repository https://gitbox.apache.org/repos/asf/airflow.git
commit c42c95c159492ed670ba1b9781ac12fb2ac848a2 Author: Ash Berlin-Taylor <[email protected]> AuthorDate: Mon Oct 5 13:55:46 2026 +0100 fixup! Keep retired task attempts and their data under the attempt UUID Keep older clients' responses, carry notes across attempts, and fix same-try history --- .../execution_api/routes/task_instances.py | 4 +- .../airflow/api_fastapi/execution_api/security.py | 13 +++--- .../execution_api/versions/v2026_10_30.py | 2 +- .../0142_3_4_0_unify_task_attempt_ownership.py | 25 +++++++++++ airflow-core/src/airflow/models/taskinstance.py | 6 +++ .../core_api/routes/public/test_task_instances.py | 2 +- .../api_fastapi/execution_api/test_security.py | 27 +++--------- .../versions/v2026_10_30/test_task_instances.py | 49 +++++++++++++++++++++ .../test_0142_unify_task_attempt_ownership.py | 50 +++++++++++++++++++--- .../tests/unit/models/test_taskinstance.py | 28 +++++++++++- 10 files changed, 164 insertions(+), 42 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 cf7ecdcfadd..b9a36103e3b 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 @@ -1090,9 +1090,7 @@ async def ti_heartbeat( if working_set is None: # A retired attempt was likely cleared while running, so return 410 Gone # instead of 404 Not Found to give the client a more specific signal. - _raise_ti_not_in_live_table( - task_instance_id, archived_in_history=IdentifyRetiredTaskStateUpdates.is_applied - ) + _raise_ti_not_in_live_table(task_instance_id, archived_in_history=True) if hostname != ti_payload.hostname or pid != ti_payload.pid: log.warning( 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 040467ce34f..1f10e1c04a5 100644 --- a/airflow-core/src/airflow/api_fastapi/execution_api/security.py +++ b/airflow-core/src/airflow/api_fastapi/execution_api/security.py @@ -233,17 +233,14 @@ async def require_auth( # The versions package imports routes, which depend on this module. from airflow.api_fastapi.execution_api.versions.v2026_10_30 import IdentifyRetiredTaskStateUpdates - await _require_live_attempt( - token, - allow_callback="task_instance_id" not in request.path_params, - identify_retired=IdentifyRetiredTaskStateUpdates.is_applied, - ) - request.scope[_REQUEST_SCOPE_LIVE_ATTEMPT_KEY] = True + if IdentifyRetiredTaskStateUpdates.is_applied: + await _require_live_attempt(token, allow_callback="task_instance_id" not in request.path_params) + request.scope[_REQUEST_SCOPE_LIVE_ATTEMPT_KEY] = True return token -async def _require_live_attempt(token: TIToken, *, allow_callback: bool, identify_retired: bool) -> None: +async def _require_live_attempt(token: TIToken, *, allow_callback: bool) -> None: """ Reject mutations from an attempt whose UUID is no longer in the working set. @@ -264,7 +261,7 @@ async def _require_live_attempt(token: TIToken, *, allow_callback: bool, identif and await session.scalar(select(Callback.id).where(Callback.id == token.id)) ): return - archived = identify_retired and attempt is not None + archived = attempt is not None raise HTTPException( status_code=status.HTTP_410_GONE if archived else status.HTTP_404_NOT_FOUND, detail={ diff --git a/airflow-core/src/airflow/api_fastapi/execution_api/versions/v2026_10_30.py b/airflow-core/src/airflow/api_fastapi/execution_api/versions/v2026_10_30.py index aef90d41506..8ca10d7c0bd 100644 --- a/airflow-core/src/airflow/api_fastapi/execution_api/versions/v2026_10_30.py +++ b/airflow-core/src/airflow/api_fastapi/execution_api/versions/v2026_10_30.py @@ -46,7 +46,7 @@ class AddStoppedTaskReport(VersionChange): class IdentifyRetiredTaskStateUpdates(VersionChangeWithSideEffects): - """Return 410 for requests from archived attempts, preserving 404 for older clients and unknown attempts.""" + """Reject every mutation from an archived attempt with 410; older clients keep each endpoint's own response.""" description = __doc__ instructions_to_migrate_to_previous_version = () diff --git a/airflow-core/src/airflow/migrations/versions/0142_3_4_0_unify_task_attempt_ownership.py b/airflow-core/src/airflow/migrations/versions/0142_3_4_0_unify_task_attempt_ownership.py index 689f2575081..0b39848899e 100644 --- a/airflow-core/src/airflow/migrations/versions/0142_3_4_0_unify_task_attempt_ownership.py +++ b/airflow-core/src/airflow/migrations/versions/0142_3_4_0_unify_task_attempt_ownership.py @@ -82,6 +82,7 @@ _COPY_COLUMNS = ( "retry_delay_override", "retry_reason", ) +_NOT_EXECUTING_STATES = ("success", "failed", "skipped", "upstream_failed", "removed", "restarting") _HITL_COLUMNS = ( "options", "subject", @@ -223,6 +224,30 @@ def upgrade(): "legacy_task_data_owner", not_valid=op.get_bind().dialect.name == "postgresql", ) + live = sa.table( + "task_instance", + *(sa.column(c) for c in _COORDINATES), + sa.column("try_number"), + sa.column("state"), + ) + archived = sa.table( + "task_instance_history", *(sa.column(c) for c in _COORDINATES), sa.column("try_number") + ) + same_coordinates = [live.c[c] == archived.c[c] for c in _COORDINATES] + # Clearing archived the try and left the replacement at it; only a row that is still executing that try is the same attempt. + op.execute( + live.update() + .where( + live.c.state.in_(_NOT_EXECUTING_STATES), + sa.exists().where(*same_coordinates, archived.c.try_number == live.c.try_number), + ) + .values( + try_number=sa.select(sa.func.max(archived.c.try_number)) + .where(*same_coordinates) + .scalar_subquery() + + 1 + ) + ) with op.batch_alter_table("task_instance_history") as batch: batch.drop_constraint("task_instance_history_ti_fkey", type_="foreignkey") with op.batch_alter_table("task_instance") as batch: diff --git a/airflow-core/src/airflow/models/taskinstance.py b/airflow-core/src/airflow/models/taskinstance.py index b3cc60ef1d3..e6a0055282f 100644 --- a/airflow-core/src/airflow/models/taskinstance.py +++ b/airflow-core/src/airflow/models/taskinstance.py @@ -1199,6 +1199,12 @@ class TaskInstance(Base, LoggingMixin, BaseWorkload): session.execute(insert(TaskInstance.__table__).values(values)) successor = session.scalars(select(TaskInstance).where(TaskInstance.id == values["id"])).one() successor.task = self.task + if ( + note := session.scalar(select(TaskInstanceNote).where(TaskInstanceNote.ti_id == self.id)) + ) is not None: + carried = TaskInstanceNote(content=note.content, user_id=note.user_id) + carried.created_at, carried.updated_at = note.created_at, note.updated_at + successor.task_instance_note = carried return successor def complete_restart(self, *, session: Session) -> TaskInstance: diff --git a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_task_instances.py b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_task_instances.py index 3648fc28928..f1c4317b435 100644 --- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_task_instances.py +++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_task_instances.py @@ -4331,7 +4331,7 @@ class TestPostClearTaskInstances(TestTaskInstanceEndpoint): ti_id = response_data["task_instances"][0]["id"] assert ti_id != str(old_ti.id) _check_task_instance_note(session, old_ti.id, {"content": "placeholder-note", "user_id": None}) - _check_task_instance_note(session, ti_id, None) + _check_task_instance_note(session, ti_id, {"content": "placeholder-note", "user_id": None}) @pytest.mark.db_test def test_clear_dry_run_does_not_set_note(self, test_client, session): diff --git a/airflow-core/tests/unit/api_fastapi/execution_api/test_security.py b/airflow-core/tests/unit/api_fastapi/execution_api/test_security.py index e80ccd18803..304d1e26f95 100644 --- a/airflow-core/tests/unit/api_fastapi/execution_api/test_security.py +++ b/airflow-core/tests/unit/api_fastapi/execution_api/test_security.py @@ -379,37 +379,20 @@ class TestAttemptLiveness: ("delete", "/execution/variables/key", None), ("post", "/execution/xcoms/{dag_id}/{run_id}/{task_id}/key", "late"), ] - # Clients older than the Cadwyn change that identifies retired attempts get 404, not 410. - OLD_CLIENT_ROUTES = [ - ("put", "/execution/task-instances/{ti_id}/heartbeat", {"hostname": "w", "pid": 1}), - ("put", "/execution/task-instances/{ti_id}/rtif", {"field": "late"}), - ("post", "/execution/xcoms/{dag_id}/{run_id}/{task_id}/key", "late"), - ] @pytest.mark.parametrize( - ("method", "path", "body", "version", "expected_status"), - [ - *(pytest.param(*route, None, 410, id=f"{route[0]}:{route[1]}") for route in RETIRED_ROUTES), - *( - pytest.param(*route, version, 404, id=f"{route[0]}:{route[1]}:{version}") - for route in OLD_CLIENT_ROUTES - for version in ("2025-04-11", "2026-06-30") - ), - ], + ("method", "path", "body"), + [pytest.param(*route, id=f"{route[0]}:{route[1]}") for route in RETIRED_ROUTES], ) - def test_retired_attempt_rejected_before_mutation( - self, client, caller, session, method, path, body, version, expected_status - ): + def test_retired_attempt_rejected_before_mutation(self, client, caller, session, method, path, body): ti, token = caller path = path.format(ti_id=token.id, dag_id=ti.dag_id, run_id=ti.run_id, task_id=ti.task_id) ti.prepare_db_for_next_try(session) session.commit() - response = client.request( - method, path, json=body, headers={"Airflow-API-Version": version} if version else {} - ) + response = client.request(method, path, json=body) - assert response.status_code == expected_status, response.text + assert response.status_code == 410, response.text def test_callback_execution_token_can_mutate_variable(self, client, caller, session): _, token = caller diff --git a/airflow-core/tests/unit/api_fastapi/execution_api/versions/v2026_10_30/test_task_instances.py b/airflow-core/tests/unit/api_fastapi/execution_api/versions/v2026_10_30/test_task_instances.py index 7668e2528b6..eecffc755a4 100644 --- a/airflow-core/tests/unit/api_fastapi/execution_api/versions/v2026_10_30/test_task_instances.py +++ b/airflow-core/tests/unit/api_fastapi/execution_api/versions/v2026_10_30/test_task_instances.py @@ -21,9 +21,12 @@ import pytest from sqlalchemy import select from airflow._shared.timezones import timezone +from airflow.api_fastapi.execution_api.app import _jwt_generator +from airflow.api_fastapi.execution_api.security import require_auth from airflow.models.taskinstance import TaskInstance from airflow.models.taskreschedule import TaskReschedule from airflow.models.trigger import Trigger +from airflow.models.xcom import XComModelV2 from airflow.sdk import task from airflow.utils.state import State @@ -268,3 +271,49 @@ class TestArgBindingsFieldBackwardCompat: "from_default": True, }, ] + + [email protected] +def retired_attempt(client, exec_app, monkeypatch, create_task_instance, session): + """A running attempt, authenticated with a signed token, that has since been retried.""" + ti = create_task_instance(state=State.RUNNING) + session.commit() + monkeypatch.delitem(exec_app.dependency_overrides, require_auth) + client.headers["Authorization"] = f"Bearer {_jwt_generator().generate({'sub': str(ti.id)})}" + successor = ti.prepare_db_for_next_try(session) + successor.state = State.UP_FOR_RETRY + session.commit() + return ti, successor + + [email protected]( + ("version", "rtif_status", "heartbeat_status", "xcom_status", "xcom_kept"), + [ + pytest.param("2025-04-11", 410, 410, 201, True, id="oldest"), + pytest.param("2026-06-30", 410, 410, 201, True, id="3.3"), + pytest.param("2026-10-30", 410, 410, 410, False, id="current"), + ], +) +def test_mutations_after_retry_response_by_version( + client, retired_attempt, session, version, rtif_status, heartbeat_status, xcom_status, xcom_kept +): + ti, successor = retired_attempt + client.headers["Airflow-API-Version"] = version + + rtif = client.put(f"/execution/task-instances/{ti.id}/rtif", json={"field": "late"}) + heartbeat = client.put( + f"/execution/task-instances/{ti.id}/heartbeat", json={"hostname": "host", "pid": 1} + ) + xcom = client.post( + f"/execution/xcoms/{ti.dag_id}/{ti.run_id}/{ti.task_id}/extra_link", json="https://example.com" + ) + + assert (rtif.status_code, heartbeat.status_code, xcom.status_code) == ( + rtif_status, + heartbeat_status, + xcom_status, + ) + session.expire_all() + stored = XComModelV2.get_for_attempt(ti.id, "extra_link", session=session) + assert (stored is not None) is xcom_kept + assert XComModelV2.get_for_attempt(successor.id, "extra_link", session=session) is None diff --git a/airflow-core/tests/unit/migrations/test_0142_unify_task_attempt_ownership.py b/airflow-core/tests/unit/migrations/test_0142_unify_task_attempt_ownership.py index d346d3c1d89..019376fb79a 100644 --- a/airflow-core/tests/unit/migrations/test_0142_unify_task_attempt_ownership.py +++ b/airflow-core/tests/unit/migrations/test_0142_unify_task_attempt_ownership.py @@ -19,7 +19,7 @@ from __future__ import annotations from shutil import copyfile -from uuid import uuid4 +from uuid import UUID, uuid4 import pytest import sqlalchemy as sa @@ -349,9 +349,8 @@ def test_upgrade_retains_history_and_legacy_owner(populated_predecessor): assert foreign_key["referred_table"] == "legacy_task_data_owner" -def test_upgrade_discards_only_history_conflicting_with_live_try(populated_predecessor): - connection, config = populated_predecessor - conflicting_id = uuid4() +def archive_attempt_at_live_try(connection) -> UUID: + archived_id = uuid4() connection.execute( table(connection, "task_instance_history", "task_instance_id") .insert() @@ -360,7 +359,7 @@ def test_upgrade_discards_only_history_conflicting_with_live_try(populated_prede task_id="task", run_id="manual", map_index=-1, - task_instance_id=conflicting_id, + task_instance_id=archived_id, try_number=2, pool="default_pool", pool_slots=1, @@ -372,7 +371,7 @@ def test_upgrade_discards_only_history_conflicting_with_live_try(populated_prede table(connection, "hitl_detail_history", "ti_history_id") .insert() .values( - ti_history_id=conflicting_id, + ti_history_id=archived_id, options=["yes"], subject="conflicting review", params={}, @@ -381,6 +380,12 @@ def test_upgrade_discards_only_history_conflicting_with_live_try(populated_prede ) ) connection.commit() + return archived_id + + +def test_upgrade_discards_only_history_conflicting_with_live_try(populated_predecessor): + connection, config = populated_predecessor + conflicting_id = archive_attempt_at_live_try(connection) command.upgrade(config, REVISION) @@ -401,6 +406,39 @@ def test_upgrade_discards_only_history_conflicting_with_live_try(populated_prede assert conflicting_id not in rows [email protected]( + "live_state", ["skipped", "upstream_failed", "removed", "success", "failed", "restarting"] +) +def test_upgrade_keeps_history_archived_at_the_try_of_a_live_row_that_never_ran_it( + populated_predecessor, live_state +): + connection, config = populated_predecessor + archived_id = archive_attempt_at_live_try(connection) + live = table(connection, "task_instance", "id") + connection.execute(live.update().where(live.c.id == CURRENT_ID).values(state=live_state)) + connection.commit() + + command.upgrade(config, REVISION) + + ti = table(connection, "task_instance", "id") + rows = {row.id: row for row in connection.execute(sa.select(ti))} + assert set(rows) == {CURRENT_ID, HISTORY_ID, archived_id} + assert (rows[CURRENT_ID].state, rows[CURRENT_ID].try_number, rows[CURRENT_ID].working_set) == ( + live_state, + 3, + True, + ) + assert (rows[archived_id].state, rows[archived_id].try_number, rows[archived_id].working_set) == ( + "success", + 2, + None, + ) + assert set(connection.scalars(sa.select(table(connection, "hitl_detail", "ti_id").c.ti_id))) == { + HISTORY_ID, + archived_id, + } + + def test_upgrade_does_not_suppress_unrelated_history_identity_conflict(populated_predecessor): connection, config = populated_predecessor history = table(connection, "task_instance_history", "task_instance_id") diff --git a/airflow-core/tests/unit/models/test_taskinstance.py b/airflow-core/tests/unit/models/test_taskinstance.py index 5bb923e6048..31fbaa6aea2 100644 --- a/airflow-core/tests/unit/models/test_taskinstance.py +++ b/airflow-core/tests/unit/models/test_taskinstance.py @@ -2944,6 +2944,31 @@ class TestTaskInstance: assert session.scalar(sa.select(sa.func.count()).select_from(TaskInstance)) == 3 assert successor.working_set is True + def test_retirement_carries_the_note_to_the_successor(self, ownership_session): + session = ownership_session + attempt = session.get(TaskInstance, CURRENT_ID) + attempt.note = "needs a look" + session.flush() + + successor = attempt.prepare_db_for_next_try(session) + session.flush() + session.expire_all() + + assert session.get(TaskInstance, successor.id).note == "needs a look" + assert session.get(TaskInstance, CURRENT_ID).note == "needs a look" + + def test_completing_a_restart_carries_the_note_to_the_successor(self, ownership_session): + session = ownership_session + attempt = session.get(TaskInstance, CURRENT_ID) + attempt.state = TaskInstanceState.RESTARTING + attempt.note = "cleared while running" + session.flush() + + successor = attempt.complete_restart(session=session) + session.expire_all() + + assert session.get(TaskInstance, successor.id).note == "cleared while running" + @pytest.mark.parametrize( ("map_index", "deleted"), [ @@ -3117,10 +3142,11 @@ class TestTaskInstance: ) is not None ) - for name in ("xcom_v1", "rtif_v1", "legacy_task_data_owner", "task_instance_note", "task_reschedule"): + for name in ("xcom_v1", "rtif_v1", "legacy_task_data_owner", "task_reschedule"): assert session.scalar(sa.text(f"SELECT count(*) FROM {name}")) == int( deleted_attempt == "current" ) + assert session.scalars(select(TaskInstanceNote.ti_id)).all() == [retained.id] @pytest.mark.execution_timeout(10) def test_coordinate_xcom_reads_choose_current_producer_and_exact_try_reads_history(
