Skip to content

Commit fddf4a7

Browse files
authored
Reschedule tasks on worker startup Dag load failures instead of exiting (#59604) (#60926)
1 parent 4795da4 commit fddf4a7

7 files changed

Lines changed: 205 additions & 34 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
@@ -1603,6 +1603,33 @@ workers:
16031603
type: float
16041604
example: ~
16051605
default: "60.0"
1606+
missing_dag_retires:
1607+
description: |
1608+
Maximum number of times a task will be rescheduled if the worker fails to
1609+
load the Dag or task definition during startup.
1610+
1611+
This situation can occur due to transient infrastructure issues such as
1612+
missing Dag files, temporary filesystem or network problems, or bundle
1613+
synchronization delays. Rescheduling in this case does not count as a
1614+
task retry.
1615+
1616+
Set this value to 0 to disable rescheduling and fail the task immediately
1617+
on startup failures.
1618+
version_added: 3.1.7
1619+
type: integer
1620+
example: ~
1621+
default: "3"
1622+
missing_dag_retry_delay:
1623+
description: |
1624+
Delay in seconds before a task is rescheduled after a worker startup
1625+
failure caused by an inability to load the Dag or task definition.
1626+
1627+
This delay is applied when the task runner requests the scheduler to
1628+
reschedule the task instance in UP_FOR_RESCHEDULE state.
1629+
version_added: 3.1.7
1630+
type: integer
1631+
example: ~
1632+
default: "60"
16061633
api_auth:
16071634
description: Settings relating to authentication on the Airflow APIs
16081635
options:

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
from airflow.stats import Stats
8888
from airflow.ti_deps.dep_context import DepContext
8989
from airflow.ti_deps.dependencies_deps import REQUEUEABLE_DEPS, RUNNING_DEPS
90+
from airflow.ti_deps.deps.ready_to_reschedule import ReadyToRescheduleDep
9091
from airflow.utils.helpers import prune_dict
9192
from airflow.utils.log.logging_mixin import LoggingMixin
9293
from airflow.utils.net import get_hostname
@@ -898,6 +899,17 @@ def are_dependencies_met(
898899
:param verbose: whether log details on failed dependencies on info or debug log level
899900
"""
900901
dep_context = dep_context or DepContext()
902+
if self.state == TaskInstanceState.UP_FOR_RESCHEDULE:
903+
# This DepContext is used when a task instance is in UP_FOR_RESCHEDULE state.
904+
#
905+
# Tasks can be put into UP_FOR_RESCHEDULE by the task runner itself (e.g. when
906+
# the worker cannot load the Dag or task). In this case, the scheduler must respect
907+
# the task instance's reschedule_date before scheduling it again.
908+
#
909+
# ReadyToRescheduleDep is the only dependency that enforces this time-based gating.
910+
# We therefore extend the normal scheduling dependency set with it, instead of
911+
# modifying the global scheduler dependencies.
912+
dep_context.deps.add(ReadyToRescheduleDep())
901913
failed = False
902914
verbose_aware_logger = self.log.info if verbose else self.log.debug
903915
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: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +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
21-
from airflow.executors.executor_loader import ExecutorLoader
2223
from airflow.models.taskreschedule import TaskReschedule
2324
from airflow.ti_deps.deps.base_ti_dep import BaseTIDep
2425
from airflow.utils.session import provide_session
2526
from airflow.utils.state import TaskInstanceState
2627

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+
2737

2838
class ReadyToRescheduleDep(BaseTIDep):
2939
"""Determines whether a task is ready to be rescheduled."""
@@ -34,27 +44,22 @@ class ReadyToRescheduleDep(BaseTIDep):
3444
RESCHEDULEABLE_STATES = {TaskInstanceState.UP_FOR_RESCHEDULE, None}
3545

3646
@provide_session
37-
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]:
3853
"""
3954
Determine whether a task is ready to be rescheduled.
4055
41-
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
4257
handled by this dependency class, otherwise this dependency is considered as passed.
4358
This dependency fails if the latest reschedule request's reschedule date is still
4459
in the future.
4560
"""
4661
from airflow.models.mappedoperator import MappedOperator
4762

48-
is_mapped = isinstance(ti.task, MappedOperator)
49-
executor, _ = ExecutorLoader.import_default_executor_cls()
50-
if (
51-
# Mapped sensors don't have the reschedule property (it can only be calculated after unmapping),
52-
# so we don't check them here. They are handled below by checking TaskReschedule instead.
53-
not is_mapped and not getattr(ti.task, "reschedule", False)
54-
):
55-
yield self._passing_status(reason="Task is not in reschedule mode.")
56-
return
57-
5863
if dep_context.ignore_in_reschedule_period:
5964
yield self._passing_status(
6065
reason="The context specified that being in a reschedule period was permitted."
@@ -75,14 +80,13 @@ def _get_dep_statuses(self, ti, session, dep_context):
7580
if not next_reschedule_date:
7681
# Because mapped sensors don't have the reschedule property, here's the last resort
7782
# and we need a slightly different passing reason
78-
if is_mapped:
83+
if isinstance(ti.task, MappedOperator):
7984
yield self._passing_status(reason="The task is mapped and not in reschedule mode")
8085
return
8186
yield self._passing_status(reason="There is no reschedule request for this task instance.")
8287
return
8388

84-
now = timezone.utcnow()
85-
if now >= next_reschedule_date:
89+
if (now := timezone.utcnow()) >= next_reschedule_date:
8690
yield self._passing_status(reason="Task instance id ready for reschedule.")
8791
return
8892

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

Lines changed: 11 additions & 10 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)
@@ -142,11 +148,6 @@ def test_mapped_task_should_pass_if_ignore_in_reschedule_period_is_set(self, not
142148
dep_context = DepContext(ignore_in_reschedule_period=True)
143149
assert ReadyToRescheduleDep().is_met(ti=ti, dep_context=dep_context)
144150

145-
def test_mapped_task_should_pass_if_not_reschedule_mode(self, not_expected_tr_db_call):
146-
ti = self._get_task_instance(State.UP_FOR_RESCHEDULE, map_index=42)
147-
del ti.task.reschedule
148-
assert ReadyToRescheduleDep().is_met(ti=ti)
149-
150151
def test_mapped_task_should_pass_if_not_in_none_state(self, not_expected_tr_db_call):
151152
ti = self._get_task_instance(State.UP_FOR_RETRY, map_index=42)
152153
assert ReadyToRescheduleDep().is_met(ti=ti)

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

Lines changed: 48 additions & 5 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
@@ -41,7 +41,11 @@
4141
from airflow.configuration import conf
4242
from airflow.dag_processing.bundles.base import BaseDagBundle, BundleVersionLock
4343
from airflow.dag_processing.bundles.manager import DagBundlesManager
44-
from airflow.exceptions import AirflowInactiveAssetInInletOrOutletException, AirflowTaskTimeout
44+
from airflow.exceptions import (
45+
AirflowInactiveAssetInInletOrOutletException,
46+
AirflowRescheduleException,
47+
AirflowTaskTimeout,
48+
)
4549
from airflow.listeners.listener import get_listener_manager
4650
from airflow.sdk.api.client import get_hostname, getuser
4751
from airflow.sdk.api.datamodels._generated import (
@@ -604,6 +608,33 @@ def _xcom_push_to_db(ti: RuntimeTaskInstance, key: str, value: Any) -> None:
604608
)
605609

606610

611+
def _maybe_reschedule_startup_failure(
612+
*,
613+
ti_context: TIRunContext,
614+
log: Logger,
615+
) -> None:
616+
"""
617+
Attempt to reschedule the task when a startup failure occurs.
618+
619+
This does not count as a retry. If the reschedule limit is exceeded, this function
620+
returns and the caller should fail the task.
621+
"""
622+
missing_dag_retires = conf.getint("workers", "missing_dag_retires", fallback=3)
623+
missing_dag_retry_delay = conf.getint("workers", "missing_dag_retry_delay", fallback=60)
624+
625+
reschedule_count = int(getattr(ti_context, "task_reschedule_count", 0) or 0)
626+
if missing_dag_retires > 0 and reschedule_count < missing_dag_retires:
627+
raise AirflowRescheduleException(
628+
reschedule_date=datetime.now(tz=timezone.utc) + timedelta(seconds=missing_dag_retry_delay)
629+
)
630+
631+
log.error(
632+
"Startup reschedule limit exceeded",
633+
reschedule_count=reschedule_count,
634+
max_reschedules=missing_dag_retires,
635+
)
636+
637+
607638
def parse(what: StartupDetails, log: Logger) -> RuntimeTaskInstance:
608639
# TODO: Task-SDK:
609640
# Using DagBag here is about 98% wrong, but it'll do for now
@@ -638,6 +669,7 @@ def parse(what: StartupDetails, log: Logger) -> RuntimeTaskInstance:
638669
log.error(
639670
"Dag not found during start up", dag_id=what.ti.dag_id, bundle=bundle_info, path=what.dag_rel_path
640671
)
672+
_maybe_reschedule_startup_failure(ti_context=what.ti_context, log=log)
641673
sys.exit(1)
642674

643675
# install_loader()
@@ -652,6 +684,7 @@ def parse(what: StartupDetails, log: Logger) -> RuntimeTaskInstance:
652684
bundle=bundle_info,
653685
path=what.dag_rel_path,
654686
)
687+
_maybe_reschedule_startup_failure(ti_context=what.ti_context, log=log)
655688
sys.exit(1)
656689

657690
if not isinstance(task, (BaseOperator, MappedOperator)):
@@ -1547,7 +1580,17 @@ def main():
15471580
SUPERVISOR_COMMS = CommsDecoder[ToTask, ToSupervisor](log=log)
15481581

15491582
try:
1550-
ti, context, log = startup()
1583+
try:
1584+
ti, context, log = startup()
1585+
except AirflowRescheduleException as reschedule:
1586+
log.warning("Rescheduling task during startup, marking task as UP_FOR_RESCHEDULE")
1587+
SUPERVISOR_COMMS.send(
1588+
msg=RescheduleTask(
1589+
reschedule_date=reschedule.reschedule_date,
1590+
end_date=datetime.now(tz=timezone.utc),
1591+
)
1592+
)
1593+
sys.exit(0)
15511594
with BundleVersionLock(
15521595
bundle_name=ti.bundle_instance.name,
15531596
bundle_version=ti.bundle_instance.version,
@@ -1557,10 +1600,10 @@ def main():
15571600
finalize(ti, state, context, log, error)
15581601
except KeyboardInterrupt:
15591602
log.exception("Ctrl-c hit")
1560-
exit(2)
1603+
sys.exit(2)
15611604
except Exception:
15621605
log.exception("Top level error")
1563-
exit(1)
1606+
sys.exit(1)
15641607
finally:
15651608
# Ensure the request socket is closed on the child side in all circumstances
15661609
# before the process fully terminates.

0 commit comments

Comments
 (0)