feat: Resolve N+1 query storm in Client API by pushing status and lim… - #3000
feat: Resolve N+1 query storm in Client API by pushing status and lim…#3000AdMub wants to merge 1 commit into
Conversation
…it filters to metadata service
Greptile SummaryThis PR attempts to resolve an O(N) query storm in Key issues found:
Confidence Score: 1/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant Client as Flow.latest_successful_run
participant Meta as MetadataProvider.get_object
participant Svc as ServiceMetadataProvider._get_object_internal
participant BE as Metaflow Service Backend
participant Filter as _apply_filter
Note over Client,Filter: Intended O(1) fast-path
Client->>Meta: get_object("flow","run",{"status":"completed"}, None, ..., limit=1)
Meta->>Svc: _get_object_internal(..., **{"limit":1})
Svc->>Svc: Build URL + "?_limit=1&status=completed"
Svc->>BE: GET /runs?_limit=1&status=completed
BE-->>Svc: [run_object]
Svc->>Filter: _apply_filter([run_object], {"status":"completed"})
Note over Filter: "status" key not handled → returns []
Filter-->>Svc: []
Svc-->>Meta: []
Meta-->>Client: []
Note over Client: runs=[] → if runs: False → falls through
Note over Client,Filter: Actual execution (legacy O(N) loop)
Client->>Client: for run in self (iterates all runs)
Client->>Client: if run.successful → return run
|
| except Exception: | ||
| # Fallback to the O(N) iteration if the backend doesn't | ||
| # support the status query parameter (e.g. LocalMetadataProvider) | ||
| pass |
There was a problem hiding this comment.
Overly broad except Exception silently hides real errors
The bare except Exception: pass catches everything — network errors, authentication failures, malformed responses, permission errors, and more. When the ServiceMetadataProvider is in use but encounters a transient network failure, this block will silently swallow the error and fall back to the O(N) loop (which will then hit the same network failure and raise normally). This makes the optimised path harder to debug and can mask regressions.
The fallback is only needed for TypeError (raised when LocalMetadataProvider._get_object_internal receives an unexpected limit kwarg) and possibly ServiceException (for old service versions). Using a narrower exception type makes the intent clear and prevents masking real failures:
except (TypeError, NotImplementedError):
# Fallback: provider does not accept limit/status kwargs
passAlternatively, the base MetadataProvider._get_object_internal and LocalMetadataProvider._get_object_internal should be updated to accept **kwargs (and simply ignore them), eliminating the need for this fallback entirely.
| runs = self._metaflow.metadata.get_object( | ||
| self._NAME, | ||
| "run", | ||
| {"status": "completed"}, |
There was a problem hiding this comment.
status=completed may not be semantically equivalent to run.successful
run.successful (line 2246–2259 in core.py) is determined client-side by checking whether the run's end step succeeded — it is a derived property, not a raw backend field. The backend status field may use "completed" to mean "the run finished running" (i.e. it is no longer in-progress), regardless of outcome. A run that finished with task failures might be stored as status=completed while run.successful returns False.
If the backend's status=completed includes failed-but-finished runs, the O(1) path can return the most recent finished run — successful or not — which breaks the semantic contract of latest_successful_run.
Verify that the backend's status=completed value strictly corresponds to a run that passed all steps (i.e. it is truly equivalent to run.successful == True). If the service uses a separate status value (e.g. "successful") for fully-succeeded runs, that value should be used here instead.
|
|
||
| try: | ||
| v, _ = cls._request(None, url, "GET") | ||
| return MetadataProvider._apply_filter(v, filters) |
There was a problem hiding this comment.
O(1) optimisation is a no-op — _apply_filter zeroes out results for unknown filter keys
_apply_filter (in metadata.py) only handles the keys any_tags, tags, and system_tags. For any key it does not recognise, no elements are appended to result during that loop iteration, and then starting_point = result assigns the empty list. So calling MetadataProvider._apply_filter(v, {"status": "completed"}) always returns an empty list regardless of what v contains.
This means the optimised path always returns [], if runs: in core.py is always False, the method silently falls through to the legacy O(N) loop every time, and the entire performance improvement is bypassed in practice.
To fix this, server-pushed filter keys should be excluded from the filters dict before it is handed to _apply_filter, so the client-side pass only re-evaluates keys that _apply_filter actually understands (any_tags, tags, system_tags).
|
Thanks for the analysis — this is an interesting optimization but it's a significant change to the metadata provider interface. Please include this as part of your GSoC proposal rather than as a standalone PR. |
PR Type
Summary
Resolves the$O(N)$ query storm in the Client API ($O(N)$ to exactly $O(1)$ .
latest_successful_run) by updatingServiceMetadataProviderto accept and pushlimitandstatusquery parameters directly to the backend, reducing iteration HTTP requests fromIssue
Fixes #2942
Reproduction
Runtime: local / service
Commands to run:
Where evidence shows up: parent console
Root Cause
The Client API ($O(N)$ client-side filtering loop.
Flow.latest_successful_runand other iteration methods) previously fetched all run objects for a flow into memory without limits. It then iterated over them to checkrun.successful, which recursively triggered further unbounded HTTP GET requests for steps, tasks, and the_successartifacts. TheMetadataProviderinterface lacked the ability to passlimitorstatusconstraints down to the backend, forcing anWhy This Fix Is Correct
It updates the base$O(1)$ request. It remains minimal by keeping the URL construction explicit and maintains a
MetadataProvider.get_objectcontract to accept**kwargs, modifiesServiceMetadataProvider._get_object_internalto safely inject supported filters (_limit,_offset,status) into the URL query string, and refactorslatest_successful_runto execute antry/exceptfallback incore.pyto preserve exact legacy behavior forLocalMetadataProvideror older service versions.Failure Modes Considered
limit=1to a provider that doesn't support it (likeLocalMetadataProvider) could throw an exception. Fixed by wrapping the optimizedtry/exceptblock incore.pythat silently falls back to the legacymetaflow-servicebackends. Fixed inservice.pyby introducing an explicitserver_supportedallowlist (["tags", "any_tags", "status"]). Any other filters gracefully fall back to_apply_filterin Python memory.Tests
Non-Goals
We intentionally did not rewrite all other Client API iteration methods (like
Flow.runs()) to use the new parameters yet. This PR strictly scopes the infrastructure change to the provider layer and implements it on the heaviest offender (latest_successful_run) to establish the architectural pattern.AI Tool Usage
Used AI to assist in profiling the specific HTTP call chains within
core.py, mapping the time-complexity of the N+1 bottleneck, and drafting the boilerplate for thetry/exceptfallback logic in the client API. All generated logic was manually reviewed, stepped through, and tested locally against theLocalMetadataProviderfallback.