This is an automated email from the ASF dual-hosted git repository.
kaxil 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 5cc50c59997 [v3-4-test] Clear retry policy state when a TI is cleared
(#73889) (#75646)
5cc50c59997 is described below
commit 5cc50c59997ab0a72265920587be12c4975c3b52
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Mon Oct 26 11:28:43 2026 +0000
[v3-4-test] Clear retry policy state when a TI is cleared (#73889) (#75646)
* Clear retry policy state when a TI is cleared
* fixing review from kaxil
* review comments from kaxil
(cherry picked from commit 0df550012b18a1476c797082894cf724b7599df3)
Co-authored-by: Amogh Desai <[email protected]>
---
.../core_api/datamodels/task_instance_history.py | 4 +-
.../core_api/datamodels/task_instances.py | 2 +-
.../api_fastapi/core_api/openapi/_private_ui.yaml | 6 +-
.../core_api/openapi/v2-rest-api-generated.yaml | 11 ++--
.../execution_api/routes/task_instances.py | 3 +-
airflow-core/src/airflow/models/dagrun.py | 5 ++
airflow-core/src/airflow/models/taskinstance.py | 16 ++++-
.../airflow/ui/openapi-gen/requests/schemas.gen.ts | 4 +-
.../airflow/ui/openapi-gen/requests/types.gen.ts | 4 +-
airflow-core/tests/unit/models/test_dagrun.py | 41 ++++++++++++-
.../tests/unit/models/test_taskinstance.py | 68 ++++++++++++++++++++++
.../src/airflowctl/api/datamodels/generated.py | 7 +--
12 files changed, 144 insertions(+), 27 deletions(-)
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/task_instance_history.py
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/task_instance_history.py
index 7c2a691e3d0..2d810885fe3 100644
---
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/task_instance_history.py
+++
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/task_instance_history.py
@@ -75,9 +75,7 @@ class TaskInstanceHistoryResponse(OmitsMissingRegion,
BaseModel):
state_reason: str | None = Field(
default=None,
validation_alias="retry_reason",
- description=(
- "The reason the task instance reached its current state, as
recorded by a retry policy. May describe a previous attempt: it is cleared only
when the task next starts running, so a task waiting to be retried or re-run
can still carry the reason its last attempt ended."
- ),
+ description=("The reason this try ended, as recorded by a retry
policy."),
)
@field_validator("state_reason", mode="after")
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/task_instances.py
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/task_instances.py
index a082a718511..a0c2d30d9c4 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/task_instances.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/task_instances.py
@@ -106,7 +106,7 @@ class TaskInstanceResponse(OmitsMissingRegion, BaseModel):
default=None,
validation_alias="retry_reason",
description=(
- "The reason the task instance reached its current state, as
recorded by a retry policy. May describe a previous attempt: it is cleared only
when the task next starts running, so a task waiting to be retried or re-run
can still carry the reason its last attempt ended."
+ "The reason the task instance reached its current state, as
recorded by a retry policy. May describe a previous attempt: a task waiting to
be retried still carries the reason its last attempt ended. It is cleared when
the task is cleared and when the next attempt is scheduled or deferred."
),
)
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
index 51320cabc1e..db96e77efb5 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
+++ b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
@@ -5990,9 +5990,9 @@ components:
- type: 'null'
title: State Reason
description: 'The reason the task instance reached its current
state, as
- recorded by a retry policy. May describe a previous attempt: it is
cleared
- only when the task next starts running, so a task waiting to be
retried
- or re-run can still carry the reason its last attempt ended.'
+ recorded by a retry policy. May describe a previous attempt: a
task waiting
+ to be retried still carries the reason its last attempt ended. It
is cleared
+ when the task is cleared and when the next attempt is scheduled or
deferred.'
type: object
required:
- id
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
index aecd0bdeaf0..7c51d8b4b42 100644
---
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
+++
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
@@ -18016,10 +18016,7 @@ components:
- type: string
- type: 'null'
title: State Reason
- description: 'The reason the task instance reached its current
state, as
- recorded by a retry policy. May describe a previous attempt: it is
cleared
- only when the task next starts running, so a task waiting to be
retried
- or re-run can still carry the reason its last attempt ended.'
+ description: The reason this try ended, as recorded by a retry
policy.
type: object
required:
- id
@@ -18224,9 +18221,9 @@ components:
- type: 'null'
title: State Reason
description: 'The reason the task instance reached its current
state, as
- recorded by a retry policy. May describe a previous attempt: it is
cleared
- only when the task next starts running, so a task waiting to be
retried
- or re-run can still carry the reason its last attempt ended.'
+ recorded by a retry policy. May describe a previous attempt: a
task waiting
+ to be retried still carries the reason its last attempt ended. It
is cleared
+ when the task is cleared and when the next attempt is scheduled or
deferred.'
type: object
required:
- id
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 ff3ffbc021b..658c0e3055d 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
@@ -919,7 +919,8 @@ def _create_ti_state_update_query_and_update_state(
if "rendered_map_index" in ti_patch_payload.model_fields_set:
ti._rendered_map_index =
ti_patch_payload.rendered_map_index
# Store retry policy overrides so next_retry_datetime() can read
them.
- # These are cleared when the task enters RUNNING (ti_run).
+ # These are cleared on clear, when the next attempt is scheduled
or deferred, and
+ # when the task enters RUNNING (ti_run).
query = query.values(retry_delay_override=retry_delay_override,
retry_reason=retry_reason)
elif isinstance(ti_patch_payload, TISuccessStatePayload):
if ti is not None:
diff --git a/airflow-core/src/airflow/models/dagrun.py
b/airflow-core/src/airflow/models/dagrun.py
index f28cd7690f4..206993e1b66 100644
--- a/airflow-core/src/airflow/models/dagrun.py
+++ b/airflow-core/src/airflow/models/dagrun.py
@@ -2717,6 +2717,9 @@ class DagRun(Base, LoggingMixin):
state=TaskInstanceState.SCHEDULED,
scheduled_dttm=timezone.utcnow(),
try_number=next_try_number,
+ # Already archived with the finished try; the new one
must not inherit it.
+ retry_reason=None,
+ retry_delay_override=None,
)
.execution_options(synchronize_session=False)
)
@@ -2767,6 +2770,8 @@ class DagRun(Base, LoggingMixin):
end_date=timezone.utcnow(),
duration=0,
try_number=next_try_number,
+ retry_reason=None,
+ retry_delay_override=None,
)
.execution_options(
synchronize_session=False,
diff --git a/airflow-core/src/airflow/models/taskinstance.py
b/airflow-core/src/airflow/models/taskinstance.py
index eeb548e5881..ca06135a3f5 100644
--- a/airflow-core/src/airflow/models/taskinstance.py
+++ b/airflow-core/src/airflow/models/taskinstance.py
@@ -554,6 +554,10 @@ def clear_task_instances(
ti.max_tries = max(ti.max_tries or 0, previous_try_number)
ti.state = None
ti.external_executor_id = None
+ # retry_delay_override is the functional one:
next_retry_datetime() prefers it over
+ # the task's own retry_delay, so a stale value would retime the
next retry.
+ ti.retry_reason = None
+ ti.retry_delay_override = None
ti.clear_next_method_args()
# Match DagVersion to latest serialized DAG when running on the
latest version.
if use_latest_version:
@@ -1178,7 +1182,8 @@ class TaskInstance(Base, LoggingMixin, BaseWorkload):
dag_version = relationship("DagVersion", back_populates="task_instances")
# Retry policy overrides: set by the task worker when a RetryPolicy is
configured.
- # Cleared on task start (ti_run). Read by next_retry_datetime().
+ # Cleared on clear, on scheduling or deferring the next attempt, and on
task start (ti_run).
+ # Read by next_retry_datetime().
retry_delay_override: Mapped[float | None] = mapped_column(Float,
nullable=True)
retry_reason: Mapped[str | None] = mapped_column(String(500),
nullable=True)
@@ -1608,8 +1613,14 @@ class TaskInstance(Base, LoggingMixin, BaseWorkload):
self.log.debug("Setting task state for %s to %s", self, state)
if self not in session:
self.refresh_from_db(session=session)
+ previous_state = self.state
self.state = state
self.start_date = self.start_date or current_time
+ if previous_state == TaskInstanceState.UP_FOR_RETRY and state !=
TaskInstanceState.UP_FOR_RETRY:
+ # The pending attempt never runs, so it must not keep the previous
one's values:
+ # prepare_db_for_next_try() would otherwise archive them under
this try number.
+ self.retry_reason = None
+ self.retry_delay_override = None
if self.state in State.finished or self.state ==
TaskInstanceState.UP_FOR_RETRY:
self.end_date = self.end_date or current_time
self.duration = (self.end_date - self.start_date).total_seconds()
@@ -2660,6 +2671,9 @@ class TaskInstance(Base, LoggingMixin, BaseWorkload):
# Keep an eye on the logic in
`check_and_change_state_before_execution()`
# depending on self.next_method semantics
self.state = TaskInstanceState.DEFERRED
+ # A start_from_trigger retry skips schedule_tis' UPDATE, so clear
here too.
+ self.retry_reason = None
+ self.retry_delay_override = None
self.trigger_id = trigger_row.id
self.next_method = start_trigger_args.next_method
self.next_kwargs =
stringify_encoding_keys(start_trigger_args.next_kwargs or {})
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
index ccbdf81c024..3d64fafeab4 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
@@ -8199,7 +8199,7 @@ export const $TaskInstanceHistoryResponse = {
}
],
title: 'State Reason',
- description: 'The reason the task instance reached its current
state, as recorded by a retry policy. May describe a previous attempt: it is
cleared only when the task next starts running, so a task waiting to be retried
or re-run can still carry the reason its last attempt ended.'
+ description: 'The reason this try ended, as recorded by a retry
policy.'
}
},
type: 'object',
@@ -8534,7 +8534,7 @@ export const $TaskInstanceResponse = {
}
],
title: 'State Reason',
- description: 'The reason the task instance reached its current
state, as recorded by a retry policy. May describe a previous attempt: it is
cleared only when the task next starts running, so a task waiting to be retried
or re-run can still carry the reason its last attempt ended.'
+ description: 'The reason the task instance reached its current
state, as recorded by a retry policy. May describe a previous attempt: a task
waiting to be retried still carries the reason its last attempt ended. It is
cleared when the task is cleared and when the next attempt is scheduled or
deferred.'
}
},
type: 'object',
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
index 0a90bf284f5..86a1680d2c5 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
@@ -2229,7 +2229,7 @@ export type TaskInstanceHistoryResponse = {
dag_version: DagVersionResponse | null;
note?: string | null;
/**
- * The reason the task instance reached its current state, as recorded by
a retry policy. May describe a previous attempt: it is cleared only when the
task next starts running, so a task waiting to be retried or re-run can still
carry the reason its last attempt ended.
+ * The reason this try ended, as recorded by a retry policy.
*/
state_reason?: string | null;
};
@@ -2280,7 +2280,7 @@ export type TaskInstanceResponse = {
dag_version: DagVersionResponse | null;
team_name?: string | null;
/**
- * The reason the task instance reached its current state, as recorded by
a retry policy. May describe a previous attempt: it is cleared only when the
task next starts running, so a task waiting to be retried or re-run can still
carry the reason its last attempt ended.
+ * The reason the task instance reached its current state, as recorded by
a retry policy. May describe a previous attempt: a task waiting to be retried
still carries the reason its last attempt ended. It is cleared when the task is
cleared and when the next attempt is scheduled or deferred.
*/
state_reason?: string | null;
};
diff --git a/airflow-core/tests/unit/models/test_dagrun.py
b/airflow-core/tests/unit/models/test_dagrun.py
index ee63b2db379..cb39a14303b 100644
--- a/airflow-core/tests/unit/models/test_dagrun.py
+++ b/airflow-core/tests/unit/models/test_dagrun.py
@@ -3599,6 +3599,30 @@ def
test_schedule_tis_does_not_increment_try_number_if_ti_already_queued_by_othe
assert refreshed_ti.try_number == 1
+def test_schedule_tis_empty_operator_clears_retry_policy_columns(dag_maker,
session):
+ """The EmptyOperator UPDATE allocates an attempt too, so it must clear
like the other one."""
+ with dag_maker(session=session) as dag:
+ EmptyOperator(task_id="empty_task")
+
+ dr = dag_maker.create_dagrun(session=session)
+ ti = dr.get_task_instance("empty_task", session=session)
+ ti.refresh_from_task(dag.get_task("empty_task"))
+ ti.state = TaskInstanceState.UP_FOR_RETRY
+ ti.retry_reason = "rate limit"
+ ti.retry_delay_override = 300.0
+ session.commit()
+ ti_id = ti.id
+
+ dr.schedule_tis((ti,), session=session)
+ session.flush()
+ session.expire_all()
+
+ scheduled = session.get(TI, ti_id)
+ assert scheduled.state == TaskInstanceState.SUCCESS
+ assert scheduled.retry_reason is None
+ assert scheduled.retry_delay_override is None
+
+
def
test_schedule_tis_empty_operator_does_not_short_circuit_if_ti_already_queued(dag_maker,
session):
with dag_maker(session=session) as dag:
EmptyOperator(task_id="empty_task")
@@ -3653,6 +3677,8 @@ def
test_schedule_tis_preserves_allocated_attempt(dag_maker, session, state, try
ti.refresh_from_task(dag.get_task("task"))
ti.state = state
ti.try_number = try_number
+ ti.retry_reason = "rate limit"
+ ti.retry_delay_override = 300.0
session.commit()
ti_id = ti.id
@@ -3660,7 +3686,11 @@ def
test_schedule_tis_preserves_allocated_attempt(dag_maker, session, state, try
session.flush()
session.expire_all()
- assert session.get(TI, ti_id).try_number == try_number
+ scheduled = session.get(TI, ti_id)
+ assert scheduled.try_number == try_number
+ # The finished try keeps these in task_instance_history; the new one must
not inherit them.
+ assert scheduled.retry_reason is None
+ assert scheduled.retry_delay_override is None
def
test_schedule_tis_up_for_reschedule_does_not_increment_try_number(dag_maker,
session):
@@ -3844,7 +3874,8 @@ def
test_schedule_tis_only_one_scheduler_update_succeeds_when_competing(dag_make
@pytest.mark.need_serialized_dag
-def test_schedule_tis_start_trigger(dag_maker, session):
[email protected]("state", [None, TaskInstanceState.UP_FOR_RETRY])
+def test_schedule_tis_start_trigger(dag_maker, session, state):
"""
Test that an operator with start_trigger_args set can be directly deferred
during scheduling.
"""
@@ -3873,8 +3904,14 @@ def test_schedule_tis_start_trigger(dag_maker, session):
assert ti.state is None
ti.task = dr.dag.get_task("test_task")
+ ti.state = state
+ ti.retry_reason = "rate limit"
+ ti.retry_delay_override = 300.0
dr.schedule_tis((ti,), session=session)
assert ti.state == TaskInstanceState.DEFERRED
+ # This path defers before the UPDATE in schedule_tis, so defer_task() has
to clear.
+ assert ti.retry_reason is None
+ assert ti.retry_delay_override is None
@pytest.mark.need_serialized_dag
diff --git a/airflow-core/tests/unit/models/test_taskinstance.py
b/airflow-core/tests/unit/models/test_taskinstance.py
index c02af00a0d5..286a04574db 100644
--- a/airflow-core/tests/unit/models/test_taskinstance.py
+++ b/airflow-core/tests/unit/models/test_taskinstance.py
@@ -5130,6 +5130,74 @@ def
test_clear_task_instances_resets_context_carrier(dag_maker, session):
assert dag_run.context_carrier["traceparent"] != original_dr_traceparent
[email protected]_test
[email protected](
+ "state", [TaskInstanceState.SUCCESS, TaskInstanceState.FAILED,
TaskInstanceState.SKIPPED]
+)
+def
test_set_state_clears_retry_policy_columns_when_leaving_up_for_retry(dag_maker,
session, state):
+ """A retry marked terminal by hand never runs, so it must not keep the
previous attempt's values."""
+ with dag_maker("test_set_state_retry_policy_columns"):
+ EmptyOperator(task_id="t1")
+ dag_run = dag_maker.create_dagrun()
+ ti = dag_run.get_task_instance("t1", session=session)
+ ti.state = TaskInstanceState.UP_FOR_RETRY
+ ti.retry_reason = "rate limit"
+ ti.retry_delay_override = 300.0
+ session.flush()
+
+ ti.set_state(state, session=session)
+
+ assert ti.retry_reason is None
+ assert ti.retry_delay_override is None
+
+
[email protected]_test
+def test_set_state_keeps_retry_policy_columns_while_up_for_retry(dag_maker,
session):
+ """next_retry_datetime() reads the override while the task waits, so a
re-set must not clear it."""
+ with dag_maker("test_set_state_keeps_retry_policy_columns"):
+ EmptyOperator(task_id="t1")
+ dag_run = dag_maker.create_dagrun()
+ ti = dag_run.get_task_instance("t1", session=session)
+ ti.state = TaskInstanceState.RUNNING
+ ti.retry_reason = "rate limit"
+ ti.retry_delay_override = 300.0
+ session.flush()
+
+ ti.set_state(TaskInstanceState.UP_FOR_RETRY, session=session)
+
+ assert ti.retry_reason == "rate limit"
+ assert ti.retry_delay_override == 300.0
+
+
[email protected]_test
+def test_clear_task_instances_clears_retry_policy_columns(dag_maker, session):
+ """Neither column may survive the clear."""
+ with dag_maker("test_clear_retry_policy_columns"):
+ EmptyOperator(task_id="t1")
+ dag_run = dag_maker.create_dagrun()
+ ti = dag_run.get_task_instance("t1", session=session)
+ ti.state = TaskInstanceState.FAILED
+ ti.retry_reason = "auth error, do not retry"
+ ti.retry_delay_override = 300.0
+ session.flush()
+ old_id = ti.id
+
+ clear_task_instances([ti], session)
+ session.flush()
+ session.expire_all()
+
+ # The archived try keeps its own values, so the reset cannot move above
the archive.
+ archived = session.get(TI, old_id)
+ assert archived.working_set is None
+ assert archived.retry_reason == "auth error, do not retry"
+ assert archived.retry_delay_override == 300.0
+
+ successor = dag_run.get_task_instance("t1", session=session)
+ assert successor.id != old_id
+ assert successor.retry_reason is None
+ assert successor.retry_delay_override is None
+
+
@pytest.mark.db_test
def test_clear_task_instances_preserves_detail_level(dag_maker, session):
"""clear_task_instances should produce a new context_carrier that keeps
the detail level from dag run conf."""
diff --git a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
index a705d2eaf66..0693fe15742 100644
--- a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
+++ b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
@@ -2732,10 +2732,7 @@ class TaskInstanceHistoryResponse(BaseModel):
note: Annotated[str | None, Field(title="Note")] = None
state_reason: Annotated[
str | None,
- Field(
- description="The reason the task instance reached its current
state, as recorded by a retry policy. May describe a previous attempt: it is
cleared only when the task next starts running, so a task waiting to be retried
or re-run can still carry the reason its last attempt ended.",
- title="State Reason",
- ),
+ Field(description="The reason this try ended, as recorded by a retry
policy.", title="State Reason"),
] = None
@@ -2786,7 +2783,7 @@ class TaskInstanceResponse(BaseModel):
state_reason: Annotated[
str | None,
Field(
- description="The reason the task instance reached its current
state, as recorded by a retry policy. May describe a previous attempt: it is
cleared only when the task next starts running, so a task waiting to be retried
or re-run can still carry the reason its last attempt ended.",
+ description="The reason the task instance reached its current
state, as recorded by a retry policy. May describe a previous attempt: a task
waiting to be retried still carries the reason its last attempt ended. It is
cleared when the task is cleared and when the next attempt is scheduled or
deferred.",
title="State Reason",
),
] = None