BLAST jobs list — real next_cursor (#50 AC4 / #51 local half)¶
Motivation¶
The jobs-list page envelope already reported an honest has_more via a
fetch-one-extra probe, but next_cursor was always omitted — the route fetched
local rows through list_for_owner / list_all and discarded the index cursor.
With the time-ordered index (#50) in place, the route can now emit a real
continuation token so a client can page past the first window without re-reading
from the top.
User-facing change¶
GET /api/blast/jobsaccepts a new optionalcursorquery parameter.- When the time-index flag (
JOBSTATE_TIME_INDEX_ENABLED) is on and the listing is owner- or all-scoped, the responsepage.next_cursorcarries the keyset of the last displayed row (encode_cursor(row_key(created_at, job_id))). - Passing that
cursorback returns the next page with no overlap and no gaps. An expired/garbage cursor degrades gracefully to the first page (thedecode_cursorfail-closed validation already rejects malformed tokens). - Scoped listings (filter by cluster / subscription / resource group) stay
first-page-only and report
next_cursoromitted — the scope columns are mutable (update()rewrites them), so they cannot key the immutable index. - Flag-off path is unchanged:
next_cursoris omitted exactly as before.
Why the keyset of the displayed row, not the fetch cursor¶
The route merges external OpenAPI /v1/jobs rows into the local set. Those rows
are upserted into the local Table (and therefore the time index), so the local
index is the effective combined cursor. Using the last displayed row's keyset
(rather than the local fetch cursor) keeps the boundary correct across the
merge: on a cursor page the local index page is filtered RowKey gt cursor, and
external rows at-or-newer-than the boundary (already shown on a previous page)
are dropped before merge so keyset pagination never duplicates.
API / IaC diff summary¶
api/services/blast/jobs_list_cache.py—jobs_list_cache_keygains acursorfield so each cursor page is a distinct SWR cache entry.api/routes/blast/jobs.py—cursorthreaded through the route signature, the SWR cache key, both background-revalidate dispatches, and_compute_blast_jobs_response, which now (a) readslist_owner_page/list_all_pagefor cursor pages, (b) drops external rows newer-than-or-equal to the cursor boundary, and © computesnext_cursorfrom the last displayed row.api/services/response_contracts.py—build_pagedocstring updated (thenext_cursorfield is now served, not reserved).- No IaC change.
Validation¶
uv run pytest -p no:xdist -o addopts="" api/tests/test_blast_jobs_routes.py— 24 passed, including 4 new cursor tests:test_jobs_list_emits_next_cursor_keyset_of_last_row,test_jobs_list_cursor_page_continues_without_overlap,test_jobs_list_no_next_cursor_when_index_disabled,test_jobs_list_scoped_listing_has_no_cursor.uv run pytest -p no:xdist -o addopts="" api/tests/test_jobs_list_cache.py— 11 passed, includingtest_cursor_distinguishes_cache_key.uv run pytest -p no:xdist -o addopts="" api/tests/test_jobstate_time_index.py— 25 passed (index write/read invariants unchanged).uv run ruff check api— clean.
The scoped canonical integration tests in
test_external_blast_api.pyhang locally (localazcredentials trigger real ARM discovery timeouts), so the cursor logic is covered by the mocked route tests above, which stub the external sync and state repo.
Out of scope / follow-up¶
list_for_scopestays on the bounded_list_recent_sortedscan by design (mutable scope columns cannot key the immutable index) — #50 AC1's scope sub-item is intentionally a documented scan, pending maintainer decision.- Folding a sibling
/v1/jobscursor into the combined token (#51 AC2) is blocked until upstreamdotnetpower/elastic-blast-azureaddscursorsupport to/v1/jobs; today external rows reach the cursor via the local-Table sync. - The SPA does not yet consume
next_cursor(no infinite-scroll / load-more); the field is additive and ready for that frontend work.