Skip to content

Commit 4955aa4

Browse files
kumare3claudeSVilgelm
authored
feat(cli): add --recover (rerun) and --recover-from (run) (#1237)
Adds the recovery surface to the CLI and un-gates it end to end (ENG26-830). ```bash # Recover from the run being rerun (fetched code, reuse its succeeded actions): flyte rerun ul56wcvgqrb9vzhzz5l2 --recover # Recover a fresh run with NEW local code from a prior run: flyte run main.py main --recover-from ul56wcvgqrb9vzhzz5l2 # Force named actions to re-execute even though they succeeded in the source run: flyte rerun ul56wcvgqrb9vzhzz5l2 --recover --force-rerun-action a3 --force-rerun-action a7 ``` Both map to `with_runcontext(recover=...)`: `--recover` (bool) recovers from the run being rerun; `--recover-from <run>` (string) recovers a fresh `run()` from a named prior run. Recovery reuses the prior run's succeeded actions and re-runs only what failed or changed (remote-only; `--local` is rejected). `--force-rerun-action` (repeatable, maps to `with_runcontext(recover_force_rerun_actions=[...])` → `RunSpec.recover.force_rerun_actions`) forces named actions to re-execute anyway; a listed parent re-enqueues its children (list them too to force the whole subtree), unknown names are ignored. Since the original draft: - Merged main (#1236 landed there and evolved: `related_to` provenance, `run_base_dir`, image-cache seeding). - Reworked to the wire contract that actually merged in flyteorg/flyte#7615: a recovery run is `RunSpec.relation = {related_to: <run>, relation_type: RELATION_TYPE_RECOVER}`; `RunSpec.recover` carries only optional extras (`force_rerun_actions`) and stays unset. There is no `Recover.run_id`. - Bumped flyteidl2 to 2.0.28 (pyproject, rs_controller pyproject + Cargo, uv.lock), which opens the runtime gate — the `NotImplementedError` path only triggers on older flyteidl2 builds. - Treat `ACTION_PHASE_RECOVERED` (10) as terminal/success everywhere phases are classified: python controller (`Action.is_terminal`, covering both watch snapshot and live updates), rust controller (`is_action_terminal` + crate bump — 2.0.27 dropped/panicked on unknown phase 10), `Run.wait()`/`watch` done-checks, `flyte.models.ActionPhase`, and TUI display. Without this, recovered children never resolve and the run hangs re-opening `WatchForUpdates` (reproduced on devbox 2026-07-16). On resolution the child's `output_uri` points at the source run's outputs and is consumed as-is. - Skew-tolerant phase handling: bindings that predate `ACTION_PHASE_RECOVERED` (e.g. a stale task-image venv) no longer kill the run — the wire value (10) is used as a `getattr` fallback, and phase-name rendering goes through a helper that never raises on unknown enum values. Recovery never reads the global cache — `overwrite_cache` / `cache_lookup_scope` are orthogonal. Needs the actions-service recovery logic (ENG26-828) for end-to-end behavior. --------- Signed-off-by: Ketan Umare <kumare3@users.noreply.github.com> Signed-off-by: Sergey Vilgelm <sergey@union.ai> Co-authored-by: Ketan Umare <kumare3@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Sergey Vilgelm <sergey@union.ai>
1 parent 7b8eadd commit 4955aa4

18 files changed

Lines changed: 617 additions & 63 deletions

File tree

rs_controller/src/action.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,9 @@ impl Action {
144144
| ActionPhase::Failed
145145
| ActionPhase::Aborted
146146
| ActionPhase::TimedOut
147+
// Recovered from a prior run: terminal, success-equivalent; output_uri
148+
// points at the source run's outputs (consume as-is).
149+
| ActionPhase::Recovered
147150
)
148151
} else {
149152
false

src/flyte/_internal/controllers/remote/_action.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@
1818

1919
ActionType = Literal["task", "trace", "condition"]
2020

21+
# ACTION_PHASE_RECOVERED landed in flyteidl2 2.0.28; the bindings in a task image may predate
22+
# it. The wire value is stable, so fall back to it — never crash on an enum value the local
23+
# bindings don't know.
24+
_ACTION_PHASE_RECOVERED: int = getattr(phase_pb2, "ACTION_PHASE_RECOVERED", 10)
25+
2126

2227
# This class should be deleted following move to pyo3.
2328
@dataclass
@@ -64,6 +69,9 @@ def is_terminal(self) -> bool:
6469
phase_pb2.ACTION_PHASE_SUCCEEDED,
6570
phase_pb2.ACTION_PHASE_ABORTED,
6671
phase_pb2.ACTION_PHASE_TIMED_OUT,
72+
# Recovered from a prior run: terminal and success-equivalent; output_uri points at
73+
# the source run's outputs (intentional — consume as-is).
74+
_ACTION_PHASE_RECOVERED,
6775
]
6876

