Skip to content

Commit f1f80a6

Browse files
authored
Add control-plane reporting for tracked runs (flyte run --tracked) (#1356)
## Overview Tracked runs: the SDK keeps orchestrating everything locally while reporting run state — actions, attempts, inputs/outputs, HTML reports — to the control plane, so local runs appear live in the console. Builds on the IDL in flyteorg/flyte#7737 and the `TrackedRunService` rename (released as flyteidl2 2.0.40, which this PR pins). ### How it works - **RemoteRunReporter** — a third `RunRecorder` sink alongside the TUI tracker and SQLite store. The recorder observer contract is synchronous and fires from async controller code *and* the threads that run sync tasks, so the sink is a sync enqueue facade over async I/O: `record_*` calls capture a fully-computed event under a lock (per-attempt monotonic versions; attempts start at 1 per the server contract) and a dedicated background event loop batches `TrackedRunService.ReportActions` calls and performs the signed-URL uploads (the same background-loop shape as `flyte.syncify`). Terminal events trigger a bounded flush barrier at run end. Reporting is best-effort and never fails or hangs the run (unless strict mode is on). - **Data path routes like everything else**: artifacts upload via `DataProxyService.CreateUploadLocation` signed PUT URLs with the `tracked-runs/<run>/<action>[/<attempt>]` filename_root scheme, routed by `ClusterService.SelectCluster(OPERATION_TRACKED_RUN_DATA)` — an org with a directly-reachable data plane cluster uploads straight to that cluster's store; an org without one is served by the control plane's own store. The reporter stamps the routing cluster onto reported attempt events so reads later route to the same store. Inputs upload before CreateRun (deterministic path, no URI on the event); outputs/report URIs attach to the terminal event after their uploads complete. Reports also flush live mid-run so the report tab updates while the run executes. - **Opt-in surface** (one naming family, anchored on the flag): - `flyte run --local --tracked [--tracked-strict]` - config: `local.tracked` / `local.tracked_strict` (section mirrors `local.persistence`) - `flyte.init(local_tracked=..., local_tracked_strict=...)`; `flyte create config --local-tracked` - runcontext: `flyte.with_runcontext(mode="local", tracked=True)` - Requires project/domain; degrades gracefully with one warning when no client is initialized. `--tracked-strict` fails the run loudly on the first reporting failure (for debugging reporting itself). SIGINT/SIGTERM abort-flushes in-flight actions as ABORTED. `Run.url` points at the console tracked-runs page. Falsy outputs (`0`, `""`, `[]`, `False`) report correctly (presence, not truthiness). File/Dir raw data never uploads — only the three metadata artifacts (inputs.pb / outputs.pb / report.html) reach the dataproxy. The deck feature (`@env.task(report=True)`, `flyte.report`) and the `@trace` decorator are unrelated and untouched. ## Test Plan - 688 unit tests green on the released flyteidl2 pin (reporter lifecycle incl. retries/ordering/flush barrier/failure isolation/strict mode, upload helper, client protocol, CLI flags, config plumbing). - Live E2E against three environments running a backend that implements `TrackedRunService`: - a single-process development stack: full lifecycle, watches, replay idempotency. - a hosted control plane with a directly-reachable data plane cluster: runs + inputs/outputs + reports render in the console; artifacts land in the cluster's metadata store, with the routing cluster recorded on attempts. - a hosted control plane where artifacts are served by the control plane's own store — the fallback leg of the routed data path, verified end to end on the released flyteidl2 pin. ## Rollout Plan The feature is opt-in per run/config and requires a control plane that implements `TrackedRunService`; nothing changes for users who don't pass `--tracked`. ## Rollback Plan Revert; the feature is client-side and opt-in. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Haytham Abuelfutuh <haytham@afutuh.com>
1 parent 768f76e commit f1f80a6

20 files changed

Lines changed: 3399 additions & 79 deletions

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,6 @@ mypy_path = ["src"]
214214

215215
[tool.uv]
216216
exclude-newer = "5 days"
217-
218217
# Opt flyteorg packages out of the global recency cutoff so freshly published
219218
# flyteidl2/flyte_controller_base releases can be picked up immediately.
220219
[tool.uv.exclude-newer-package]

src/flyte/_initialize.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ class CommonInit:
4343
source_config_path: Optional[Path] = None # Only used for documentation
4444
sync_local_sys_paths: bool = True
4545
local_persistence: bool = False
46+
local_tracked: bool = False
47+
local_tracked_strict: bool = False
4648

4749

4850
@dataclass(init=True, kw_only=True, repr=True, eq=True, frozen=True)
@@ -233,6 +235,8 @@ async def init(
233235
sync_local_sys_paths: bool = True,
234236
load_plugin_type_transformers: bool = True,
235237
local_persistence: bool = False,
238+
local_tracked: bool = False,
239+
local_tracked_strict: bool = False,
236240
) -> None:
237241
"""
238242
Initialize the Flyte system with the given configuration. This method should be called before any other Flyte
@@ -280,6 +284,11 @@ async def init(
280284
load_plugin_type_transformers: If enabled (default True), load the type transformer plugins registered under
281285
the "flyte.plugins.types" entry point group.
282286
local_persistence: Whether to enable SQLite persistence for local run metadata (default: False).
287+
local_tracked: Whether to report tracked run state to the Flyte control plane
288+
(default: False). Requires an initialized client and a configured project/domain.
289+
local_tracked_strict: Strict tracked-run reporting for debugging (default: False). Any
290+
reporting failure fails the run loudly instead of being logged and swallowed. Only takes
291+
effect when reporting is enabled.
283292
disable_keyring: Disable storage of tokens in local keyring.
284293
285294
Returns:
@@ -341,6 +350,8 @@ async def init(
341350
source_config_path=source_config_path,
342351
sync_local_sys_paths=sync_local_sys_paths,
343352
local_persistence=local_persistence,
353+
local_tracked=local_tracked,
354+
local_tracked_strict=local_tracked_strict,
344355
)
345356

346357
logger.info(f"Flyte initialized with config: {_init_config}")
@@ -435,6 +446,8 @@ async def init_from_config(
435446
source_config_path=cfg_path,
436447
sync_local_sys_paths=sync_local_sys_paths,
437448
local_persistence=cfg.local.persistence,
449+
local_tracked=cfg.local.tracked,
450+
local_tracked_strict=cfg.local.tracked_strict,
438451
# disable_keyring is threaded outside _platform_to_client_kwargs
439452
# because the helper output is also spread into
440453
# create_remote_controller from init_in_cluster, and the
@@ -781,6 +794,22 @@ def is_persistence_enabled() -> bool:
781794
return cfg.local_persistence
782795

783796

797+
def is_local_tracked_enabled() -> bool:
798+
"""Check if reporting tracked run state to the control plane is enabled."""
799+
cfg = _get_init_config()
800+
if cfg is None:
801+
return False
802+
return cfg.local_tracked
803+
804+
805+
def is_local_tracked_strict() -> bool:
806+
"""Check if strict tracked-run reporting (fail the run on any reporting failure) is enabled."""
807+
cfg = _get_init_config()
808+
if cfg is None:
809+
return False
810+
return cfg.local_tracked_strict
811+
812+
784813
def initialize_in_cluster() -> None:
785814
"""
786815
Initialize the system for in-cluster execution. This is a placeholder function and does not perform any actions.

src/flyte/_internal/controllers/_local_controller.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,8 @@ async def submit(self, _task: TaskTemplate, *args, **kwargs) -> Any:
221221
short_name=_task.short_name if _task.short_name != _task.name else None,
222222
parent_id=parent_id,
223223
inputs=native_inputs,
224+
proto_inputs=inputs.proto_inputs,
225+
task=_task,
224226
output_path=sub_action_output_path,
225227
has_report=_task.report,
226228
cache_enabled=cache_enabled,
@@ -374,6 +376,8 @@ async def get_action_outputs(
374376
task_name=func_name,
375377
parent_id=task_action.name,
376378
inputs=native_inputs,
379+
proto_inputs=converted_inputs.proto_inputs,
380+
trace_interface=_interface,
377381
output_path=action_output_path,
378382
)
379383

@@ -406,7 +410,8 @@ async def record_trace(self, info: TraceInfo):
406410
self._recorder.record_failure(action_id=info.action.name, error=str(info.error))
407411
else:
408412
converted_outputs = None
409-
if info.interface.outputs and info.output:
413+
# Presence, not truthiness: falsy results (0, "", [], False) are real outputs.
414+
if info.interface.outputs and info.output is not None:
410415
_ctx = ctx.new_in_driver_literal_conversion(True) if ctx.is_task_context() else nullcontext()
411416
with _ctx:
412417
converted_outputs = await convert.convert_from_native_to_outputs(

src/flyte/_persistence/_recorder.py

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44

55

66
class RunRecorder:
7-
"""Unified proxy that delegates recording events to the TUI tracker and/or
8-
the SQLite persistence layer (RunStore).
7+
"""Unified proxy that delegates recording events to the TUI tracker, the SQLite
8+
persistence layer (RunStore) and/or the control-plane reporter
9+
(`RemoteRunReporter`).
910
1011
The controller only talks to this single object — no more interleaved
1112
`if tracker` / `if persist` conditionals.
@@ -19,20 +20,25 @@ def __init__(
1920
tracker: Any | None = None,
2021
persist: bool = False,
2122
run_name: str | None = None,
23+
reporter: Any | None = None,
2224
) -> None:
2325
self._tracker = tracker
2426
self._persist = persist and run_name is not None
2527
self._run_name: str = run_name or ""
28+
self._reporter = reporter
2629

2730
@property
2831
def is_active(self) -> bool:
2932
"""True if at least one recording backend is enabled."""
30-
return self._tracker is not None or self._persist
33+
return self._tracker is not None or self._persist or self._reporter is not None
3134

3235
def get_action(self, action_id: str) -> Any:
33-
"""Delegate to the tracker, or return None when tracker is absent."""
36+
"""Delegate to the tracker (or the reporter when no tracker is attached), or
37+
return None when neither backend knows the action."""
3438
if self._tracker is not None:
3539
return self._tracker.get_action(action_id)
40+
if self._reporter is not None:
41+
return self._reporter.get_action(action_id)
3642
return None
3743

3844
# ------------------------------------------------------------------
@@ -74,6 +80,9 @@ def record_start(
7480
parent_id: str | None = None,
7581
short_name: str | None = None,
7682
inputs: dict | None = None,
83+
proto_inputs: Any = None,
84+
task: Any = None,
85+
trace_interface: Any = None,
7786
output_path: str | None = None,
7887
has_report: bool = False,
7988
cache_enabled: bool = False,
@@ -121,6 +130,26 @@ def record_start(
121130
log_links=log_links,
122131
)
123132

133+
if self._reporter is not None:
134+
# The reporter needs the raw proto inputs (when available) so it can
135+
# offload the action's inputs.pb, and the task template / trace interface
136+
# so the reported spec carries a typed interface (the console gates I/O
137+
# rendering on it).
138+
self._reporter.record_start(
139+
action_id=action_id,
140+
task_name=task_name,
141+
parent_id=parent_id,
142+
proto_inputs=proto_inputs,
143+
task=task,
144+
trace_interface=trace_interface,
145+
output_path=output_path,
146+
has_report=bool(has_report),
147+
group=group,
148+
cache_enabled=cache_enabled,
149+
cache_hit=cache_hit,
150+
disable_run_cache=disable_run_cache,
151+
)
152+
124153
def record_complete(self, *, action_id: str, outputs: Any = None) -> None:
125154
# Convert outputs to a display representation once, so both backends
126155
# receive the same pre-formatted data.
@@ -140,6 +169,10 @@ def record_complete(self, *, action_id: str, outputs: Any = None) -> None:
140169
outputs=repr(display) if display is not None else None,
141170
)
142171

172+
if self._reporter is not None:
173+
# The reporter needs the raw outputs (proto wrapper), not the display form.
174+
self._reporter.record_complete(action_id=action_id, outputs=outputs)
175+
143176
@staticmethod
144177
def _to_display(outputs: Any) -> Any:
145178
"""Convert raw outputs to a display-friendly representation.
@@ -173,6 +206,9 @@ def record_failure(self, *, action_id: str, error: str) -> None:
173206
error=error,
174207
)
175208

209+
if self._reporter is not None:
210+
self._reporter.record_failure(action_id=action_id, error=error)
211+
176212
def record_attempt_start(self, *, action_id: str, attempt_num: int) -> None:
177213
if self._tracker is not None and hasattr(self._tracker, "record_attempt_start"):
178214
self._tracker.record_attempt_start(action_id=action_id, attempt_num=attempt_num)
@@ -186,6 +222,9 @@ def record_attempt_start(self, *, action_id: str, attempt_num: int) -> None:
186222
attempt_num=attempt_num,
187223
)
188224

225+
if self._reporter is not None:
226+
self._reporter.record_attempt_start(action_id=action_id, attempt_num=attempt_num)
227+
189228
def record_attempt_complete(self, *, action_id: str, attempt_num: int, outputs: Any = None) -> None:
190229
display: Any = None
191230
if outputs is not None:
@@ -208,6 +247,9 @@ def record_attempt_complete(self, *, action_id: str, attempt_num: int, outputs:
208247
outputs=repr(display) if display is not None else None,
209248
)
210249

250+
if self._reporter is not None:
251+
self._reporter.record_attempt_complete(action_id=action_id, attempt_num=attempt_num, outputs=outputs)
252+
211253
def record_attempt_failure(self, *, action_id: str, attempt_num: int, error: str) -> None:
212254
if self._tracker is not None and hasattr(self._tracker, "record_attempt_failure"):
213255
self._tracker.record_attempt_failure(
@@ -226,8 +268,11 @@ def record_attempt_failure(self, *, action_id: str, attempt_num: int, error: str
226268
error=error,
227269
)
228270

271+
if self._reporter is not None:
272+
self._reporter.record_attempt_failure(action_id=action_id, attempt_num=attempt_num, error=error)
273+
229274
# ------------------------------------------------------------------
230-
# Root "a0" action (called by _run.py — persistence only)
275+
# Root "a0" action (called by _run.py — persistence + reporter only)
231276
# ------------------------------------------------------------------
232277

233278
def record_root_start(self, *, task_name: str) -> None:
@@ -241,6 +286,9 @@ def record_root_start(self, *, task_name: str) -> None:
241286
parent_id=None,
242287
)
243288

289+
if self._reporter is not None:
290+
self._reporter.record_root_start(task_name=task_name)
291+
244292
def record_root_complete(self) -> None:
245293
if self._persist:
246294
from flyte._persistence._run_store import RunStore
@@ -250,6 +298,9 @@ def record_root_complete(self) -> None:
250298
action_name="a0",
251299
)
252300

301+
if self._reporter is not None:
302+
self._reporter.record_root_complete()
303+
253304
def record_root_failure(self, *, error: str) -> None:
254305
if self._persist:
255306
from flyte._persistence._run_store import RunStore
@@ -260,6 +311,9 @@ def record_root_failure(self, *, error: str) -> None:
260311
error=error,
261312
)
262313

314+
if self._reporter is not None:
315+
self._reporter.record_root_failure(error=error)
316+
263317
# ------------------------------------------------------------------
264318
# Static helpers
265319
# ------------------------------------------------------------------

0 commit comments

Comments
 (0)