This is an automated email from the ASF dual-hosted git repository.
vatsrahul1001 pushed a commit to branch v3-4-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
commit 188e2a3aa72b601132344530d89698f99f703005
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Fri Oct 30 12:40:10 2026 +0000
[v3-4-test] Store and show the retry deadline; start retries with empty run
fields (#76027) (#76028)
When a task instance failed and was put up for retry,
`prepare_db_for_next_try`
copied every column of the failed attempt into the new one, so the waiting
attempt showed the previous attempt's start date, end date, duration,
hostname and pid in the API and UI. The retry deadline was computed from
that
copied end_date, so it could not simply be nulled out.
We now store the deadline on the waiting attempt as `retry_at`. The
Execution
API deliberately avoids loading the serialized Dag, and loading it on every
retry just to compute a delay would undo that. A retry reported by a worker
therefore arrives without a deadline, and the scheduler computes and stores
it on its first pass over the run. Until then, and for rows already waiting
at
upgrade time, the deadline is computed from the previous attempt's end date.
Because the delay is fixed at that point, changing retry_delay in a new Dag
version no longer retimes a retry that is already waiting.
The column is ret-conned in tothe 3.4.0 migration that made each attempt
its own
task_instance row, since that is where a waiting retry became a separate row
inheriting the failed attempt's fields. Anyone who has already migrated to
RC1 will need to manually apply this, or recreate their DB:
```sql
-- PostgreSQL
ALTER TABLE task_instance ADD COLUMN retry_at TIMESTAMP WITH TIME ZONE;
-- MySQL
ALTER TABLE task_instance ADD COLUMN retry_at TIMESTAMP(6) NULL;
-- SQLite
ALTER TABLE task_instance ADD COLUMN retry_at DATETIME;
```
The scheduler has always skipped dependency evaluation for retries that
are not yet due. A waiting retry has already passed its trigger rule, so an
upstream that changes state during the wait is only acted on once the retry
is
due or the run checks for a deadlock, and the end state is the same. Doesn't
really affect anything but people messing about with clearing upstream tasks
etc, but the eventually consistent state remains the same before and after
this change, it just might take more time to reach it
`emit_state_change_metric` relied on the copied end_date to skip retries, so
scheduled_duration and queued_duration are now also emitted for retries and
cleared tasks. Each attempt has its own scheduled and queued times, so these
samples are accurate.
(cherry picked from commit 155560df6685ccb3db59f954faf927e80af8059c)
Co-authored-by: Ash Berlin-Taylor <[email protected]>
---
.../core_api/datamodels/task_instance_history.py | 4 +
.../core_api/datamodels/task_instances.py | 5 ++
.../api_fastapi/core_api/datamodels/ui/grid.py | 1 +
.../api_fastapi/core_api/openapi/_private_ui.yaml | 14 ++++
.../core_api/openapi/v2-rest-api-generated.yaml | 16 ++++
.../airflow/api_fastapi/core_api/routes/ui/grid.py | 2 +
.../api_fastapi/core_api/services/ui/grid.py | 5 ++
.../execution_api/routes/task_instances.py | 5 +-
.../src/airflow/jobs/scheduler_job_runner.py | 2 -
.../0142_3_4_0_unify_task_attempt_ownership.py | 3 +-
airflow-core/src/airflow/models/dagrun.py | 12 +++
airflow-core/src/airflow/models/taskinstance.py | 88 ++++++++++++++++++---
.../serialization/definitions/baseoperator.py | 2 +-
.../serialization/definitions/mappedoperator.py | 2 +-
.../ti_deps/deps/not_in_retry_period_dep.py | 6 +-
.../airflow/ui/openapi-gen/requests/schemas.gen.ts | 38 +++++++++
.../airflow/ui/openapi-gen/requests/types.gen.ts | 9 +++
.../airflow/ui/public/i18n/locales/en/common.json | 1 +
.../ui/src/components/TaskInstanceTooltip.test.tsx | 46 ++++++++++-
.../ui/src/components/TaskInstanceTooltip.tsx | 7 ++
.../ui/src/pages/TaskInstance/Details.test.tsx | 24 ++++++
.../airflow/ui/src/pages/TaskInstance/Details.tsx | 8 ++
.../core_api/routes/public/test_hitl.py | 1 +
.../core_api/routes/public/test_task_instances.py | 92 ++++++++++++++++------
.../api_fastapi/core_api/routes/ui/test_grid.py | 25 ++++++
.../versions/head/test_task_instances.py | 1 +
airflow-core/tests/unit/models/test_dagrun.py | 20 +++++
.../tests/unit/models/test_taskinstance.py | 64 ++++++++++++++-
airflow-core/tests/unit/models/test_trigger.py | 4 +-
.../src/airflowctl/api/datamodels/generated.py | 14 ++++
30 files changed, 470 insertions(+), 51 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 2d810885fe3..a4c7fa489a1 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
@@ -77,6 +77,10 @@ class TaskInstanceHistoryResponse(OmitsMissingRegion,
BaseModel):
validation_alias="retry_reason",
description=("The reason this try ended, as recorded by a retry
policy."),
)
+ retry_at: datetime | None = Field(
+ default=None,
+ description="When a task instance waiting to be retried becomes due.
Only set in the up_for_retry state.",
+ )
@field_validator("state_reason", mode="after")
@classmethod
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 a0c2d30d9c4..097bad4159f 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
@@ -110,6 +110,11 @@ class TaskInstanceResponse(OmitsMissingRegion, BaseModel):
),
)
+ retry_at: datetime | None = Field(
+ default=None,
+ description="When a task instance waiting to be retried becomes due.
Only set in the up_for_retry state.",
+ )
+
@field_validator("state_reason", mode="after")
@classmethod
def redact_state_reason(cls, v: str | None) -> str | None:
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/grid.py
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/grid.py
index b584fd7c4ca..c3497eeeaca 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/grid.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/grid.py
@@ -43,6 +43,7 @@ class LightGridTaskInstanceSummary(BaseModel):
loop_iterations_count: int | None = None
latest_region_id: UUID | None = None
latest_region_index: int | None = None
+ retry_at: datetime | None = None
class GridTISummaries(BaseModel):
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 c28d3ff03b7..7869196d863 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
@@ -5066,6 +5066,12 @@ components:
- type: integer
- type: 'null'
title: Latest Region Index
+ retry_at:
+ anyOf:
+ - type: string
+ format: date-time
+ - type: 'null'
+ title: Retry At
type: object
required:
- task_id
@@ -6003,6 +6009,14 @@ components:
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.'
+ retry_at:
+ anyOf:
+ - type: string
+ format: date-time
+ - type: 'null'
+ title: Retry At
+ description: When a task instance waiting to be retried becomes due.
Only
+ set in the up_for_retry state.
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 7c51d8b4b42..b1c2b5c3407 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
@@ -18017,6 +18017,14 @@ components:
- type: 'null'
title: State Reason
description: The reason this try ended, as recorded by a retry
policy.
+ retry_at:
+ anyOf:
+ - type: string
+ format: date-time
+ - type: 'null'
+ title: Retry At
+ description: When a task instance waiting to be retried becomes due.
Only
+ set in the up_for_retry state.
type: object
required:
- id
@@ -18224,6 +18232,14 @@ components:
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.'
+ retry_at:
+ anyOf:
+ - type: string
+ format: date-time
+ - type: 'null'
+ title: Retry At
+ description: When a task instance waiting to be retried becomes due.
Only
+ set in the up_for_retry state.
type: object
required:
- id
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/grid.py
b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/grid.py
index 6eca4ae979a..f6d571eea70 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/grid.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/grid.py
@@ -491,6 +491,7 @@ def _build_ti_summaries(
iteration == latest_iteration[(ti.task_id, loop_id, family_id)]
for loop_id, (family_id, iteration) in positions.items()
),
+ retry_at=getattr(ti, "retry_at", None),
)
def get_node_summaries() -> Iterable[dict[str, Any]]:
@@ -597,6 +598,7 @@ def get_grid_ti_summaries_stream(
TaskInstance.dag_version_id,
TaskInstance.start_date,
TaskInstance.end_date,
+ TaskInstance.retry_at,
DagVersion.version_number,
has_note_subq,
)
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/services/ui/grid.py
b/airflow-core/src/airflow/api_fastapi/core_api/services/ui/grid.py
index 2eaa3296aad..fe99d7feb71 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/services/ui/grid.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/services/ui/grid.py
@@ -68,6 +68,8 @@ class GridNodeAgg:
has_note: bool = False
loop_iterations: dict[str, set[tuple[UUID, int]]] =
field(default_factory=dict)
latest_region: tuple[UUID, int] | None = None
+ # Only meaningful while the summary holds a single task instance.
+ retry_at: datetime | None = None
def add_ti(
self,
@@ -80,6 +82,7 @@ class GridNodeAgg:
loop_positions: dict[str, tuple[UUID, int]] | None = None,
is_latest_pass: bool = True,
region: tuple[UUID, int] | None = None,
+ retry_at: datetime | None = None,
) -> None:
"""
Merge one task instance row into the summary.
@@ -92,6 +95,7 @@ class GridNodeAgg:
does not supply the coordinate: a link has to address the newest row.
"""
self.child_states[state] += 1
+ self.retry_at = retry_at if self.child_states.total() == 1 else None
# A looped task has one row per iteration, so a link without a
coordinate cannot say
# which one it means. Carry the newest so the grid can address it.
if is_latest_pass and region is not None:
@@ -254,6 +258,7 @@ def _find_aggregates(
"type": "task",
"parent_id": parent_id,
**_get_aggs_for_node(summary),
+ "retry_at": summary.retry_at,
},
summary,
)
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 bb3f8fdf20d..a20580ece0a 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
@@ -284,8 +284,8 @@ def ti_run(
)
# One sample per queue wait, not per try: the scheduler refreshes
queued_dttm on every
# queueing, so a retry and a resume from deferral each waited for a
slot of their own.
- # task.scheduled_duration skips retries (emit_state_change_metric
returns early while
- # end_date is set), so the two disagree on retries by design.
+ # task.scheduled_duration is one sample per try
(emit_state_change_metric returns early once
+ # the try has an end_date, as after a sensor reschedule), so the two
disagree on resumes by design.
# queued_dttm is unset only for runs that skip the scheduler's
queueing, e.g. dag.test().
emit_queued_duration = ti.queued_dttm is not None
@@ -298,6 +298,7 @@ def ti_run(
state=TaskInstanceState.RUNNING,
last_heartbeat_at=timezone.utcnow(),
retry_delay_override=None,
+ retry_at=None,
retry_reason=None,
)
diff --git a/airflow-core/src/airflow/jobs/scheduler_job_runner.py
b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
index 3817bcd450e..8a29b83c2e6 100644
--- a/airflow-core/src/airflow/jobs/scheduler_job_runner.py
+++ b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
@@ -3791,8 +3791,6 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
reset_tis_message.append(repr(ti))
successor = ti.prepare_db_for_next_try(session=session)
successor.state = None
- successor.queued_by_job_id = None
- successor.external_executor_id = None
successor.clear_next_method_args()
for ti in set(tis_to_adopt_or_reset) - set(to_reset):
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 41a1e0e055a..3f19e561a33 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
@@ -233,6 +233,7 @@ def upgrade():
with op.batch_alter_table("task_instance") as batch:
batch.add_column(sa.Column("working_set", sa.Boolean(),
nullable=True, server_default=sa.true()))
batch.add_column(sa.Column("archived_reason", sa.String(50),
nullable=True))
+ batch.add_column(sa.Column("retry_at", UtcDateTime(),
nullable=True))
batch.drop_constraint("task_instance_composite_key",
type_="unique")
batch.create_unique_constraint("task_instance_current_key",
[*_COORDINATES, "working_set"])
batch.create_unique_constraint("task_instance_try_key",
[*_COORDINATES, "try_number"])
@@ -598,7 +599,7 @@ def downgrade():
batch.drop_constraint("task_instance_current_key", type_="unique")
batch.drop_constraint("task_instance_try_key", type_="unique")
batch.create_unique_constraint("task_instance_composite_key",
list(_COORDINATES))
- for column in ("working_set", "archived_reason"):
+ for column in ("working_set", "archived_reason", "retry_at"):
batch.drop_column(column)
with op.batch_alter_table("task_instance_history") as batch:
batch.create_foreign_key(
diff --git a/airflow-core/src/airflow/models/dagrun.py
b/airflow-core/src/airflow/models/dagrun.py
index 206993e1b66..810e308fb59 100644
--- a/airflow-core/src/airflow/models/dagrun.py
+++ b/airflow-core/src/airflow/models/dagrun.py
@@ -1773,10 +1773,20 @@ class DagRun(Base, LoggingMixin):
expansion_happened = False
# (task id, region id) pairs for which _revise_map_indexes_if_mapped
was already done
revised_expansion_keys: set[tuple[str, UUID]] = set()
+ now = timezone.utcnow()
for schedulable in itertools.chain(schedulable_tis, additional_tis):
if TYPE_CHECKING:
assert isinstance(schedulable.task, Operator)
old_state = schedulable.state
+ if old_state == TaskInstanceState.UP_FOR_RETRY:
+ # Worker-reported retries arrive without one; storing it
spares later passes the
+ # previous-attempt lookup.
+ if schedulable.retry_at is None:
+ schedulable.retry_at = schedulable.next_retry_datetime()
+ # Not due yet, so no dependency of it can pass;
_are_premature_tis still counts it
+ # as runnable when the run looks for a deadlock.
+ if schedulable.retry_at >= now:
+ continue
if not schedulable.are_dependencies_met(session=session,
dep_context=dep_context):
old_states[schedulable.id] = old_state
continue
@@ -2720,6 +2730,7 @@ class DagRun(Base, LoggingMixin):
# Already archived with the finished try; the new one
must not inherit it.
retry_reason=None,
retry_delay_override=None,
+ retry_at=None,
)
.execution_options(synchronize_session=False)
)
@@ -2772,6 +2783,7 @@ class DagRun(Base, LoggingMixin):
try_number=next_try_number,
retry_reason=None,
retry_delay_override=None,
+ retry_at=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 6905ae965d8..974690b9382 100644
--- a/airflow-core/src/airflow/models/taskinstance.py
+++ b/airflow-core/src/airflow/models/taskinstance.py
@@ -144,6 +144,20 @@ from airflow.utils.state import DagRunState, State,
TaskInstanceState
TR = TaskReschedule
log = logging.getLogger(__name__)
+
+# Columns describing one attempt's execution; a successor attempt starts with
them empty.
+_PER_ATTEMPT_COLUMNS = (
+ "start_date",
+ "end_date",
+ "duration",
+ "hostname",
+ "unixname",
+ "pid",
+ "queued_dttm",
+ "scheduled_dttm",
+ "queued_by_job_id",
+ "last_heartbeat_at",
+)
tracer = trace.get_tracer(__name__)
if TYPE_CHECKING:
@@ -558,6 +572,7 @@ def clear_task_instances(
# 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.retry_at = None
ti.clear_next_method_args()
# Match DagVersion to latest serialized DAG when running on the
latest version.
if use_latest_version:
@@ -1191,6 +1206,9 @@ class TaskInstance(Base, LoggingMixin, BaseWorkload):
# 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)
+ # When an UP_FOR_RETRY attempt becomes due; written by the scheduler on
its first pass after the
+ # attempt is created.
+ retry_at: Mapped[datetime | None] = mapped_column(UtcDateTime,
nullable=True)
__table_args__ = (
Index("ti_dag_state", dag_id, state),
@@ -1628,6 +1646,7 @@ class TaskInstance(Base, LoggingMixin, BaseWorkload):
# prepare_db_for_next_try() would otherwise archive them under
this try number.
self.retry_reason = None
self.retry_delay_override = None
+ self.retry_at = 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()
@@ -1755,12 +1774,22 @@ class TaskInstance(Base, LoggingMixin, BaseWorkload):
"""Archive this UUID and return its successor in the caller's
transaction."""
successor_state = self.state
dag_version_id = self.dag_version_id
+ retry_at = None
+ # The execution API doesn't load the task, so a worker-reported retry
without a policy
+ # override is left for DagRun._get_ready_tis.
+ if successor_state == TaskInstanceState.UP_FOR_RETRY and (
+ self.retry_delay_override is not None or self.task is not None
+ ):
+ retry_at = self._compute_retry_deadline(
+ failed_try_number=self.try_number,
failed_end_date=self.end_date
+ )
self.archive(reason="retry", session=session)
values = {
attribute.columns[0].name: getattr(self, attribute.key)
for attribute in inspect(TaskInstance).column_attrs
}
values.update(
+ {name: None for name in _PER_ATTEMPT_COLUMNS},
id=uuid7(),
try_number=self.try_number + 1,
working_set=True,
@@ -1769,6 +1798,7 @@ class TaskInstance(Base, LoggingMixin, BaseWorkload):
external_executor_id=None,
dag_version_id=dag_version_id,
state=successor_state,
+ retry_at=retry_at,
)
session.execute(insert(TaskInstance.__table__).values(values))
successor = session.scalars(select(TaskInstance).where(TaskInstance.id
== values["id"])).one()
@@ -2060,28 +2090,60 @@ class TaskInstance(Base, LoggingMixin, BaseWorkload):
return False
return region is not None and region.node_id == self.task_id and
region.parent_region_id is None
- def next_retry_datetime(self):
+ def next_retry_datetime(self, *, session: Session | None = None) ->
datetime:
"""
Get datetime of the next retry if the task instance fails.
+ ``retry_at`` is usually written by the scheduler on its first pass
after an
+ ``UP_FOR_RETRY`` attempt is created. Until then, or for a row from
before that column, the
+ deadline is computed from the previous attempt's end date.
+ """
+ if self.state == TaskInstanceState.UP_FOR_RETRY:
+ if self.retry_at is not None:
+ return self.retry_at
+ return self._compute_retry_deadline(
+ failed_try_number=self.try_number - 1,
+
failed_end_date=self._get_previous_attempt_end_date(session=session) or
self.end_date,
+ )
+ return self._compute_retry_deadline(failed_try_number=self.try_number,
failed_end_date=self.end_date)
+
+ def _get_previous_attempt_end_date(self, *, session: Session | None) ->
datetime | None:
+ session = session or inspect(self).session
+ if session is None:
+ return None
+ return session.scalar(
+ select(TaskInstance.end_date)
+ .where(
+ TaskInstance.dag_id == self.dag_id,
+ TaskInstance.run_id == self.run_id,
+ TaskInstance.task_id == self.task_id,
+ TaskInstance.region_id == self.region_id,
+ TaskInstance.region_index == self.region_index,
+ TaskInstance.try_number == self.try_number - 1,
+ )
+ .execution_options(include_all_attempts=True)
+ )
+
+ def _compute_retry_deadline(
+ self, *, failed_try_number: int, failed_end_date: datetime | None
+ ) -> datetime:
+ """
+ Compute when the attempt after ``failed_try_number`` may start.
+
When a :class:`~airflow.sdk.definitions.retry_policy.RetryPolicy` has
- overridden the delay, ``retry_delay_override`` is stored on the task
- instance row and takes precedence over the static ``retry_delay`` /
- exponential-backoff calculation.
+ overridden the delay, ``retry_delay_override`` takes precedence over
the
+ static ``retry_delay`` / exponential-backoff calculation.
For exponential backoff, retry_delay is used as base and will be
converted to seconds.
"""
- # Check for a policy-driven delay override.
+ base = failed_end_date if failed_end_date is not None else
timezone.utcnow()
if self.retry_delay_override is not None:
- base = self.end_date if self.end_date is not None else
timezone.utcnow()
return base + timedelta(seconds=self.retry_delay_override)
from airflow.sdk.definitions._internal.abstractoperator import
MAX_RETRY_DELAY
- # While waiting for retry, the live row already identifies the next
attempt.
- failed_try_number = (
- self.try_number - 1 if self.state ==
TaskInstanceState.UP_FOR_RETRY else self.try_number
- )
+ if TYPE_CHECKING:
+ assert self.task is not None
delay = self.task.retry_delay
multiplier = self.task.retry_exponential_backoff if
self.task.retry_exponential_backoff != 0 else 1.0
if multiplier != 1.0 and multiplier > 0:
@@ -2124,7 +2186,7 @@ class TaskInstance(Base, LoggingMixin, BaseWorkload):
delay = timedelta(seconds=delay_backoff_in_seconds)
if self.task.max_retry_delay:
delay = min(self.task.max_retry_delay, delay)
- return self.end_date + delay
+ return base + delay
def ready_for_retry(self) -> bool:
"""Check on whether the task instance is in the right state and
timeframe to be retried."""
@@ -2353,8 +2415,7 @@ class TaskInstance(Base, LoggingMixin, BaseWorkload):
Supported states: QUEUED and RUNNING
"""
if self.end_date:
- # if the task has an end date, it means that this is not its first
round.
- # we send the state transition time metric only on the first try,
otherwise it gets more complex.
+ # This try already ran once (e.g. a sensor reschedule); only its
first scheduling is timed.
return
# switch on state and deduce which metric to send
@@ -2681,6 +2742,7 @@ class TaskInstance(Base, LoggingMixin, BaseWorkload):
# A start_from_trigger retry skips schedule_tis' UPDATE, so clear
here too.
self.retry_reason = None
self.retry_delay_override = None
+ self.retry_at = 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/serialization/definitions/baseoperator.py
b/airflow-core/src/airflow/serialization/definitions/baseoperator.py
index 4d5647a8555..9f41bde935d 100644
--- a/airflow-core/src/airflow/serialization/definitions/baseoperator.py
+++ b/airflow-core/src/airflow/serialization/definitions/baseoperator.py
@@ -103,7 +103,7 @@ class SerializedBaseOperator(DAGNode):
map_index_template: str | None = None
max_active_tis_per_dag: int | None = None
max_active_tis_per_dagrun: int | None = None
- max_retry_delay: datetime.timedelta | float | None = None
+ max_retry_delay: datetime.timedelta | None = None
multiple_outputs: bool = False
# Boolean flags for callback existence
diff --git
a/airflow-core/src/airflow/serialization/definitions/mappedoperator.py
b/airflow-core/src/airflow/serialization/definitions/mappedoperator.py
index 4033152abbb..aea53d1bd47 100644
--- a/airflow-core/src/airflow/serialization/definitions/mappedoperator.py
+++ b/airflow-core/src/airflow/serialization/definitions/mappedoperator.py
@@ -297,7 +297,7 @@ class SerializedMappedOperator(DAGNode):
return float(value)
@property
- def max_retry_delay(self) -> datetime.timedelta | float | None:
+ def max_retry_delay(self) -> datetime.timedelta | None:
return self._get_partial_kwargs_or_operator_default("max_retry_delay")
@property
diff --git a/airflow-core/src/airflow/ti_deps/deps/not_in_retry_period_dep.py
b/airflow-core/src/airflow/ti_deps/deps/not_in_retry_period_dep.py
index ce20fddf40c..0af1adfb292 100644
--- a/airflow-core/src/airflow/ti_deps/deps/not_in_retry_period_dep.py
+++ b/airflow-core/src/airflow/ti_deps/deps/not_in_retry_period_dep.py
@@ -42,11 +42,9 @@ class NotInRetryPeriodDep(BaseTIDep):
yield self._passing_status(reason="The task instance was not
marked for retrying.")
return
- # Calculate the date first so that it is always smaller than the
timestamp used by
- # ready_for_retry
cur_date = timezone.utcnow()
- next_task_retry_date = ti.next_retry_datetime()
- if ti.is_premature:
+ next_task_retry_date = ti.next_retry_datetime(session=session)
+ if next_task_retry_date >= cur_date:
yield self._failing_status(
reason=(
f"Task is not ready for retry yet but will be retried
automatically. "
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 32669dad1aa..8ba458c15dc 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
@@ -8200,6 +8200,19 @@ export const $TaskInstanceHistoryResponse = {
],
title: 'State Reason',
description: 'The reason this try ended, as recorded by a retry
policy.'
+ },
+ retry_at: {
+ anyOf: [
+ {
+ type: 'string',
+ format: 'date-time'
+ },
+ {
+ type: 'null'
+ }
+ ],
+ title: 'Retry At',
+ description: 'When a task instance waiting to be retried becomes
due. Only set in the up_for_retry state.'
}
},
type: 'object',
@@ -8535,6 +8548,19 @@ 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: 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.'
+ },
+ retry_at: {
+ anyOf: [
+ {
+ type: 'string',
+ format: 'date-time'
+ },
+ {
+ type: 'null'
+ }
+ ],
+ title: 'Retry At',
+ description: 'When a task instance waiting to be retried becomes
due. Only set in the up_for_retry state.'
}
},
type: 'object',
@@ -12318,6 +12344,18 @@ export const $LightGridTaskInstanceSummary = {
}
],
title: 'Latest Region Index'
+ },
+ retry_at: {
+ anyOf: [
+ {
+ type: 'string',
+ format: 'date-time'
+ },
+ {
+ type: 'null'
+ }
+ ],
+ title: 'Retry At'
}
},
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 c34a9a1dfee..6f088ae943a 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
@@ -2232,6 +2232,10 @@ export type TaskInstanceHistoryResponse = {
* The reason this try ended, as recorded by a retry policy.
*/
state_reason?: string | null;
+ /**
+ * When a task instance waiting to be retried becomes due. Only set in the
up_for_retry state.
+ */
+ retry_at?: string | null;
};
/**
@@ -2283,6 +2287,10 @@ export type TaskInstanceResponse = {
* 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;
+ /**
+ * When a task instance waiting to be retried becomes due. Only set in the
up_for_retry state.
+ */
+ retry_at?: string | null;
};
/**
@@ -3179,6 +3187,7 @@ export type LightGridTaskInstanceSummary = {
loop_iterations_count?: number | null;
latest_region_id?: string | null;
latest_region_index?: number | null;
+ retry_at?: string | null;
};
/**
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
index c12d426d6a6..7d55f915642 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
@@ -487,6 +487,7 @@
"queue": "Queue",
"queuedWhen": "Queued At",
"renderedMapIndex": "Rendered Map Index",
+ "retryAt": "Retry At",
"scheduledWhen": "Scheduled At",
"stateReason": "Reason for state",
"stateReasonSummary": {
diff --git
a/airflow-core/src/airflow/ui/src/components/TaskInstanceTooltip.test.tsx
b/airflow-core/src/airflow/ui/src/components/TaskInstanceTooltip.test.tsx
index e667c59f86f..692182b3903 100644
--- a/airflow-core/src/airflow/ui/src/components/TaskInstanceTooltip.test.tsx
+++ b/airflow-core/src/airflow/ui/src/components/TaskInstanceTooltip.test.tsx
@@ -20,7 +20,11 @@ import "@testing-library/jest-dom/vitest";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
-import type { LightGridTaskInstanceSummary, TaskInstanceResponse } from
"openapi/requests/types.gen";
+import type {
+ LightGridTaskInstanceSummary,
+ TaskInstanceHistoryResponse,
+ TaskInstanceResponse,
+} from "openapi/requests/types.gen";
import { Wrapper } from "src/utils/Wrapper";
@@ -34,6 +38,18 @@ vi.mock("react-i18next", () => ({
}),
}));
+const buildTry = (overrides: Partial<TaskInstanceHistoryResponse>) =>
+ ({
+ dag_id: "test",
+ dag_run_id: "test",
+ duration: null,
+ end_date: null,
+ start_date: null,
+ task_id: "my_task",
+ try_number: 2,
+ ...overrides,
+ }) as unknown as TaskInstanceHistoryResponse;
+
describe("TaskInstanceTooltip", () => {
it("renders children directly when both taskInstance and tooltip are
undefined", () => {
render(
@@ -291,4 +307,32 @@ describe("TaskInstanceTooltip", () => {
// two neighbouring bars can both be a first try and the number is what
tells them apart.
expect(screen.getByText(/tryNumber/iu).textContent).toContain("1");
});
+
+ it("shows when a try waiting to be retried becomes due", () => {
+ render(
+ <TaskInstanceTooltip
+ open
+ taskInstance={buildTry({ retry_at: "2026-10-29T12:00:30Z", state:
"up_for_retry" })}
+ >
+ <span>trigger</span>
+ </TaskInstanceTooltip>,
+ { wrapper: Wrapper },
+ );
+
+ expect(screen.getByText(/taskInstance\.retryAt/u)).toBeInTheDocument();
+ });
+
+ it.each([
+ { retry_at: null, state: "up_for_retry" },
+ { retry_at: "2026-10-29T12:00:30Z", state: "failed" },
+ ] as const)("hides retry at for a $state try with retry_at $retry_at",
(overrides) => {
+ render(
+ <TaskInstanceTooltip open taskInstance={buildTry(overrides)}>
+ <span>trigger</span>
+ </TaskInstanceTooltip>,
+ { wrapper: Wrapper },
+ );
+
+
expect(screen.queryByText(/taskInstance\.retryAt/u)).not.toBeInTheDocument();
+ });
});
diff --git a/airflow-core/src/airflow/ui/src/components/TaskInstanceTooltip.tsx
b/airflow-core/src/airflow/ui/src/components/TaskInstanceTooltip.tsx
index 8df0332b122..4c57757ab05 100644
--- a/airflow-core/src/airflow/ui/src/components/TaskInstanceTooltip.tsx
+++ b/airflow-core/src/airflow/ui/src/components/TaskInstanceTooltip.tsx
@@ -150,6 +150,13 @@ const TaskInstanceTooltip = ({
</Text>
</>
) : undefined}
+ {"retry_at" in taskInstance &&
+ taskInstance.state === "up_for_retry" &&
+ Boolean(taskInstance.retry_at) ? (
+ <Text>
+ {translate("taskInstance.retryAt")}: <Time
datetime={taskInstance.retry_at} />
+ </Text>
+ ) : undefined}
{"min_start_date" in taskInstance && taskInstance.min_start_date
!== null ? (
<>
<Text>
diff --git
a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Details.test.tsx
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Details.test.tsx
index bcd4182f5bf..9e01ae2cd25 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Details.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Details.test.tsx
@@ -136,6 +136,30 @@ describe("Details state reason row", () => {
});
});
+describe("Details retry at row", () => {
+ beforeEach(() => {
+ i18n.addResourceBundle("en", "common", commonLocale, true, true);
+ });
+
+ it("shows when a try waiting to be retried becomes due", () => {
+ renderDetails(buildTaskInstance({ retry_at: "2026-10-29T12:00:30Z", state:
"up_for_retry" }));
+
+ const row =
screen.getByText(i18n.t("common:taskInstance.retryAt")).closest("tr");
+
+ expect(row).not.toBeNull();
+ expect(within(row as
HTMLElement).getByTestId("time-display")).toHaveAttribute(
+ "datetime",
+ "2026-10-29T12:00:30Z",
+ );
+ });
+
+ it("omits the row when the try has no retry_at", () => {
+ renderDetails(buildTaskInstance({ retry_at: null, state: "up_for_retry"
}));
+
+
expect(screen.queryByText(i18n.t("common:taskInstance.retryAt"))).not.toBeInTheDocument();
+ });
+});
+
describe("Details loop context", () => {
beforeEach(() => {
i18n.addResourceBundle("en", "common", commonLocale, true, true);
diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Details.tsx
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Details.tsx
index 05314739c2b..129fa910d13 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Details.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Details.tsx
@@ -252,6 +252,14 @@ export const Details = () => {
<Time datetime={tryInstance?.end_date} />
</Table.Cell>
</Table.Row>
+ {Boolean(tryInstance?.retry_at) ? (
+ <Table.Row>
+ <Table.Cell>{translate("taskInstance.retryAt")}</Table.Cell>
+ <Table.Cell>
+ <Time datetime={tryInstance?.retry_at} />
+ </Table.Cell>
+ </Table.Row>
+ ) : undefined}
<Table.Row>
<Table.Cell>{translate("taskInstance.dagVersion")}</Table.Cell>
<Table.Cell>
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_hitl.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_hitl.py
index 221cf9f95f9..bcc836f491b 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_hitl.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_hitl.py
@@ -427,6 +427,7 @@ def expected_sample_hitl_detail_dict(sample_ti:
TaskInstance) -> dict[str, Any]:
"task_id": TASK_ID,
"team_name": None,
"state_reason": None,
+ "retry_at": None,
"trigger": None,
"triggerer_job": None,
"try_number": 0,
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 ddb0776e8b8..eb47b72983f 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
@@ -493,6 +493,7 @@ class TestGetTaskInstance(TestTaskInstanceEndpoint):
"triggerer_job": None,
"team_name": None,
"state_reason": None,
+ "retry_at": None,
}
def test_should_include_state_reason(self, test_client, session):
@@ -612,6 +613,7 @@ class TestGetTaskInstance(TestTaskInstanceEndpoint):
"triggerer_job": None,
"team_name": None,
"state_reason": None,
+ "retry_at": None,
"dag_version": {
"id": response_data["dag_version"]["id"],
"version_number": expected_version_number,
@@ -711,6 +713,7 @@ class TestGetTaskInstance(TestTaskInstanceEndpoint):
},
"team_name": None,
"state_reason": None,
+ "retry_at": None,
}
def test_should_respond_200_with_task_state_in_removed(self, test_client,
session):
@@ -768,6 +771,7 @@ class TestGetTaskInstance(TestTaskInstanceEndpoint):
"triggerer_job": None,
"team_name": None,
"state_reason": None,
+ "retry_at": None,
}
def test_should_respond_200_task_instance_with_rendered(self, test_client,
session):
@@ -828,6 +832,7 @@ class TestGetTaskInstance(TestTaskInstanceEndpoint):
"triggerer_job": None,
"team_name": None,
"state_reason": None,
+ "retry_at": None,
}
def test_raises_404_for_nonexistent_task_instance(self, test_client):
@@ -964,6 +969,7 @@ class TestGetMappedTaskInstance(TestTaskInstanceEndpoint):
"triggerer_job": None,
"team_name": None,
"state_reason": None,
+ "retry_at": None,
}
def test_should_respond_401(self, unauthenticated_test_client):
@@ -3225,6 +3231,7 @@ class TestGetTaskInstanceTry(TestTaskInstanceEndpoint):
"version_number": 1,
},
"state_reason": None,
+ "retry_at": None,
}
def test_should_include_state_reason_from_history(self, test_client,
session):
@@ -3273,31 +3280,31 @@ class TestGetTaskInstanceTry(TestTaskInstanceEndpoint):
assert response_data == {
"dag_id": "example_python_operator",
"dag_display_name": "example_python_operator",
- "duration": 10000.0,
- "end_date": "2020-01-03T00:00:00Z",
+ "duration": 10000.0 if try_number == 1 else None,
+ "end_date": "2020-01-03T00:00:00Z" if try_number == 1 else None,
"executor": None,
"executor_config": "{}",
"id": mock.ANY,
"note": mock.ANY,
- "hostname": "",
+ "hostname": "" if try_number == 1 else None,
"map_index": -1,
"loop_iteration": None,
"max_tries": 0 if try_number == 1 else 1,
"operator": "PythonOperator",
"operator_name": "PythonOperator",
- "pid": 100,
+ "pid": 100 if try_number == 1 else None,
"pool": "default_pool",
"pool_slots": 1,
"priority_weight": 14,
"queue": "default_queue",
"queued_when": None,
"scheduled_when": None,
- "start_date": "2020-01-02T00:00:00Z",
+ "start_date": "2020-01-02T00:00:00Z" if try_number == 1 else None,
"state": "success" if try_number == 1 else None,
"task_id": "print_the_context",
"task_display_name": "print_the_context",
"try_number": try_number,
- "unixname": getuser(),
+ "unixname": getuser() if try_number == 1 else None,
"dag_run_id": "TEST_DAG_RUN_ID",
"dag_version": {
"bundle_name":
"apache-airflow-providers-standard-example-dags",
@@ -3310,6 +3317,7 @@ class TestGetTaskInstanceTry(TestTaskInstanceEndpoint):
"version_number": 1,
},
"state_reason": None,
+ "retry_at": None,
}
@pytest.mark.parametrize("try_number", [1, 2])
@@ -3357,31 +3365,31 @@ class TestGetTaskInstanceTry(TestTaskInstanceEndpoint):
assert response_data == {
"dag_id": "example_python_operator",
"dag_display_name": "example_python_operator",
- "duration": 10000.0,
- "end_date": "2020-01-03T00:00:00Z",
+ "duration": 10000.0 if try_number == 1 else None,
+ "end_date": "2020-01-03T00:00:00Z" if try_number == 1 else
None,
"executor": None,
"executor_config": "{}",
"id": mock.ANY,
"note": mock.ANY,
- "hostname": "",
+ "hostname": "" if try_number == 1 else None,
"map_index": map_index,
"loop_iteration": None,
"max_tries": 0 if try_number == 1 else 1,
"operator": "PythonOperator",
"operator_name": "PythonOperator",
- "pid": 100,
+ "pid": 100 if try_number == 1 else None,
"pool": "default_pool",
"pool_slots": 1,
"priority_weight": 14,
"queue": "default_queue",
"queued_when": None,
"scheduled_when": None,
- "start_date": "2020-01-02T00:00:00Z",
+ "start_date": "2020-01-02T00:00:00Z" if try_number == 1 else
None,
"state": "failed" if try_number == 1 else None,
"task_id": "print_the_context",
"task_display_name": "print_the_context",
"try_number": try_number,
- "unixname": getuser(),
+ "unixname": getuser() if try_number == 1 else None,
"dag_run_id": "TEST_DAG_RUN_ID",
"dag_version": {
"bundle_name":
"apache-airflow-providers-standard-example-dags",
@@ -3394,6 +3402,7 @@ class TestGetTaskInstanceTry(TestTaskInstanceEndpoint):
"version_number": 1,
},
"state_reason": None,
+ "retry_at": None,
}
def test_should_respond_200_with_task_state_in_deferred(self, test_client,
session):
@@ -3464,6 +3473,7 @@ class TestGetTaskInstanceTry(TestTaskInstanceEndpoint):
"version_number": 1,
},
"state_reason": None,
+ "retry_at": None,
}
def test_should_respond_200_with_task_state_in_removed(self, test_client,
session):
@@ -3515,6 +3525,7 @@ class TestGetTaskInstanceTry(TestTaskInstanceEndpoint):
"version_number": 1,
},
"state_reason": None,
+ "retry_at": None,
}
def test_should_respond_401(self, unauthenticated_test_client):
@@ -3594,6 +3605,7 @@ class TestGetTaskInstanceTry(TestTaskInstanceEndpoint):
"dag_display_name": "dag_with_multiple_versions",
},
"state_reason": None,
+ "retry_at": None,
}
def test_should_not_return_duplicate_runs(self, test_client, session):
@@ -4862,6 +4874,7 @@ class
TestPostClearTaskInstances(TestTaskInstanceEndpoint):
"triggerer_job": None,
"team_name": None,
"state_reason": None,
+ "retry_at": None,
"try_number": 1,
"unixname": getuser(),
},
@@ -5537,6 +5550,24 @@ class
TestPostClearTaskInstances(TestTaskInstanceEndpoint):
class TestGetTaskInstanceTries(TestTaskInstanceEndpoint):
+ def test_waiting_retry_returns_retry_at(self, test_client, session):
+ self.create_task_instances(
+ session=session, task_instances=[{"state": State.SUCCESS}],
with_ti_history=True
+ )
+ current =
session.scalar(select(TaskInstance).where(TaskInstance.working_set.is_(True)))
+ current.state = State.UP_FOR_RETRY
+ current.retry_at = datetime(2020, 1, 3, 0, 10)
+ session.commit()
+
+ response = test_client.get(
+
"/dags/example_python_operator/dagRuns/TEST_DAG_RUN_ID/taskInstances/print_the_context/tries"
+ )
+
+ assert response.status_code == 200
+ attempts = {item["try_number"]: item for item in
response.json()["task_instances"]}
+ assert attempts[1]["retry_at"] is None
+ assert attempts[2]["retry_at"] == "2020-01-03T00:10:00Z"
+
def test_history_without_dag_version_serializes_null(self, test_client,
session):
self.create_task_instances(
session=session, task_instances=[{"state": State.SUCCESS}],
with_ti_history=True
@@ -5612,35 +5643,36 @@ class
TestGetTaskInstanceTries(TestTaskInstanceEndpoint):
"version_number": 1,
},
"state_reason": None,
+ "retry_at": None,
},
{
"dag_id": "example_python_operator",
"dag_display_name": "example_python_operator",
- "duration": 10000.0,
- "end_date": "2020-01-03T00:00:00Z",
+ "duration": None,
+ "end_date": None,
"executor": None,
"executor_config": "{}",
"id": mock.ANY,
"note": mock.ANY,
- "hostname": "",
+ "hostname": None,
"map_index": -1,
"loop_iteration": None,
"max_tries": 1,
"operator": "PythonOperator",
"operator_name": "PythonOperator",
- "pid": 100,
+ "pid": None,
"pool": "default_pool",
"pool_slots": 1,
"priority_weight": 14,
"queue": "default_queue",
"queued_when": None,
"scheduled_when": None,
- "start_date": "2020-01-02T00:00:00Z",
+ "start_date": None,
"state": None,
"task_id": "print_the_context",
"task_display_name": "print_the_context",
"try_number": 2,
- "unixname": getuser(),
+ "unixname": None,
"dag_run_id": "TEST_DAG_RUN_ID",
"dag_version": {
"bundle_name":
"apache-airflow-providers-standard-example-dags",
@@ -5653,6 +5685,7 @@ class TestGetTaskInstanceTries(TestTaskInstanceEndpoint):
"version_number": 1,
},
"state_reason": None,
+ "retry_at": None,
},
],
"total_entries": 2,
@@ -5772,35 +5805,36 @@ class
TestGetTaskInstanceTries(TestTaskInstanceEndpoint):
"version_number": 1,
},
"state_reason": None,
+ "retry_at": None,
},
{
"dag_id": "example_python_operator",
"dag_display_name": "example_python_operator",
- "duration": 10000.0,
- "end_date": "2020-01-03T00:00:00Z",
+ "duration": None,
+ "end_date": None,
"executor": None,
"executor_config": "{}",
"id": mock.ANY,
"note": mock.ANY,
- "hostname": "",
+ "hostname": None,
"map_index": map_index,
"loop_iteration": None,
"max_tries": 1,
"operator": "PythonOperator",
"operator_name": "PythonOperator",
- "pid": 100,
+ "pid": None,
"pool": "default_pool",
"pool_slots": 1,
"priority_weight": 14,
"queue": "default_queue",
"queued_when": None,
"scheduled_when": None,
- "start_date": "2020-01-02T00:00:00Z",
+ "start_date": None,
"state": None,
"task_id": "print_the_context",
"task_display_name": "print_the_context",
"try_number": 2,
- "unixname": getuser(),
+ "unixname": None,
"dag_run_id": "TEST_DAG_RUN_ID",
"dag_version": {
"bundle_name":
"apache-airflow-providers-standard-example-dags",
@@ -5813,6 +5847,7 @@ class TestGetTaskInstanceTries(TestTaskInstanceEndpoint):
"version_number": 1,
},
"state_reason": None,
+ "retry_at": None,
},
],
"total_entries": 2,
@@ -5884,6 +5919,7 @@ class TestGetTaskInstanceTries(TestTaskInstanceEndpoint):
"dag_display_name": "dag_with_multiple_versions",
},
"state_reason": None,
+ "retry_at": None,
}
@@ -6651,6 +6687,7 @@ class TestPatchTaskInstance(TestTaskInstanceEndpoint):
"triggerer_job": None,
"team_name": None,
"state_reason": None,
+ "retry_at": None,
}
],
"total_entries": 1,
@@ -6932,6 +6969,7 @@ class TestPatchTaskInstance(TestTaskInstanceEndpoint):
"triggerer_job": None,
"team_name": None,
"state_reason": None,
+ "retry_at": None,
}
],
"total_entries": 1,
@@ -7075,6 +7113,7 @@ class TestPatchTaskInstance(TestTaskInstanceEndpoint):
"triggerer_job": None,
"team_name": None,
"state_reason": None,
+ "retry_at": None,
}
],
"total_entries": 1,
@@ -7143,6 +7182,7 @@ class TestPatchTaskInstance(TestTaskInstanceEndpoint):
"triggerer_job": None,
"team_name": None,
"state_reason": None,
+ "retry_at": None,
}
],
"total_entries": 1,
@@ -7258,6 +7298,7 @@ class TestPatchTaskInstance(TestTaskInstanceEndpoint):
"triggerer_job": None,
"team_name": None,
"state_reason": None,
+ "retry_at": None,
}
],
"total_entries": 1,
@@ -7347,6 +7388,7 @@ class TestPatchTaskInstance(TestTaskInstanceEndpoint):
"triggerer_job": None,
"team_name": None,
"state_reason": None,
+ "retry_at": None,
}
_check_task_instance_note(
@@ -7546,6 +7588,7 @@ class
TestPatchTaskInstanceDryRun(TestTaskInstanceEndpoint):
"triggerer_job": None,
"team_name": None,
"state_reason": None,
+ "retry_at": None,
}
],
"total_entries": 1,
@@ -7839,6 +7882,7 @@ class
TestPatchTaskInstanceDryRun(TestTaskInstanceEndpoint):
"triggerer_job": None,
"team_name": None,
"state_reason": None,
+ "retry_at": None,
}
],
"total_entries": 1,
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_grid.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_grid.py
index 65e46cd11de..cfb8e122cec 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_grid.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_grid.py
@@ -888,6 +888,7 @@ class TestGetGridDataEndpoint:
ti["loop_iterations_count"] = None
ti["latest_region_id"] = None
ti["latest_region_index"] = None
+ ti["retry_at"] = None
for obj in actual, expected:
tis = obj["task_instances"]
tis[:] = sorted(tis, key=lambda x: x["task_id"])
@@ -997,6 +998,7 @@ class TestGetGridDataEndpoint:
ti["loop_iterations_count"] = None
ti["latest_region_id"] = None
ti["latest_region_index"] = None
+ ti["retry_at"] = None
expected = sort_dict(expected)
actual = sort_dict(actual)
assert actual == expected
@@ -1270,6 +1272,29 @@ class TestGetGridDataEndpoint:
assert by_task_id[TASK_ID]["has_note"] is True
assert all(not ti["has_note"] for task_id, ti in by_task_id.items() if
task_id != TASK_ID)
+ def test_grid_ti_summaries_retry_at_only_for_a_single_task_instance(self,
session, test_client):
+ retry_at = pendulum.DateTime(2024, 12, 30, 3, 0, 0,
tzinfo=pendulum.UTC)
+ waiting = session.scalars(
+ select(TaskInstance).where(
+ TaskInstance.run_id == "run_2",
+ (TaskInstance.task_id == TASK_ID)
+ | ((TaskInstance.task_id == "mapped_task_group.subtask") &
(TaskInstance.map_index == 2)),
+ )
+ ).all()
+ for ti in waiting:
+ ti.state = TaskInstanceState.UP_FOR_RETRY
+ ti.retry_at = retry_at
+ session.commit()
+
+ response =
test_client.get(f"/grid/ti_summaries/{DAG_ID}?run_ids=run_2")
+ assert response.status_code == 200
+ [data] = self._parse_ndjson(response)
+ by_task_id = {ti["task_id"]: ti for ti in data["task_instances"]}
+
+ assert by_task_id[TASK_ID]["retry_at"] == "2024-12-30T03:00:00Z"
+ assert by_task_id["mapped_task_group.subtask"]["retry_at"] is None
+ assert by_task_id["mapped_task_group"]["retry_at"] is None
+
@staticmethod
def _parse_ndjson(response) -> list[dict]:
"""Parse NDJSON streaming response into a list of dicts."""
diff --git
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_instances.py
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_instances.py
index 9ff1bb8433d..336d7c28592 100644
---
a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_instances.py
+++
b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_instances.py
@@ -4258,6 +4258,7 @@ class TestTIUpdateState:
assert ti.state == State.UP_FOR_RETRY
assert ti.retry_delay_override == 42.5
assert ti.retry_reason == "Rate limit: backing off"
+ assert ti.retry_at == DEFAULT_END_DATE + timedelta(seconds=42.5)
def test_ti_update_state_retry_policy_overrides_persisted_in_history(
self, client, session, create_task_instance
diff --git a/airflow-core/tests/unit/models/test_dagrun.py
b/airflow-core/tests/unit/models/test_dagrun.py
index cb39a14303b..7ffdf347472 100644
--- a/airflow-core/tests/unit/models/test_dagrun.py
+++ b/airflow-core/tests/unit/models/test_dagrun.py
@@ -6619,3 +6619,23 @@ def
test_lock_dag_runs_locks_again_after_a_savepoint_rolls_back(session, two_run
lock_dag_runs(session, [first])
assert locked_dag_run_keys == [[first], [first]]
+
+
[email protected](TI, "are_dependencies_met", autospec=True)
+def
test_task_instance_scheduling_decisions_skips_retry_not_yet_due(mock_deps_met,
dag_maker, session):
+ with dag_maker(session=session):
+ EmptyOperator(task_id="retrying", retries=2,
retry_delay=datetime.timedelta(minutes=10))
+ dr = dag_maker.create_dagrun(session=session)
+ ti = dr.get_task_instance("retrying", session=session)
+ ti.try_number = 1
+ ti.end_date = timezone.utcnow()
+ ti.state = TaskInstanceState.UP_FOR_RETRY
+ successor = ti.prepare_db_for_next_try(session)
+ successor.retry_at = None
+ session.flush()
+
+ decision = dr.task_instance_scheduling_decisions(session=session)
+
+ assert decision.schedulable_tis == []
+ mock_deps_met.assert_not_called()
+ assert successor.retry_at == ti.end_date + datetime.timedelta(minutes=10)
diff --git a/airflow-core/tests/unit/models/test_taskinstance.py
b/airflow-core/tests/unit/models/test_taskinstance.py
index 958ed9c8252..732e3a4a420 100644
--- a/airflow-core/tests/unit/models/test_taskinstance.py
+++ b/airflow-core/tests/unit/models/test_taskinstance.py
@@ -854,6 +854,67 @@ class TestTaskInstance:
)
assert history.try_number == try_number
+ @time_machine.travel("2026-10-29 12:00:00+00:00", tick=False)
+ def test_prepare_next_try_for_retry_stores_deadline_and_starts_empty(self,
dag_maker, session):
+ with dag_maker():
+ task = BashOperator(
+ task_id="retry",
+ bash_command="exit 1",
+ retries=3,
+ retry_delay=datetime.timedelta(seconds=30),
+ )
+ ti = dag_maker.create_dagrun(session=session).task_instances[0]
+ ti.task = task
+ ti.try_number = 1
+ ti.start_date = timezone.utcnow() - datetime.timedelta(minutes=5)
+ ti.end_date = timezone.utcnow()
+ ti.duration = 300.0
+ ti.hostname = "worker-1"
+ ti.unixname = "airflow"
+ ti.pid = 1234
+ ti.queued_dttm = ti.scheduled_dttm = ti.last_heartbeat_at =
ti.start_date
+ ti.state = State.UP_FOR_RETRY
+ session.flush()
+
+ successor = ti.prepare_db_for_next_try(session)
+ session.flush()
+ session.expire(successor)
+
+ assert successor.retry_at == timezone.utcnow() +
datetime.timedelta(seconds=30)
+ assert successor.next_retry_datetime() == successor.retry_at
+ assert successor.start_date is None
+ assert successor.end_date is None
+ assert successor.duration is None
+ assert successor.hostname is None
+ assert successor.unixname is None
+ assert successor.pid is None
+ assert successor.queued_dttm is None
+ assert successor.scheduled_dttm is None
+ assert successor.last_heartbeat_at is None
+
+ def
test_next_retry_datetime_without_stored_deadline_uses_previous_attempt(self,
dag_maker, session):
+ with dag_maker():
+ task = BashOperator(
+ task_id="retry",
+ bash_command="exit 1",
+ retries=3,
+ retry_delay=datetime.timedelta(seconds=30),
+ )
+ ti = dag_maker.create_dagrun(session=session).task_instances[0]
+ ti.task = task
+ ti.try_number = 1
+ failed_at = timezone.datetime(2026, 10, 29, 12)
+ ti.end_date = failed_at
+ ti.state = State.UP_FOR_RETRY
+ successor = ti.prepare_db_for_next_try(session)
+ successor.task = task
+ # A row written before retry_at existed still carries the failed
attempt's dates.
+ successor.retry_at = None
+ successor.end_date = failed_at + datetime.timedelta(days=1)
+ session.flush()
+
+ assert successor.next_retry_datetime() == failed_at +
datetime.timedelta(seconds=30)
+
def test_next_retry_datetime(self, dag_maker):
delay = datetime.timedelta(seconds=30)
max_delay = datetime.timedelta(minutes=60)
@@ -2885,6 +2946,7 @@ class TestTaskInstance:
"context_carrier": {},
"retry_delay_override": 60.0,
"retry_reason": "Rate limit, backing off",
+ "retry_at": None,
"working_set": True,
"archived_reason": None,
}
@@ -3183,7 +3245,7 @@ class TestTaskInstance:
assert successor.try_number == original_try + 1
assert successor.working_set is True
assert successor.state == TaskInstanceState.UP_FOR_RETRY
- assert successor.end_date == attempt.end_date
+ assert successor.end_date is None
assert (
session.scalar(
sa.select(sa.func.count())
diff --git a/airflow-core/tests/unit/models/test_trigger.py
b/airflow-core/tests/unit/models/test_trigger.py
index d03ccd58330..ff12adf0f37 100644
--- a/airflow-core/tests/unit/models/test_trigger.py
+++ b/airflow-core/tests/unit/models/test_trigger.py
@@ -823,7 +823,6 @@ def test_submit_event_task_end_failed_respects_retries(
assert ti.next_method is None
assert ti.next_kwargs is None
- assert ti.end_date is not None
tih = session.scalars(
select(TaskInstance)
@@ -840,9 +839,12 @@ def test_submit_event_task_end_failed_respects_retries(
assert ti.id != old_ti_id
assert tih[0].id == old_ti_id
assert tih[0].try_number == 1
+ assert tih[0].end_date is not None
assert ti.try_number == 2
+ assert ti.end_date is None
else:
assert tih == []
+ assert ti.end_date is not None
assert ti.id == old_ti_id
assert ti.try_number == 1
diff --git a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
index 9a6fea85062..38994f1bb50 100644
--- a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
+++ b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
@@ -2734,6 +2734,13 @@ class TaskInstanceHistoryResponse(BaseModel):
str | None,
Field(description="The reason this try ended, as recorded by a retry
policy.", title="State Reason"),
] = None
+ retry_at: Annotated[
+ datetime | None,
+ Field(
+ description="When a task instance waiting to be retried becomes
due. Only set in the up_for_retry state.",
+ title="Retry At",
+ ),
+ ] = None
class TaskInstanceResponse(BaseModel):
@@ -2787,6 +2794,13 @@ class TaskInstanceResponse(BaseModel):
title="State Reason",
),
] = None
+ retry_at: Annotated[
+ datetime | None,
+ Field(
+ description="When a task instance waiting to be retried becomes
due. Only set in the up_for_retry state.",
+ title="Retry At",
+ ),
+ ] = None
class TaskResponse(BaseModel):