77import sys
88import uuid
99from 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
1212from flyte ._context import Context , contextual_run , internal_ctx
1313from 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+
142161def _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
0 commit comments