kaxil commented on code in PR #73554:
URL: https://github.com/apache/airflow/pull/73554#discussion_r4073994129


##########
airflow-core/src/airflow/models/taskinstance.py:
##########
@@ -1933,14 +1950,7 @@ def fetch_handle_failure_context(
             if task and fail_fast:
                 _stop_remaining_tasks(task_instance=ti, session=session)
         else:
-            if ti.state != TaskInstanceState.RESTARTING:
-                # Record the current attempt and prepare the TI for its next 
try.
-                # Covers every path eligible for retry reaching 
handle_failure():
-                # - RUNNING: task raised an exception during execution (normal 
failure)
-                # - QUEUED/SCHEDULED: executor killed the task externally 
before
-                #   it could start (e.g. pod OOMKilled in KubernetesExecutor)
-                # RESTARTING is excluded: the task was cleared via the UI/API 
while running;
-                # prepare_db_for_next_try() was already called during that 
clear operation.
+            if ti.state != TaskInstanceState.UP_FOR_RETRY:
                 ti.prepare_db_for_next_try(session)

Review Comment:
   Now that this also bumps `try_number`, the `on_task_instance_failed` hook a 
few lines down receives the TI after it has been rolled forward, so 
`task_instance.try_number` is N+1 (and the id is the replacement's) for the 
attempt that failed. OpenLineage's `_on_task_instance_manual_state_change` 
builds the FAIL run id from `ti.try_number`, so on the scheduler-side path 
(zombie or executor-reported kill of a retry-eligible task) it no longer 
matches the START event the worker emitted for try N. Before this change the id 
was already rotated here but the try number still matched. Could the listener 
call move above the archive, or the failed attempt be captured before it?



##########
airflow-core/src/airflow/migrations/versions/0135_3_4_0_allocate_pending_task_attempt_numbers.py:
##########
@@ -0,0 +1,65 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""
+Allocate attempt numbers for pending retries and cleared tasks.

Review Comment:
   Does this want a `significant` newsfragment? After this lands, `try_number` 
on an `up_for_retry` row identifies the pending attempt rather than the one 
that failed, which is visible through the public REST API, the UI header, and 
listeners, and there is a migration to run.



##########
airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py:
##########
@@ -379,9 +391,41 @@ def ti_update_state(
     Not all state transitions are valid, and transitioning to some states 
requires extra information to be
     passed along. (Check out the datamodels for details, the rendered docs 
might not reflect this accurately)
     """
+    # The version bundle imports the routes while registering its changes.
+    from airflow.api_fastapi.execution_api.versions.v2026_10_30 import 
IdentifyRetiredTaskStateUpdates
+
     bind_contextvars(ti_id=str(task_instance_id))
     log.debug("Updating task instance state", new_state=ti_patch_payload.state)
 
+    if isinstance(ti_patch_payload, TITerminalStatePayload) and (
+        ti_patch_payload.state == TerminalStateNonSuccess.SERVER_TERMINATED
+    ):
+        ti = session.scalar(
+            select(TI)
+            .where(TI.id == task_instance_id)
+            .with_for_update(of=TI)
+            .execution_options(populate_existing=True)
+        )
+        if ti is None:
+            if 
session.scalar(select(TIH.task_instance_id).where(TIH.task_instance_id == 
task_instance_id)):
+                return Response(status_code=status.HTTP_204_NO_CONTENT)
+            raise HTTPException(status_code=404, detail={"reason": 
"not_found"})
+        if (ti.hostname, ti.pid) != (ti_patch_payload.hostname, 
ti_patch_payload.pid) or (
+            ti_patch_payload.hostname is None or ti_patch_payload.pid is None
+        ):
+            raise HTTPException(status_code=409, detail={"reason": 
"running_elsewhere"})
+        if ti.state == TaskInstanceState.RESTARTING:
+            dag = dag_bag.get_dag_for_run(dag_run=ti.dag_run, session=session)
+            if dag is None:
+                raise HTTPException(status_code=404, detail={"reason": 
"dag_not_found"})
+            ti.task = dag.get_task(ti.task_id)

Review Comment:
   `get_task` raises `TaskNotFound` if the task was dropped from the DAG 
between the attempt starting and the clear, which would surface as a 500 here 
and leave the row RESTARTING until the executor event arrives. The scheduler 
fallback wraps the same lookup in try/except. Worth returning a 404 like the 
`dag is None` branch above?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to