Skip to content

[GSoC] Add paginated, filtered run iteration to the core client - #3348

Open
Aryan95614 wants to merge 6 commits into
Netflix:masterfrom
Aryan95614:feat/client-pagination-support
Open

[GSoC] Add paginated, filtered run iteration to the core client#3348
Aryan95614 wants to merge 6 commits into
Netflix:masterfrom
Aryan95614:feat/client-pagination-support

Conversation

@Aryan95614

@Aryan95614 Aryan95614 commented Aug 23, 2026

Copy link
Copy Markdown

GSoC 2026 project: metadata service request improvements (mentor: @saikonen). Upstream twin of the fork review PR (Aryan95614#1), opened per mentor request for a static PR number.

Summary

Moves cursor pagination and server-side run filtering into the regular Metaflow client instead of maintaining a parallel client stack.

  • adds a provider-level iter_objects() path with a backwards-compatible fallback
  • version-gates the paginated path on the metadata service (>= 2.6.1) with an X-Limit capability-header check, falling back to legacy listing for older services and rejecting server-side filters when the service cannot honor them
  • generalizes pagination to all collection types, not just flow/run listings
  • streams service listings through X-Next-Cursor
  • forwards the metadata service's field:operator filters
  • extends Flow.runs() with explicit keyword-only filters, page_size, and max_runs
  • applies positional tags locally, so Flow.runs("prod") works with local metadata as well as the service
  • sorts listings newest-first by ts_epoch, so max_runs returns the newest runs regardless of provider order
  • guards reserved pagination parameters and repeated cursors
  • preserves the existing no-option Flow.runs(*tags) behavior

Separation

This is the base PR. It contains pagination and filtering only. The agent-oriented failure investigation utility is isolated in stacked PR #2.

This structure follows the review direction that pagination, filtering, and normal record iteration belong in the core client.

Verification

  • 41 focused pagination/provider compatibility tests passed
  • black (repo-pinned 25.12.0) clean on the changed files
  • complete unit suite: no new regressions (pre-existing inheritance/spin integration failures are environmental and unrelated)

Note on the version gate

The paginated/filtered path is gated on metadata-service >= 2.6.1 — the release that ships pagination + filtering together with the artifact latest-attempt fix (metaflow-service#497) — plus an X-Limit capability-header check as defense-in-depth. Services without it fall back to legacy listing, and server-side filters raise a clear error rather than silently returning unfiltered results.

Public surface

Flow.runs() takes *tags, _filters, and max_runs.

_filters is intentionally private: the field:operator grammar belongs to the metadata service and has no meaning for the local provider, so it should not be something callers depend on. page_size is not part of the signature at all — runs() returns an iterator either way, so page size is a transport concern and comes from METAFLOW_SERVICE_PAGE_SIZE. max_runs stays public because, unlike the other two, it is meaningful for every metadata provider.

Future work (not in this PR)

Filters could be expressed in plain python and translated by each provider, so the local metadata provider could support the same calls:

flow.runs(filters={"status": eq("failed"), "ts_epoch": gt(cutoff)})

with each provider owning a filter_funcs_mapping that matches its internals. Keeping _filters private now means that can land later without a breaking change. Credit for the idea goes to @saikonen.

…explicit kwargs

- Gate the cursor-paginated path on metadata-service version (>= 2.5.1) with an
  X-Limit capability-header check; fall back to legacy listing for older
  services and reject server-side filters when the service cannot honor them.
- Generalize pagination to all collection types, not just flow/run listings.
- Sort iter_objects results newest-first by ts_epoch so max_runs returns the
  newest runs regardless of the provider's ordering.
- Replace Flow.runs(**kwargs) with explicit keyword-only filters/page_size/
  max_runs, and apply positional tags locally so flow.runs("prod") works with
  local metadata as well as the service.
- Extract collection listing into private helpers for readability.
- Use mocks instead of fake providers in the run-listing tests; add coverage
  for legacy fallback, capability checks, and newest-first ordering.
Follow-up on the paginated listing path:
- Explain why _get_object_internal materializes pages into a list: get_object's
  object-or-list contract must stay stable, the goal here is to relieve
  server-side pressure, and callers needing to stream large collections go
  through iter_objects()/_iter_paginated_records (e.g. Flow.runs()).
- Document that result ordering is implicitly newest-first (descending ts_epoch)
  because there is no _order query param yet, in both the service iterator and
  the base MetadataProvider.iter_objects sort, so the two paths stay consistent.
…tion

- Bump _MIN_SERVICE_VERSION_WITH_CURSOR_PAGINATION to 2.6.0 -- the release that
  ships pagination + filtering (per maintainer).
- The paginated get_object path returned [] where the legacy path returned None
  for a missing (404) collection. Add a raise_on_missing flag threaded through
  _iter_paginated_records and _legacy_get_collection so get_object keeps
  legacy's atomic contract: a 404 at any point in the listing (first page,
  mid-pagination, or the no-X-Limit legacy fallback) resolves to None, never an
  empty or silently truncated list. Streaming iter_objects is unchanged: a 404
  just ends the stream.
- Lift get_object's obj_type/sub_type validation guards into a shared
  _validate_object_query helper and call it from the paginated listing path,
  so streamed access rejects the same nonsensical combinations as
  materialized access.
- Cover all of the above with tests.
@codecov

codecov Bot commented Aug 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.00000% with 46 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (master@4fce948). Learn more about missing BASE report.

Files with missing lines Patch % Lines
metaflow/plugins/metadata_providers/service.py 85.71% 10 Missing and 12 partials ⚠️
metaflow/metadata_provider/metadata.py 52.77% 10 Missing and 7 partials ⚠️
metaflow/metaflow_config_funcs.py 55.55% 3 Missing and 1 partial ⚠️
metaflow/client/core.py 89.65% 1 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff            @@
##             master    #3348   +/-   ##
=========================================
  Coverage          ?   31.27%           
=========================================
  Files             ?      382           
  Lines             ?    52903           
  Branches          ?     9354           
=========================================
  Hits              ?    16547           
  Misses            ?    35126           
  Partials          ?     1230           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread metaflow/client/core.py Outdated
Comment thread metaflow/client/core.py
Comment thread metaflow/client/core.py Outdated
Comment on lines +2623 to +2624
page_size: Optional[int] = None,
max_runs: Optional[int] = None,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we also do away with the page_size and max_runs here, or are they required? page size is a metaflow config level toggle already so I see no reason to keep it as a call level arg as well. It is also service-specific, as the return here is an iterator anyway so the caller does not concern with page sizes

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dropped page_size from the signature entirely rather than just making it private. You're right that it has no use for the local implementation, and runs() hands back an iterator either way, so the caller never really needed it. It comes from METAFLOW_SERVICE_PAGE_SIZE now, which I've documented, validated, and clamped to the service maximum of 500.

I did keep max_runs public, and I'd rather flag that than slip it past you, since @talsperre +1'd removing both. Sakari's point was that max_runs is different from the other two: it works for every provider including local, and failed_runs(max_runs=...) in #3349 is built on it. If you'd still prefer it gone, I'll take it out.

Comment thread metaflow/client/core.py Outdated
@saikonen
saikonen marked this pull request as ready for review August 25, 2026 23:11
@greptile-apps

greptile-apps Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds provider-level streaming iteration, metadata-service cursor pagination and filtering, and bounded newest-first run listing. It also consolidates service capability checks into a URL-keyed version cache, but that cache remains stale when the endpoint behind an existing URL changes.

  • Adds paginated service collection iteration with cursor and capability-header validation.
  • Extends Flow.runs() with private service filters and provider-independent max_runs.
  • Adds configurable service page sizing and focused compatibility tests.

Confidence Score: 4/5

The PR should not merge until service capability decisions can refresh when the endpoint behind an already-cached URL changes version.

The process-lifetime URL cache can continue rejecting supported filtering after an in-place service upgrade or send unsupported requests after an in-place downgrade.

Files Needing Attention: metaflow/plugins/metadata_providers/service.py

Important Files Changed

Filename Overview
metaflow/client/core.py Adds streaming child construction and bounded run iteration while preserving the no-option legacy path.
metaflow/metadata_provider/metadata.py Introduces the provider iterator fallback, shared query validation, and newest-first materialized ordering.
metaflow/metaflow_config.py Adds validated configuration for metadata-service pagination page size.
metaflow/metaflow_config_funcs.py Adds a reusable positive-integer configuration validator.
metaflow/plugins/metadata_providers/service.py Implements cursor pagination and shared capability caching, but capability results never refresh when a service changes at the same URL.
test/unit/test_client_run_listing.py Thoroughly exercises pagination and cross-URL cache behavior but does not cover upgrades or replacements behind the same URL.

Reviews (3): Last reviewed commit: "Paginate artifact collections against me..." | Re-trigger Greptile

Comment thread metaflow/plugins/metadata_providers/service.py Outdated

@classmethod
def _can_paginate_collection(cls, sub_type, attempt):
return sub_type != "self" and attempt is None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should not paginate artifact already. return sub_type not in ("self", "artifact") and attempt is None. The current metadata service does artifact selction based on latest task attempt, but this PR will find the latest occurrence of every artifact.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, with a comment on the line explaining why, so it doesn't get "optimised" back later.

Thank you for tracing this to the SQL. DISTINCT ON (task_id, name) really is a different operation from the legacy latest-attempt filter, and I hadn't spotted that the two paths disagree on retried tasks. I'll open a follow-up on metaflow-service with the latest_attempt CTE. The client-side guard should stay until that ships and is released, since the client can't tell a fixed 2.6 from an unfixed one.

@talsperre

Copy link
Copy Markdown
Collaborator

Some additional comments as well here

…ests

Covers every open thread plus talsperre's written review.

Blocker -- artifact collections no longer paginate:
- Service 2.6.0's paginated artifact query selects the newest row per artifact
  *name* (SELECT DISTINCT ON (task_id, name)), not the artifacts of the newest
  task *attempt*, so a file written only by attempt 0 could surface on a task
  whose latest attempt never wrote it. _can_paginate_collection now excludes
  "artifact", with a comment explaining why, so artifacts keep the legacy
  latest-attempt semantics. The root cause is in the service SQL and needs a
  metaflow-service fix; this is the safe client-side guard until then.

Capability gating is now a property of the selected service, not the process:
- Replace the three process-global booleans (_supports_attempt_gets,
  _supports_tag_mutation, _supports_cursor_pagination) with one
  _service_version_cache keyed by service URL, read through _service_supports().
  metadata("service@...") can repoint a live process at another service; the old
  single boolean let a 2.5 verdict disable filtering on a 2.6 service, and let a
  2.6 verdict send pagination parameters to a 2.5 one. Each call site keeps its
  own minimum-version error message, and one version ping now serves all three
  checks per service instead of three.

Pagination protocol failures are loud instead of silently wrong:
- The X-Limit capability header is checked on every page, not just the first.
  In a rolling deployment page 2 can reach an older instance that ignores the
  cursor and filters and answers with a bulk listing; yielding that would
  duplicate and unfilter the stream. Falling back to the legacy listing stays
  allowed on the first page only, where nothing has been yielded yet.
- A repeated cursor now raises instead of returning. It is a broken exchange,
  and returning presented a truncated listing as a complete one.
- iter_objects validates obj_type/sub_type before the capability ping, so an
  invalid internal query no longer makes an HTTP request before failing.

Flow.runs() public surface:
- filters -> _filters. The field:operator grammar is a metadata-service detail
  with no meaning for local metadata, so it is not part of the public API;
  Flow.failed_runs() is the typed accessor built on top of it.
- page_size is removed from the signature entirely rather than made private.
  runs() returns an iterator either way, so page size is a transport concern;
  it comes from METAFLOW_SERVICE_PAGE_SIZE.
- max_runs stays public: unlike the other two it is meaningful for every
  metadata provider, including local.
- Validate _filters with isinstance(..., Mapping) instead of hasattr("items").
  The explicit bool check on max_runs stays, now with a comment: bool subclasses
  int, so without it max_runs=True would silently mean one run.
- Extract MetaflowObject._child_from_record() so __iter__ and _iter_children
  build children the same way.

Config:
- METAFLOW_SERVICE_PAGE_SIZE is documented, validated as a positive integer
  (values from the environment arrive as strings), and clamped to the service
  maximum of 500 before it is sent.

Tests:
- Converted to the repo's conventions: pytest-mock instead of unittest.mock.
- Tests that did not exercise what their name claimed now do. The max_runs
  ordering test and the local tag-filtering test run end to end against a real
  Flow and the local provider instead of slicing provider output or mocking
  _iter_children. The collection-type test is parametrized over every paginable
  collection. The validation test now fails if either _version() or _request()
  is called at all.
- New regression tests: service switch rechecks capability, missing capability
  header on a later page raises, repeated cursor raises instead of truncating,
  artifact collections never paginate, one version ping per service, each
  capability keeps its own message, page size is clamped.
- The repeated-cursor test previously asserted silent truncation. Inverted.
- The artifact test asserts only that artifacts take the legacy path. Comparing
  paginated and legacy artifact results needs a metaflow-service test, and
  claiming that coverage here would be the same false confidence the review
  flagged.
@Aryan95614

Copy link
Copy Markdown
Author

Thanks for taking the time to write this up properly. The artifact one in particular I would not have found on my own.

Pushed a commit that covers all six points and the test feedback.

1. Artifact pagination. Artifacts are excluded from the paginated path now. The root cause is on the service side, so I'll follow up there with the latest_attempt CTE; the client guard should stay until that's released.

2. Capability cache. Replaced all three global booleans with a single version cache keyed by service URL, rather than URL-keying only the pagination flag. Each call site keeps its own error message, and one ping per service now covers all three checks.

3. Only the first page was checked. The X-Limit check runs on every response. The first page can still fall back to the legacy listing because nothing has been yielded at that point; after that it raises.

4. Repeated cursor. Raises now, separated from the normal "no next cursor" completion.

5. Validation before HTTP. _validate_object_query moved to the top of iter_objects. The test makes both _version() and _request() blow up, so it fails if that ever slides back down.

6. Tests. You were right, several of them weren't checking what their names claimed. The max_runs ordering test and the local tag-filtering test now run end to end against a real Flow and the local provider instead of slicing provider output or mocking _iter_children. The collection-type test is parametrized over every paginable collection. The file is on pytest-mock per CONTRIBUTING.md. The repeated-cursor test that locked in silent truncation now expects the exception.

Since your point was that a green CI proved nothing here, I went back and reverted each fix one at a time to check the new tests actually fail: the artifact exclusion, the per-page header check, the repeated-cursor raise, and the URL-keyed cache each break exactly the test meant to catch them. The full unit suite has the same failures before and after my commit.

Two things I didn't quietly decide on my own:

  • max_runs is still public. Details in the thread above, and I'm happy to drop it.
  • The artifact test only asserts that artifacts take the legacy path. It can't honestly compare paginated and legacy artifact results, because the difference lives in the service's SQL and the client no longer paginates them at all. That comparison belongs in the metaflow-service PR.

I've also fixed the 2.5.1 / 2.6.0 mismatch in the description and added a short future work note about the provider-translated filter grammar.

#3349 is stacked on this, so I've left it alone for now and will update it once this settles.

saikonen
saikonen previously approved these changes Aug 29, 2026
metaflow-service 2.6.0 paginated artifacts as the newest row per artifact
name rather than the artifacts of the task's latest attempt, so the client
kept artifact collections on the legacy bulk GET. 2.6.1 ships the fix
(metaflow-service#497), so the artifact exclusion goes away and the
pagination gate moves from 2.6.0 to 2.6.1 instead. A 2.6.0 service now
falls back to legacy listing for every collection, which is what it
shipped with; the per-attempt artifact endpoint still has no cursor
pagination and keeps the bulk GET.
Comment on lines +276 to +279
url = cls.INFO
if url not in cls._service_version_cache:
cls._service_version_cache[url] = cls._version(None)
version = cls._service_version_cache[url]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Service version cache stays stale

If a metadata service is upgraded, downgraded, or replaced without changing its URL, _service_supports continues using the first version cached for that URL. The client then rejects supported filtering and uses legacy listing after an upgrade, or sends unsupported paginated and attempt-specific requests after a downgrade.

Knowledge Base Used:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants