kaxil commented on code in PR #74719:
URL: https://github.com/apache/airflow/pull/74719#discussion_r4284033693
##########
task-sdk/src/airflow/sdk/execution_time/task_runner.py:
##########
@@ -1956,9 +1960,54 @@ def _handle_current_task_failed(
return _finalize_task_failure(
ti, log, retry_delay_override=decision.retry_delay,
retry_reason=decision.reason
)
+ if decision is not None and decision.action == RetryAction.RESCHEDULE:
+ if (limit := _reschedule_limit_reached(ti, decision, context)) is None:
+ ti.end_date = datetime.now(tz=UTC)
+ delay = decision.retry_delay if decision.retry_delay is not None
else ti.task.retry_delay
+ reschedule_date = ti.end_date + delay
+ log.info(
+ "Retry policy rescheduled the task, marking task as
UP_FOR_RESCHEDULE",
+ reschedule_date=reschedule_date,
+ reschedule_count=_reschedule_count(ti),
+ max_reschedules=decision.max_reschedules,
+ reschedule_timeout=decision.reschedule_timeout,
+ )
+ return (
+ RescheduleTask(reschedule_date=reschedule_date,
end_date=ti.end_date),
+ TaskInstanceState.UP_FOR_RESCHEDULE,
+ )
+ log.info("Reschedule limit reached, applying the standard retry
logic", limit=limit)
+ reason = f"Reschedule limit reached ({limit})"
+ if decision.reason:
+ reason = f"{reason}: {decision.reason}"
+ return _finalize_task_failure(ti, log, retry_reason=reason)
return _finalize_task_failure(ti, log)
+def _reschedule_count(ti: RuntimeTaskInstance) -> int:
+ """Return how many times this try has been rescheduled so far."""
+ from_server = ti._ti_context_from_server
+ return (from_server.task_reschedule_count or 0) if from_server else 0
+
+
+def _reschedule_limit_reached(
+ ti: RuntimeTaskInstance, decision: RetryDecision, context: Context | None
+) -> str | None:
+ """
+ Say which bound stops a RESCHEDULE decision, or return None when the task
may reschedule.
+
+ Both bounds count every reschedule of this try, whatever caused it (a
sensor's pokes and a
+ startup reschedule included), so a retry starts them afresh.
+ """
+ if decision.max_reschedules is not None and _reschedule_count(ti) >=
decision.max_reschedules:
+ return f"max_reschedules={decision.max_reschedules}"
+ if decision.reschedule_timeout is not None and context is not None:
Review Comment:
Made it required. Every caller already passed one, so
`_handle_current_task_failed` and `_reschedule_limit_reached` now take
`context: Context` and the timeout check always runs (f85ee40f0c).
##########
airflow-core/docs/core-concepts/tasks.rst:
##########
@@ -254,14 +256,83 @@ Composition with existing parameters
* - Parameter
- Behaviour when ``retry_policy`` is set
* - ``retries``
- - Still the maximum retry count. Policy can fail earlier but not exceed
it.
+ - Still the maximum retry count. Policy can fail earlier but not exceed
it. A
+ reschedule does not use a retry.
* - ``retry_delay`` / ``retry_exponential_backoff`` / ``max_retry_delay``
- Used when the policy returns DEFAULT or when
``RetryDecision.retry_delay`` is None.
+ A reschedule without its own delay waits ``retry_delay``, without
backoff.
* - ``on_retry_callback``
- - Fires on all retries, including policy-driven retries.
+ - Fires on all retries, including policy-driven retries. Does not fire on
a reschedule.
Review Comment:
Yes, intended. It's the same state a sensor's reschedule uses, so it counts
as waiting rather than failing. The docs now list `on_retry_callback`,
`on_failure_callback`, the `on_task_instance_failed` listener hook,
`email_on_retry` and the `ti_failures`/`operator_failures` metrics, and say
they run as usual once a bound is reached.
`test_retry_policy_reschedule_runs_no_failure_callbacks_or_metrics` pins it.
##########
airflow-core/docs/core-concepts/tasks.rst:
##########
@@ -254,14 +256,83 @@ Composition with existing parameters
* - Parameter
- Behaviour when ``retry_policy`` is set
* - ``retries``
- - Still the maximum retry count. Policy can fail earlier but not exceed
it.
+ - Still the maximum retry count. Policy can fail earlier but not exceed
it. A
+ reschedule does not use a retry.
* - ``retry_delay`` / ``retry_exponential_backoff`` / ``max_retry_delay``
- Used when the policy returns DEFAULT or when
``RetryDecision.retry_delay`` is None.
+ A reschedule without its own delay waits ``retry_delay``, without
backoff.
* - ``on_retry_callback``
- - Fires on all retries, including policy-driven retries.
+ - Fires on all retries, including policy-driven retries. Does not fire on
a reschedule.
* - ``AirflowFailException``
- Always takes precedence. The policy is never consulted for this
exception.
+.. _concepts:retry-policies-reschedule:
+
+Rescheduling without using a retry
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. versionadded:: 3.5.0
+
+Some failures mean "not yet" rather than "this went wrong": the system the
task drives is
+out of capacity, such as a Kubernetes namespace whose resource quota is full
or a warehouse
+with no free slots. Retrying spends the task's ``retries`` on waiting, so a
real failure
+later has none left, and ``on_retry_callback`` fires every time.
+
+**RESCHEDULE** puts the task in ``up_for_reschedule`` instead, the same state
a sensor in
+``mode="reschedule"`` uses between pokes. The try number and ``retries`` are
untouched and
+``on_retry_callback`` does not fire. It works with ``retries=0``.
+
+.. exampleinclude:: /../src/airflow/example_dags/example_retry_policy.py
+ :language: python
+ :start-after: [START retry_policy_reschedule]
+ :end-before: [END retry_policy_reschedule]
+
+A RESCHEDULE always has a bound, so a rule matching a broad exception cannot
loop forever:
+
+* ``max_reschedules`` -- how many times the try may be rescheduled.
+* ``reschedule_timeout`` -- how long after the first reschedule the try may
keep being
+ rescheduled.
+
+Set either or both; a RESCHEDULE without one raises ``ValueError`` when the
rule or decision
+is created. Both count every reschedule of this try, including a sensor's
pokes, so a retry
Review Comment:
Added. A `RetryRule` fails at parse time; in a custom policy it's at failure
time, logged, and the task falls back to the standard retry logic like any
policy that raises.
##########
airflow-core/docs/core-concepts/tasks.rst:
##########
@@ -254,14 +256,83 @@ Composition with existing parameters
* - Parameter
- Behaviour when ``retry_policy`` is set
* - ``retries``
- - Still the maximum retry count. Policy can fail earlier but not exceed
it.
+ - Still the maximum retry count. Policy can fail earlier but not exceed
it. A
Review Comment:
Reworded all four.
##########
task-sdk/tests/task_sdk/execution_time/test_task_runner.py:
##########
@@ -1703,6 +1703,170 @@ def execute(self, context):
)
+class _CapacityError(Exception):
+ """Stands in for a cluster rejecting a pod because its resource quota is
full."""
+
+
+class _NoCapacityTask(BaseOperator):
+ def execute(self, context):
+ raise _CapacityError("exceeded quota")
+
+
+def _reschedule_policy(retry_delay: timedelta | None = timedelta(minutes=10),
**bounds):
+ return ExceptionRetryPolicy(
+ rules=[
+ RetryRule(
+ exception=_CapacityError,
+ action=RetryAction.RESCHEDULE,
+ retry_delay=retry_delay,
+ reason="cluster full",
+ **bounds,
+ )
+ ]
+ )
+
+
[email protected](
+ ("bounds", "task_reschedule_count", "first_rescheduled_ago"),
+ [
+ pytest.param({"max_reschedules": 3}, 0, timedelta(0),
id="first-failure"),
+ pytest.param({"max_reschedules": 3}, 2, timedelta(minutes=20),
id="below-max-reschedules"),
+ pytest.param(
+ {"reschedule_timeout": timedelta(hours=1)}, 2,
timedelta(minutes=20), id="within-timeout"
+ ),
+ ],
+)
+def test_retry_policy_reschedule_does_not_use_a_retry(
+ time_machine,
+ create_runtime_ti,
+ mock_supervisor_comms,
+ bounds,
+ task_reschedule_count,
+ first_rescheduled_ago,
+):
+ """RESCHEDULE within its bounds reschedules the try, even with no retries
configured."""
+ now = datetime(2026, 10, 14, 9, 0, tzinfo=UTC)
+ time_machine.move_to(now, tick=False)
+ task = _NoCapacityTask(task_id="no_capacity", retries=0,
retry_policy=_reschedule_policy(**bounds))
+ ti = create_runtime_ti(task=task,
task_reschedule_count=task_reschedule_count)
+ ti._ti_context_from_server.first_task_reschedule_start_date = now -
first_rescheduled_ago
+
+ state, msg, error = run(ti, ti.get_template_context(),
structlog.get_logger())
+
+ assert state == TaskInstanceState.UP_FOR_RESCHEDULE
+ assert msg == RescheduleTask(reschedule_date=now + timedelta(minutes=10),
end_date=now)
+
+
+def test_retry_policy_reschedule_without_delay_uses_the_task_retry_delay(
+ time_machine, create_runtime_ti, mock_supervisor_comms
+):
+ now = datetime(2026, 10, 14, 9, 0, tzinfo=UTC)
+ time_machine.move_to(now, tick=False)
+ task = _NoCapacityTask(
+ task_id="no_capacity",
+ retry_delay=timedelta(minutes=7),
+ retry_policy=_reschedule_policy(retry_delay=None, max_reschedules=3),
+ )
+ ti = create_runtime_ti(task=task)
+
+ state, msg, error = run(ti, ti.get_template_context(),
structlog.get_logger())
+
+ assert state == TaskInstanceState.UP_FOR_RESCHEDULE
+ assert msg.reschedule_date == now + timedelta(minutes=7)
+
+
[email protected](
+ ("bounds", "task_reschedule_count", "first_rescheduled_ago", "limit"),
+ [
+ pytest.param(
+ {"max_reschedules": 3}, 3, timedelta(minutes=30),
"max_reschedules=3", id="max-reschedules"
+ ),
+ pytest.param(
+ {"reschedule_timeout": timedelta(hours=1)},
+ 5,
+ timedelta(hours=2),
+ "reschedule_timeout=1:00:00",
+ id="reschedule-timeout",
+ ),
+ ],
+)
[email protected](
+ ("retries", "expected_state", "expected_msg"),
+ [
+ pytest.param(
+ 1,
+ TaskInstanceState.UP_FOR_RETRY,
+ lambda now, reason: RetryTask(end_date=now, retry_reason=reason),
+ id="retry-left",
+ ),
+ pytest.param(
+ 0,
+ TaskInstanceState.FAILED,
+ lambda now, reason: TaskState(state=TaskInstanceState.FAILED,
end_date=now, retry_reason=reason),
+ id="no-retry-left",
+ ),
+ ],
+)
+def
test_retry_policy_reschedule_past_its_bound_applies_the_standard_retry_logic(
+ time_machine,
+ create_runtime_ti,
+ mock_supervisor_comms,
+ bounds,
+ task_reschedule_count,
+ first_rescheduled_ago,
+ limit,
+ retries,
+ expected_state,
+ expected_msg,
+):
+ """Past its bound the task retries on its own retry_delay, not the
reschedule delay, or fails."""
+ now = datetime(2026, 10, 14, 9, 0, tzinfo=UTC)
+ time_machine.move_to(now, tick=False)
+ task = _NoCapacityTask(task_id="no_capacity", retries=retries,
retry_policy=_reschedule_policy(**bounds))
+ ti = create_runtime_ti(task=task,
task_reschedule_count=task_reschedule_count)
+ ti._ti_context_from_server.first_task_reschedule_start_date = now -
first_rescheduled_ago
+
+ state, msg, error = run(ti, ti.get_template_context(),
structlog.get_logger())
+
+ assert state == expected_state
+ assert msg == expected_msg(now, f"Reschedule limit reached ({limit}):
cluster full")
+
+
+class _SensorStyleReschedule(BaseOperator):
+ def execute(self, context):
+ raise AirflowRescheduleException(reschedule_date=datetime(2026, 10,
14, 10, 0, tzinfo=UTC))
+
+
[email protected](
+ ("task", "should_fail_closed"),
+ [
+ pytest.param(
+ _NoCapacityTask(task_id="no_capacity",
retry_policy=_reschedule_policy(max_reschedules=3)),
+ True,
+ id="policy-reschedule-after-an-exception",
+ ),
+ pytest.param(_SensorStyleReschedule(task_id="poke"), False,
id="task-raised-reschedule"),
+ ],
+)
+def test_run_fails_closed_when_a_policy_reschedule_cannot_be_reported(
Review Comment:
`context=None` is gone now that it's required. The finalize test is
`test_retry_policy_reschedule_runs_no_failure_callbacks_or_metrics`: it runs
`run()` and `finalize()` and checks neither callback fires and no failure
metric is counted.
##########
providers/common/compat/src/airflow/providers/common/compat/_retry_policy.py:
##########
@@ -123,7 +124,7 @@ def evaluate(
reason = f"{name}: {decision.reason or decision.action.value}"
if trail:
reason = f"{reason} (after {'; '.join(trail)})"
- return RetryDecision(action=decision.action,
retry_delay=decision.retry_delay, reason=reason)
+ return replace(decision, reason=reason)
Review Comment:
Added `test_reschedule_keeps_its_bounds` to the backport tests, skipped
where `RESCHEDULE` doesn't exist.
--
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]