6977
def increment_retries(self):

src/flyte/_internal/controllers/remote/_informer.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from flyteidl2.workflow import state_service_pb2
1212

1313
from flyte._logging import log, logger
14+
from flyte._utils.helpers import action_phase_name
1415

1516
from ._action import Action
1617
from ._service_protocol import ActionsService, StateService
@@ -41,13 +42,11 @@ async def observe_state(self, state: state_service_pb2.ActionUpdate) -> Action:
4142
"""
4243
Add an action to the cache if it doesn't exist. This is invoked by the watch.
4344
"""
44-
logger.debug(f"Observing phase {phase_pb2.ActionPhase.Name(state.phase)} for {state.action_id.name}")
45+
logger.debug(f"Observing phase {action_phase_name(state.phase)} for {state.action_id.name}")
4546
if state.output_uri:
4647
logger.debug(f"Output URI: {state.output_uri}")
4748
else:
48-
logger.warning(
49-
f"{state.action_id.name} has no output URI, in phase {phase_pb2.ActionPhase.Name(state.phase)}"
50-
)
49+
logger.warning(f"{state.action_id.name} has no output URI, in phase {action_phase_name(state.phase)}")
5150
if state.phase == phase_pb2.ACTION_PHASE_FAILED:
5251
logger.error(
5352
f"Action {state.action_id.name} failed with error (msg):"

src/flyte/_run.py

Lines changed: 149 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import sys
88
import uuid
99
from datetime import datetime, timezone
10-
from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union, cast
10+
from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Sequence, Tuple, Union, cast
1111

1212
from flyte._context import Context, contextual_run, internal_ctx
1313
from flyte._environment import Environment
@@ -139,6 +139,25 @@ def _ambient_image_cache() -> ImageCache | None:
139139
return tctx.compiled_image_cache if tctx else None
140140

141141

142+
def _uri_inputs_hash(inputs_uri: str) -> str:
143+
"""Deterministic stand-in for ``OffloadedInputData.inputs_hash`` when the inputs blob
144+
cannot be read client-side (rerun of a source run whose outputs were cleaned up).
145+
146+
The server computes the real hash as FNV-64a over the marshaled inputs and requires the
147+
field to be non-empty (it feeds cache-key computation). Hashing the source inputs URI in
148+
the same format is conservative: identical source location -> identical inputs -> same
149+
key, while an accidental match with a content-derived hash is a ~2^-64 event — the same
150+
collision odds the content hash itself carries. Worst case is a lost cache hit, never a
151+
wrong one beyond those odds.
152+
"""
153+
import base64
154+
155+
h = 0xCBF29CE484222325
156+
for b in inputs_uri.encode("utf-8"):
157+
h = ((h ^ b) * 0x100000001B3) & 0xFFFFFFFFFFFFFFFF
158+
return base64.urlsafe_b64encode(h.to_bytes(8, "big")).decode().rstrip("=")
159+
160+
142161
def _to_cache_lookup_scope(scope: CacheLookupScope | None = None):
143162
"""Map the SDK cache-lookup-scope literal onto its RunSpec enum value."""
144163
from flyteidl2.task import run_pb2
@@ -188,6 +207,8 @@ def __init__(
188207
preserve_original_types: bool | None = None,
189208
debug: bool = False,
190209
recover: bool | str | None = False,
210+
recover_force_rerun_actions: Sequence[str] | None = None,
211+
allow_missing_source_outputs: bool = False,
191212
_tracker: Any = None,
192213
_bundle_relative_paths: tuple[str, ...] | None = None,
193214
_bundle_from_dir: pathlib.Path | None = None,
@@ -241,6 +262,14 @@ def __init__(
241262
# Carried on RunSpec.relation with RELATION_TYPE_RECOVER; remote-only; gated in
242263
# _apply_overrides until the flyteidl2 field + backend ship. See _resolve_recover_ref.
243264
self._recover = recover
265+
# Escape hatch: actions that must re-execute in the recovery run even if they succeeded
266+
# in the source run (RunSpec.recover.force_rerun_actions). Only valid with recover.
267+
self._recover_force_rerun_actions = tuple(recover_force_rerun_actions or ())
268+
if self._recover_force_rerun_actions and not self._recover:
269+
raise ValueError("recover_force_rerun_actions requires recover to be set")
270+
# Opt-in for rerun/recover of a source run whose outputs were cleaned up from storage:
271+
# proceed with its inputs URI instead of failing. See the fallback in rerun().
272+
self._allow_missing_source_outputs = allow_missing_source_outputs
244273

245274
def _resolve_recover_ref(self, rerun_run_name: str | None) -> str | None:
246275
"""Resolve `self._recover` to the reference run name to recover from (or None).
@@ -527,7 +556,7 @@ def _apply_overrides(self, base: Any, *, task: Any = None, relation: Tuple[Any,
527556
run_spec.CopyFrom(base)
528557
# Provenance is per-run, never inherited: a rerun of a rerun must point at its immediate
529558
# parent (set below from `relation`), not the grandparent captured in the prior spec.
530-
for provenance_field in ("relation", "related_to"):
559+
for provenance_field in ("relation", "related_to", "recover"):
531560
# DESCRIPTOR internals are opaque to checkers; guard for fields absent from the current pin.
532561
if provenance_field in cast(Any, run_pb2.RunSpec).DESCRIPTOR.fields_by_name:
533562
run_spec.ClearField(provenance_field)
@@ -588,17 +617,35 @@ def _apply_overrides(self, base: Any, *, task: Any = None, relation: Tuple[Any,
588617
cast(Any, run_spec).relation.CopyFrom(
589618
_relation_pb.Relation(related_to=ref, relation_type=relation_type)
590619
)
620+
if kind == "recover" and self._recover_force_rerun_actions:
621+
# Escape hatch: these actions re-execute even though they succeeded in the
622+
# source run. A listed parent re-enqueues its children (list them too to force
623+
# the whole subtree); unknown names are ignored server-side.
624+
cast(Any, run_spec).recover.CopyFrom(
625+
cast(Any, run_pb2).Recover(force_rerun_actions=list(self._recover_force_rerun_actions))
626+
)
591627

592628
return run_spec
593629

594630
async def _submit_remote(
595-
self, *, task_spec: Any, task_id: Any, proto_inputs: Any, run_spec: Any, run_id: Any, project_id: Any
631+
self,
632+
*,
633+
task_spec: Any,
634+
task_id: Any,
635+
proto_inputs: Any,
636+
run_spec: Any,
637+
run_id: Any,
638+
project_id: Any,
639+
offloaded_input_data: Any = None,
596640
) -> Run:
597641
"""Upload inputs and create the run. The single network call site for remote submission.
598642
599643
Consumes an already-built ``run_spec`` (see ``_apply_overrides``), raw proto ``inputs``
600644
(``flyteidl2.task.Inputs``), and a task by reference (``task_id``) or by value
601-
(``task_spec``); shared by ``_run_remote`` and ``rerun``.
645+
(``task_spec``); shared by ``_run_remote`` and ``rerun``. ``offloaded_input_data``
646+
(``flyteidl2.common.OffloadedInputData``) references already-offloaded inputs (e.g. the
647+
source run's inputs.pb on a rerun whose inputs can't be re-downloaded) and skips the
648+
upload; exactly one of ``proto_inputs`` / ``offloaded_input_data`` is used.
602649
"""
603650
from connectrpc.code import Code
604651
from connectrpc.errors import ConnectError
@@ -609,28 +656,30 @@ async def _submit_remote(
609656
from flyte.remote import Run
610657

611658
try:
612-
upload_req = dataproxy_service_pb2.UploadInputsRequest(inputs=proto_inputs)
613-
# Pass the explicit run_base_dir so the offloaded inputs are written under the
614-
# same base the CreateRun below resolves (RunSpec.run_base_dir, set in _apply_overrides).
615-
# When unset the server falls back to settings/cluster default in both paths.
616-
if self._run_base_dir:
617-
upload_req.base_dir = self._run_base_dir
618-
# Reference an already-registered task by id; otherwise upload the full spec.
619-
if task_id is not None:
620-
upload_req.task_id.CopyFrom(task_id)
621-
else:
622-
upload_req.task_spec.CopyFrom(task_spec)
623-
if run_id is not None:
624-
upload_req.run_id.CopyFrom(run_id)
625-
else:
626-
upload_req.project_id.CopyFrom(project_id)
659+
if offloaded_input_data is None:
660+
upload_req = dataproxy_service_pb2.UploadInputsRequest(inputs=proto_inputs)
661+
# Pass the explicit run_base_dir so the offloaded inputs are written under the
662+
# same base the CreateRun below resolves (RunSpec.run_base_dir, set in _apply_overrides).
663+
# When unset the server falls back to settings/cluster default in both paths.
664+
if self._run_base_dir:
665+
upload_req.base_dir = self._run_base_dir
666+
# Reference an already-registered task by id; otherwise upload the full spec.
667+
if task_id is not None:
668+
upload_req.task_id.CopyFrom(task_id)
669+
else:
670+
upload_req.task_spec.CopyFrom(task_spec)
671+
if run_id is not None:
672+
upload_req.run_id.CopyFrom(run_id)
673+
else:
674+
upload_req.project_id.CopyFrom(project_id)
627675

628-
upload_resp = await get_client().dataproxy_service.upload_inputs(upload_req)
676+
upload_resp = await get_client().dataproxy_service.upload_inputs(upload_req)
677+
offloaded_input_data = upload_resp.offloaded_input_data
629678

630679
create_req = run_service_pb2.CreateRunRequest(
631680
run_id=run_id,
632681
project_id=project_id,
633-
offloaded_input_data=upload_resp.offloaded_input_data,
682+
offloaded_input_data=offloaded_input_data,
634683
run_spec=run_spec,
635684
)
636685
# Reference an already-registered task by id; otherwise send the full spec.
@@ -1139,6 +1188,8 @@ async def rerun(
11391188
version = task_spec.task_template.id.version
11401189

11411190
# Inputs: reuse the prior raw proto inputs, or convert new native kwargs against the interface.
1191+
proto_inputs = None
1192+
offloaded_input_data = None
11421193
if inputs:
11431194
if task_template is not None:
11441195
iface = task_template.native_interface
@@ -1149,10 +1200,67 @@ async def rerun(
11491200
converted = await convert_from_native_to_inputs(iface, custom_context=self._custom_context, **inputs)
11501201
proto_inputs = converted.proto_inputs
11511202
else:
1152-
resp = await get_client().dataproxy_service.get_action_data(
1153-
request=dataproxy_service_pb2.GetActionDataRequest(action_id=action_details.pb2.id)
1154-
)
1155-
proto_inputs = resp.inputs
1203+
# Rerun/recover only need the source run's INPUTS. GetActionData resolves inputs AND
1204+
# outputs server-side concurrently and 404s wholesale when either blob has been
1205+
# cleaned up (retention) — and which half the error names is a race. The client has
1206+
# no RPC to check the inputs blob alone, so a missing-data 404 is a hard error by
1207+
# default; `allow_missing_source_outputs` opts into proceeding with the inputs URI
1208+
# (fails at runtime if the inputs turn out to be gone too).
1209+
from connectrpc.code import Code
1210+
from connectrpc.errors import ConnectError
1211+
from flyteidl2.common import run_pb2 as common_run_pb2
1212+
from flyteidl2.workflow import run_service_pb2
1213+
1214+
import flyte.errors
1215+
1216+
try:
1217+
resp = await get_client().dataproxy_service.get_action_data(
1218+
request=dataproxy_service_pb2.GetActionDataRequest(action_id=action_details.pb2.id)
1219+
)
1220+
proto_inputs = resp.inputs
1221+
except ConnectError as e:
1222+
if e.code != Code.NOT_FOUND:
1223+
raise
1224+
if "inputs" in str(e.message):
1225+
# The inputs blob itself is gone — nothing to feed the new run; fail
1226+
# fast with a clear story instead of the server's raw 404.
1227+
raise flyte.errors.RuntimeUserError(
1228+
"SourceRunInputsUnavailableError",
1229+
f"Source run {run_name}'s inputs are no longer in storage (deleted by "
1230+
f"retention/cleanup), so it cannot be rerun or recovered with its "
1231+
f"original inputs. Pass new inputs explicitly instead: "
1232+
f"flyte.with_runcontext(...).rerun('{run_name}', inputs={{...}}), or "
1233+
f"launch fresh local code with `flyte run ... --recover-from {run_name}` "
1234+
f"(inputs come from the CLI parameters).",
1235+
) from e
1236+
if not self._allow_missing_source_outputs:
1237+
raise flyte.errors.RuntimeUserError(
1238+
"SourceRunOutputsUnavailableError",
1239+
f"Source run {run_name}'s outputs are no longer in storage. Rerun/recover "
1240+
f"only needs its inputs, but whether those still exist cannot be verified "
1241+
f"from the client. If you know the inputs are intact, retry with "
1242+
f"--allow-missing-outputs "
1243+
f"(with_runcontext(allow_missing_source_outputs=True)); if they were "
1244+
f"deleted too, the new run would fail at runtime — pass new inputs "
1245+
f"explicitly instead (rerun('{run_name}', inputs={{...}}) or "
1246+
f"`flyte run ... --recover-from {run_name}`).",
1247+
) from e
1248+
uris = await get_client().run_service.get_action_data_u_r_is(
1249+
run_service_pb2.GetActionDataURIsRequest(action_id=action_details.pb2.id)
1250+
)
1251+
if not uris.inputs_uri:
1252+
raise
1253+
logger.warning(
1254+
f"Source run {run_name} outputs are no longer in storage; proceeding with its "
1255+
f"inputs at {uris.inputs_uri} (--allow-missing-outputs). If the inputs were "
1256+
f"deleted too the new run will fail at runtime, and recovered actions "
1257+
f"referencing deleted outputs will fail if consumed "
1258+
f"(use --force-rerun-action to re-execute them)."
1259+
)
1260+
offloaded_input_data = common_run_pb2.OffloadedInputData(
1261+
uri=uris.inputs_uri,
1262+
inputs_hash=_uri_inputs_hash(uris.inputs_uri),
1263+
)
11561264

11571265
run_id, project_id = self._resolve_run_target(project, domain, cfg.org)
11581266

@@ -1186,6 +1294,7 @@ async def rerun(
11861294
run_spec=run_spec,
11871295
run_id=run_id,
11881296
project_id=project_id,
1297+
offloaded_input_data=offloaded_input_data,
11891298
)
11901299

11911300

@@ -1223,6 +1332,8 @@ def with_runcontext(
12231332
preserve_original_types: bool = False,
12241333
debug: bool = False,
12251334
recover: bool | str | None = False,
1335+
recover_force_rerun_actions: Sequence[str] | None = None,
1336+
allow_missing_source_outputs: bool = False,
12261337
_tracker: Any = None,
12271338
) -> _Runner:
12281339
"""
@@ -1306,8 +1417,17 @@ async def example_task(x: int, y: str) -> str:
13061417
:param recover: Recover (reuse a prior run's succeeded actions, re-running only what failed or
13071418
changed). ``True`` recovers from the run being rerun — only valid with ``.rerun(...)``; a
13081419
run-name string recovers from that named run and is the only form valid on ``.run(...)``.
1309-
Remote-only. Not yet supported by the backend (raises NotImplementedError at submit until
1310-
flyteidl2 RunSpec.relation ships).
1420+
Remote-only. Requires a backend (and flyteidl2 build) with RunSpec.relation recovery
1421+
support; raises NotImplementedError at submit otherwise.
1422+
:param recover_force_rerun_actions: Optional names of actions that must re-execute in the
1423+
recovery run even if they succeeded in the source run (escape hatch). A listed parent
1424+
action re-enqueues its children — list them too to force the whole subtree; a listed
1425+
condition re-pauses for a new signal. Unknown names are ignored. Only valid with
1426+
``recover``.
1427+
:param allow_missing_source_outputs: Opt-in for ``rerun``/recover when the source run's
1428+
outputs were cleaned up from storage: proceed using the source inputs URI instead of
1429+
failing. The client cannot verify the inputs still exist — if they were deleted too,
1430+
the new run fails at runtime.
13111431
:param _tracker: This is an internal only parameter used by the CLI to render the TUI.
13121432
13131433
:return: runner
@@ -1358,6 +1478,8 @@ async def example_task(x: int, y: str) -> str:
13581478
preserve_original_types=preserve_original_types,
13591479
debug=debug,
13601480
recover=recover,
1481+
recover_force_rerun_actions=recover_force_rerun_actions,
1482+
allow_missing_source_outputs=allow_missing_source_outputs,
13611483
_tracker=_tracker,
13621484
)
13631485

src/flyte/_utils/helpers.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,19 @@ def _selector_policy():
8787
yield
8888
finally:
8989
asyncio.set_event_loop_policy(original_policy) # ty: ignore[deprecated] # kept until 3.16 drops the API
90+
91+
92+
def action_phase_name(phase: int) -> str:
93+
"""Human-readable name for an ActionPhase wire value.
94+
95+
Proto3 enums are open: the server may send values these bindings don't know
96+
(e.g. ACTION_PHASE_RECOVERED from a newer flyteidl2), and ``Name()`` raises
97+
ValueError on them. Never crash on display — fall back to the known name for
98+
stable wire values, or a generic one otherwise.
99+
"""
100+
from flyteidl2.common import phase_pb2
101+
102+
try:
103+
return phase_pb2.ActionPhase.Name(phase)
104+
except ValueError:
105+
return "ACTION_PHASE_RECOVERED" if phase == 10 else f"ACTION_PHASE_{phase}"

0 commit comments

Comments
 (0)