Repository navigation
[GSoC] Add failure-investigation accessors to the core client (stacked on #3348) - #3349
Aryan95614 wants to merge 4 commits into
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #3349 +/- ##
=========================================
Coverage ? 28.94%
=========================================
Files ? 382
Lines ? 52948
Branches ? 9362
=========================================
Hits ? 15327
Misses ? 36469
Partials ? 1152 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Shriprasad-P
left a comment
There was a problem hiding this comment.
Review
PR: [GSoC] Add failure-investigation accessors to the core client (stacked on #3348)
Touched: metaflow/client/core.py, metaflow/metadata_provider/metadata.py, metaflow/metaflow_config.py, metaflow/plugins/metadata_providers/service.py, test/unit/test_client_failure_investigation.py
- Scoped to the stated goal; please confirm CI is green.
- Size: +1249/-46.
Drive-by review after reading the diff.
Replaces the separate metaflow/agent module with core-client accessors, per
the review that record iteration and normal client operations belong in core:
- Flow.failed_runs(*, since=None, max_runs=None): iterate failed runs newest
first via runs(filters={"status:eq": "failed"}); raises on local/older
services like any other server-filtered listing.
- Run.failed_task: the first unsuccessful task in the run (newest step first).
- Task.failure_summary -> FailureSummary: normalized exception type/message/
stacktrace/attempt, or None when the task has no exception. Read errors
propagate rather than being swallowed into diagnostic strings.
Drops the injectable resolver/fetcher and the TaskRef indirection (uses real
Task objects). Adds test/unit/test_client_failure_investigation.py.
7ea2f8c to
8cb149e
Compare
|
| """ | ||
| for step in self: | ||
| for task in step: | ||
| if not task.successful: |
There was a problem hiding this comment.
this expression will be true for tasks that are running, pending, or have not launched because it simply pulls from the task["_success"].data
There was a problem hiding this comment.
alternative could be
if task.finished and not task.successful:There was a problem hiding this comment.
Agreed on the problem, but finished on its own does not work here. Task.finished reads _task_ok, and task.py sets that to False when the step raises, so finished and not successful would match only failures a decorator handled (for example @catch) and never the task that actually crashed. I checked on a local run with one raising step and one @catch step: the raising task reads finished=False, successful=False with exception set; the @catch task reads finished=True, successful=False with exception None.
The latest push rules out successful tasks first, then returns the task if task.exception is not None or task.finished. That covers the crashed task and the handled one, and skips tasks that have not written their artifacts yet (running, pending, never launched), which read as unsuccessful, unfinished and without an exception. Tests cover all four cases.
…t FailureSummary - failed_runs passed filters= but runs() only takes _filters=, so every call raised TypeError; the plain Mock used for runs in the tests hid it. Tests now autospec Flow so runs enforces its real signature, and one test goes through the real runs() down to the provider call. - failed_runs takes tags positionally, like runs, and forwards them. - failed_task returned any task whose _success is not True, which is also the case for tasks still running or never started. It now requires the task to have finished without success: an exception recorded, or finished and not successful (a decorator-handled failure). finished on its own does not work because _task_ok is False for a task that raised. - FailureSummary is re-exported from metaflow.client and metaflow, next to Flow, Run and Task.
| # has recorded its exception; a task whose failure a decorator | ||
| # handled (e.g. @catch) is finished but not successful. A task | ||
| # that has not written its artifacts yet is neither. | ||
| if task.exception is not None or task.finished: |
There was a problem hiding this comment.
If a task is terminated before its final artifacts are persisted—for example by OOM, SIGKILL, or preemption—it has no _exception or _task_ok artifact. This condition treats that genuinely failed task as unfinished and skips it, so failed_task can return None or an unrelated earlier failure.
saikonen
left a comment
There was a problem hiding this comment.
Apologies that the review turnaround has stretched for this. Hopefully you have a moment still to address the review comments. Otherwise let me know and I'll push some changes on top to get this past the finish line.
One generic note as well is that some of the user-facing docstrings have unnecessary implementation details. These could be streamlined a bit
| exception_type=normalized["type"], | ||
| message=normalized["message"], | ||
| stacktrace=normalized["stacktrace"], | ||
| attempt=self.current_attempt, |
There was a problem hiding this comment.
self.current_attempt points to the latest started attempt, so there can be a real mismatch here for example with using @retry
| return False | ||
|
|
||
| @property | ||
| def failed_task(self) -> Optional[Task]: |
There was a problem hiding this comment.
the returned failed task might not be the correct one. This can return a @catch handled task instead of the fatal one depending on timings
| """ | ||
| for step in self: | ||
| for task in step: | ||
| if task.successful: |
There was a problem hiding this comment.
task.successful along with task.exception and task.finished checks result in a lot of calls to the metadata- and datastore. Can we implement this in a more cost-effective manner?
…rt the exception's own attempt - failed_task reads one metadata_dict per task and returns a task only when its latest started attempt is done and recorded attempt_ok=False, so @catch-handled failures and tasks still running a retry are skipped; no per-task artifact loads. - failure_summary takes the attempt from the _exception artifact it reads, not the latest started attempt, so it stays correct under @Retry. - Trim the user-facing docstrings.
|
Thanks @saikonen, and no worries on the timing. Pushed fixes for all three in 1596f08:
Tests cover the retry-in-progress, |
| if ( | ||
| metadata.get("attempt_ok") == "False" | ||
| and metadata.get("attempt-done") is not None | ||
| and metadata.get("attempt") == metadata.get("attempt-done") | ||
| ): |
There was a problem hiding this comment.
When a decorator such as @catch handles a task failure, it persists attempt_ok as "True". This condition only selects tasks where attempt_ok == "False", so failed_task skips the handled failure and can return None even though the accessor's stated contract says these tasks count as failed. The revised test also explicitly expects this failure to be skipped.
GSoC 2026 project: metadata service request improvements (mentor: @saikonen). Upstream twin of the fork review PR (Aryan95614#2), opened per mentor request for a static PR number.
Rebased onto
masterafter #3348 merged (2026-09-15); the diff is only this PR's own commits.Summary
Folds bounded failure investigation into the regular Metaflow client as three
accessors on the existing objects, instead of a separate
metaflow/agentmodule. Built on the core pagination/filtering from #3348.
Context / Motivation
Review on the earlier revision steered the run-listing and pagination pieces
into the core client (that's #3348). Following the same direction, the failure
investigation layer now lives on the client objects rather than a parallel
metaflow.agentpackage, so there's a single, discoverable API.Changes Made
Flow.failed_runs(*tags, since=None, max_runs=None) -> Iterator[Run]: failedruns newest-first, via
runs(*tags, _filters={"status:eq": "failed"});tagsare forwarded exactly as for
runs, andsinceadds an inclusivets_epoch:gebound.Run.failed_task -> Optional[Task]: the latest task that finished withoutsuccess (newest step first), or None. A task counts as failed when it raised
(
exceptionis set) or a decorator such as@catchhandled the failure;tasks that are still running or never started are skipped.
Task.failure_summary -> Optional[FailureSummary]: normalized{exception_type, message, stacktrace, attempt}, or None when the task hasno exception. Read errors propagate rather than being swallowed.
FailureSummaryis exported frommetaflow.clientandmetaflow, next toFlow,RunandTask.metaflow/agentmodule (failure_finder,pagination,run_lister,TaskRef/FailureInfo, injectable resolver/fetcher).Behavior note:
failed_runsrelies on server-side filtering, so it raisesagainst the local metadata provider or a service without pagination/filtering
support: the same gate as
runs(_filters=...).Testing
test/unit/test_client_failure_investigation.py: 19 tests coveringfailed_runsfilter/tags/bounds forwarding (including one through the realFlow.runsdown to the provider call),failed_taskiteration order and thecrashed / decorator-handled / still-running cases,
failure_summaryconstruction + error propagation,
_normalize_exceptionacrossmapping/object/string inputs, and the package exports.
test_client_run_listing.py): 63 passed.@catchstep:failed_taskreturns the raising task andfailure_summarycarries its type and message.black(repo-pinned 25.12.0) clean.Trade-offs / Design Decisions
failed_taskcannot usefinishedalone:Task.finishedreads_task_ok,which the task runner sets to False when the step raises, so
finished and not successfulwould match only decorator-handled failuresand never the task that crashed. It checks
exception is not None or finishedafter ruling out successful tasks.
task_resolver/artifact_fetcherand theTaskRefindirection: unnecessary once this is core; real
Taskobjects are used andthe methods are testable with mocks.
FailureSummaryas a small frozen dataclass incore.pyrather thanreturning a bare dict, so the normalized fields are typed and discoverable.