Skip to content

Commit cd345ab

Browse files
Lee-Wsuii2210
authored andcommitted
Reschedule tasks on worker startup Dag load failures instead of exiting (apache#59604)
1 parent 2ee2cbf commit cd345ab

7 files changed

Lines changed: 201 additions & 25 deletions

File tree

airflow-core/src/airflow/config_templates/config.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1686,6 +1686,33 @@ workers:
16861686
type: float
16871687
example: ~
16881688
default: "60.0"
1689+
missing_dag_retires:
1690+
description: |
1691+
Maximum number of times a task will be rescheduled if the worker fails to
1692+
load the Dag or task definition during startup.
1693+
1694+
This situation can occur due to transient infrastructure issues such as
1695+
missing Dag files, temporary filesystem or network problems, or bundle
1696+
synchronization delays. Rescheduling in this case does not count as a
1697+
task retry.
1698+
1699+
Set this value to 0 to disable rescheduling and fail the task immediately
1700+
on startup failures.
1701+
version_added: 3.1.7
1702+
type: integer
1703+
example: ~
1704+
default: "3"
1705+
missing_dag_retry_delay:
1706+
description: |
1707+
Delay in seconds before a task is rescheduled after a worker startup
1708+
failure caused by an inability to load the Dag or task definition.
1709+
1710+
This delay is applied when the task runner requests the scheduler to
1711+
reschedule the task instance in UP_FOR_RESCHEDULE state.
1712+
version_added: 3.1.7
1713+
type: integer
1714+
example: ~
1715+
default: "60"
16891716
api_auth:
16901717
description: Settings relating to authentication on the Airflow APIs
16911718
options:

airflow-core/src/airflow/models/taskinstance.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@
8585
from airflow.settings import task_instance_mutation_hook
8686
from airflow.ti_deps.dep_context import DepContext
8787
from airflow.ti_deps.dependencies_deps import REQUEUEABLE_DEPS, RUNNING_DEPS
88+
from airflow.ti_deps.deps.ready_to_reschedule import ReadyToRescheduleDep
8889
from airflow.utils.helpers import prune_dict
8990
from airflow.utils.log.logging_mixin import LoggingMixin
9091
from airflow.utils.net import get_hostname
@@ -888,6 +889,17 @@ def are_dependencies_met(
888889
:param verbose: whether log details on failed dependencies on info or debug log level
889890
"""
890891
dep_context = dep_context or DepContext()
892+
if self.state == TaskInstanceState.UP_FOR_RESCHEDULE:
893+
# This DepContext is used when a task instance is in UP_FOR_RESCHEDULE state.
894+
#
895+
# Tasks can be put into UP_FOR_RESCHEDULE by the task runner itself (e.g. when
896+
# the worker cannot load the Dag or task). In this case, the scheduler must respect
897+
# the task instance's reschedule_date before scheduling it again.
898+
#
899+
# ReadyToRescheduleDep is the only dependency that enforces this time-based gating.
900+
# We therefore extend the normal scheduling dependency set with it, instead of
901+
# modifying the global scheduler dependencies.
902+
dep_context.deps.add(ReadyToRescheduleDep())
891903
failed = False
892904
verbose_aware_logger = self.log.info if verbose else self.log.debug
893905
for dep_status in self.get_failed_dep_statuses(dep_context=dep_context, session=session):

airflow-core/src/airflow/ti_deps/deps/base_ti_dep.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,14 @@
1717
# under the License.
1818
from __future__ import annotations
1919

20-
from collections.abc import Iterator
2120
from typing import TYPE_CHECKING, NamedTuple
2221

2322
from airflow.ti_deps.dep_context import DepContext
2423
from airflow.utils.session import provide_session
2524

2625
if TYPE_CHECKING:
26+
from collections.abc import Iterator
27+
2728
from sqlalchemy.orm import Session
2829

2930
from airflow.models.taskinstance import TaskInstance

airflow-core/src/airflow/ti_deps/deps/ready_to_reschedule.py

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,23 @@
1717
# under the License.
1818
from __future__ import annotations
1919

20+
from typing import TYPE_CHECKING
21+
2022
from airflow._shared.timezones import timezone
2123
from airflow.models.taskreschedule import TaskReschedule
2224
from airflow.ti_deps.deps.base_ti_dep import BaseTIDep
2325
from airflow.utils.session import provide_session
2426
from airflow.utils.state import TaskInstanceState
2527

28+
if TYPE_CHECKING:
29+
from collections.abc import Iterator
30+
31+
from sqlalchemy.orm import Session
32+
33+
from airflow.models.taskinstance import TaskInstance
34+
from airflow.ti_deps.dep_context import DepContext
35+
from airflow.ti_deps.deps.base_ti_dep import TIDepStatus
36+
2637

2738
class ReadyToRescheduleDep(BaseTIDep):
2839
"""Determines whether a task is ready to be rescheduled."""
@@ -33,23 +44,20 @@ class ReadyToRescheduleDep(BaseTIDep):
3344
RESCHEDULEABLE_STATES = {TaskInstanceState.UP_FOR_RESCHEDULE, None}
3445

3546
@provide_session
36-
def _get_dep_statuses(self, ti, session, dep_context):
47+
def _get_dep_statuses(
48+
self,
49+
ti: TaskInstance,
50+
session: Session,
51+
dep_context: DepContext,
52+
) -> Iterator[TIDepStatus]:
3753
"""
3854
Determine whether a task is ready to be rescheduled.
3955
40-
Only tasks in NONE state with at least one row in task_reschedule table are
56+
Only tasks in NONE or UP_FOR_RESCHEDULE state with at least one row in task_reschedule table are
4157
handled by this dependency class, otherwise this dependency is considered as passed.
4258
This dependency fails if the latest reschedule request's reschedule date is still
4359
in the future.
4460
"""
45-
if (
46-
# Mapped sensors don't have the reschedule property (it can only be calculated after unmapping),
47-
# so we don't check them here. They are handled below by checking TaskReschedule instead.
48-
ti.map_index < 0 and not getattr(ti.task, "reschedule", False)
49-
):
50-
yield self._passing_status(reason="Task is not in reschedule mode.")
51-
return
52-
5361
if dep_context.ignore_in_reschedule_period:
5462
yield self._passing_status(
5563
reason="The context specified that being in a reschedule period was permitted."
@@ -76,8 +84,7 @@ def _get_dep_statuses(self, ti, session, dep_context):
7684
yield self._passing_status(reason="There is no reschedule request for this task instance.")
7785
return
7886

79-
now = timezone.utcnow()
80-
if now >= next_reschedule_date:
87+
if (now := timezone.utcnow()) >= next_reschedule_date:
8188
yield self._passing_status(reason="Task instance id ready for reschedule.")
8289
return
8390

airflow-core/tests/unit/ti_deps/deps/test_ready_to_reschedule_dep.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -103,11 +103,6 @@ def test_should_pass_if_ignore_in_reschedule_period_is_set(self, not_expected_tr
103103
dep_context = DepContext(ignore_in_reschedule_period=True)
104104
assert ReadyToRescheduleDep().is_met(ti=ti, dep_context=dep_context)
105105

106-
def test_should_pass_if_not_reschedule_mode(self, not_expected_tr_db_call):
107-
ti = self._get_task_instance(State.UP_FOR_RESCHEDULE)
108-
del ti.task.reschedule
109-
assert ReadyToRescheduleDep().is_met(ti=ti)
110-
111106
def test_should_pass_if_not_in_none_state(self, not_expected_tr_db_call):
112107
ti = self._get_task_instance(State.UP_FOR_RETRY)
113108
assert ReadyToRescheduleDep().is_met(ti=ti)
@@ -126,6 +121,17 @@ def test_should_pass_after_reschedule_date_multiple(self):
126121
self._create_task_reschedule(ti, [-21, -11, -1])
127122
assert ReadyToRescheduleDep().is_met(ti=ti)
128123

124+
def test_should_fail_before_reschedule_date_even_if_task_is_not_reschedule_mode(self):
125+
"""
126+
When a task is in UP_FOR_RESCHEDULE state but the operator itself is not in reschedule mode
127+
(i.e. reschedule was triggered by infrastructure/startup failure), we still must respect the
128+
TaskReschedule.reschedule_date.
129+
"""
130+
ti = self._get_task_instance(State.UP_FOR_RESCHEDULE)
131+
del ti.task.reschedule
132+
self._create_task_reschedule(ti, 1)
133+
assert not ReadyToRescheduleDep().is_met(ti=ti)
134+
129135
def test_should_fail_before_reschedule_date_one(self):
130136
ti = self._get_task_instance(State.UP_FOR_RESCHEDULE)
131137
self._create_task_reschedule(ti, 1)

task-sdk/src/airflow/sdk/execution_time/task_runner.py

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
import time
2828
from collections.abc import Callable, Iterable, Iterator, Mapping
2929
from contextlib import suppress
30-
from datetime import datetime, timezone
30+
from datetime import datetime, timedelta, timezone
3131
from itertools import product
3232
from pathlib import Path
3333
from typing import TYPE_CHECKING, Annotated, Any, Literal
@@ -61,6 +61,7 @@
6161
from airflow.sdk.exceptions import (
6262
AirflowException,
6363
AirflowInactiveAssetInInletOrOutletException,
64+
AirflowRescheduleException,
6465
AirflowRuntimeError,
6566
AirflowTaskTimeout,
6667
ErrorType,
@@ -696,6 +697,33 @@ def _xcom_push_to_db(ti: RuntimeTaskInstance, key: str, value: Any) -> None:
696697
)
697698

698699

700+
def _maybe_reschedule_startup_failure(
701+
*,
702+
ti_context: TIRunContext,
703+
log: Logger,
704+
) -> None:
705+
"""
706+
Attempt to reschedule the task when a startup failure occurs.
707+
708+
This does not count as a retry. If the reschedule limit is exceeded, this function
709+
returns and the caller should fail the task.
710+
"""
711+
missing_dag_retires = conf.getint("workers", "missing_dag_retires", fallback=3)
712+
missing_dag_retry_delay = conf.getint("workers", "missing_dag_retry_delay", fallback=60)
713+
714+
reschedule_count = int(getattr(ti_context, "task_reschedule_count", 0) or 0)
715+
if missing_dag_retires > 0 and reschedule_count < missing_dag_retires:
716+
raise AirflowRescheduleException(
717+
reschedule_date=datetime.now(tz=timezone.utc) + timedelta(seconds=missing_dag_retry_delay)
718+
)
719+
720+
log.error(
721+
"Startup reschedule limit exceeded",
722+
reschedule_count=reschedule_count,
723+
max_reschedules=missing_dag_retires,
724+
)
725+
726+
699727
def parse(what: StartupDetails, log: Logger) -> RuntimeTaskInstance:
700728
# TODO: Task-SDK:
701729
# Using BundleDagBag here is about 98% wrong, but it'll do for now
@@ -726,6 +754,7 @@ def parse(what: StartupDetails, log: Logger) -> RuntimeTaskInstance:
726754
log.error(
727755
"Dag not found during start up", dag_id=what.ti.dag_id, bundle=bundle_info, path=what.dag_rel_path
728756
)
757+
_maybe_reschedule_startup_failure(ti_context=what.ti_context, log=log)
729758
sys.exit(1)
730759

731760
# install_loader()
@@ -740,6 +769,7 @@ def parse(what: StartupDetails, log: Logger) -> RuntimeTaskInstance:
740769
bundle=bundle_info,
741770
path=what.dag_rel_path,
742771
)
772+
_maybe_reschedule_startup_failure(ti_context=what.ti_context, log=log)
743773
sys.exit(1)
744774

745775
if not isinstance(task, (BaseOperator, MappedOperator)):
@@ -1721,7 +1751,17 @@ def main():
17211751
)
17221752

17231753
try:
1724-
ti, context, log = startup()
1754+
try:
1755+
ti, context, log = startup()
1756+
except AirflowRescheduleException as reschedule:
1757+
log.warning("Rescheduling task during startup, marking task as UP_FOR_RESCHEDULE")
1758+
SUPERVISOR_COMMS.send(
1759+
msg=RescheduleTask(
1760+
reschedule_date=reschedule.reschedule_date,
1761+
end_date=datetime.now(tz=timezone.utc),
1762+
)
1763+
)
1764+
sys.exit(0)
17251765
with BundleVersionLock(
17261766
bundle_name=ti.bundle_instance.name,
17271767
bundle_version=ti.bundle_instance.version,
@@ -1731,10 +1771,10 @@ def main():
17311771
finalize(ti, state, context, log, error)
17321772
except KeyboardInterrupt:
17331773
log.exception("Ctrl-c hit")
1734-
exit(2)
1774+
sys.exit(2)
17351775
except Exception:
17361776
log.exception("Top level error")
1737-
exit(1)
1777+
sys.exit(1)
17381778
finally:
17391779
# Ensure the request socket is closed on the child side in all circumstances
17401780
# before the process fully terminates.

0 commit comments

Comments
 (0)