Repository navigation
fix(cli): Route all limit-bounded pagination through paginate() - #1625
Merged
Merged
Conversation
Sentry's page-number paginators (OffsetPaginator, used by projects, teams, members, repositories, and dashboard revisions) put the page size in the cursor. Asking for "the remaining budget" on the last page reads the wrong window and silently skips rows, so paginate() now keeps one page size per walk, picks one that divides the limit, and stops a page early rather than trimming away a usable cursor. Hand-written limit-bounded cursor loops are migrated to paginate() and rejected by a new sentry-cli/no-manual-pagination lint rule. A property test ports Sentry's paginator cursor math so this class of bug fails CI. Co-Authored-By: BYK <[email protected]>
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
BYK
requested changes
Oct 23, 2026
BYK
left a comment
Member
There was a problem hiding this comment.
Known gap, not addressed here and not new: resuming a page-number cursor with a different --limit still misaligns, because the cursor's page size no longer matches. Fixing that needs CLI-side cursors that carry the page size.
Let's do this here too
BYK
requested changes
Oct 23, 2026
BYK
left a comment
Member
There was a problem hiding this comment.
We should also clean up any redundant tests now.
Resuming a page-number cursor with a different --limit misaligned, because Sentry's OffsetPaginator encodes the page size in its cursor and a different per_page reads the wrong window. paginate() now returns opaque CLI cursors (cli1~<perPage>~<skip>~<server cursor>) that record the walk's page size and how many rows of the current page were already returned. A later -c next reuses that page size whatever --limit is, and resumes mid-page by refetching the page and skipping returned rows, so it no longer stops a page early or drops the cursor when a server ignores per_page. Raw server cursors are still accepted as a starting point. issue list resumes through listIssuesAllPages so wrapped cursors are decoded, and dashboard list always requests full pages, since its afterId bookmarks already handle mid-page resumes.
Now that paginate() cursors carry the page size and resume mid-page, a page size that divides the limit no longer buys correctness. Use min(limit, API_MAX_PER_PAGE) so large limits need fewer requests, and remove pageSizeForLimit(). Drop per-endpoint tests that re-checked paginate()'s trimming, overshoot, and page-size rules; the property test and paginate's unit tests cover them. Each caller keeps its per_page and cursor wiring tests.
paginate() now wraps server cursors with the walk's page size, so the e2e test checks that the server cursor is carried instead of matching it exactly.
BYK
approved these changes
Oct 23, 2026
BYK
added a commit
that referenced
this pull request
Oct 23, 2026
… cursors (#1642) Follow-up to #1625. This fixes two things before `paginate()`'s cursors ship in a release. **Overfetching on the last page.** `paginate()` used 100-row pages for any `--limit` over 100. So `--limit 201` made three requests of 100 and threw away 99 rows. A fresh walk now uses the same, minimal number of requests and splits the limit evenly across them: 201 → 3 × 67, 250 → 3 × 84, 101 → 2 × 51. A property test checks the request count (`ceil(limit / 100)`) and that a walk overfetches by less than one row per request. **Cursors that work across CLI versions.** #1625 returned CLI-only `cli1~<perPage>~<skip>~<server cursor>` cursors. Older CLIs send any cursor straight to the API, so they couldn't use those, and the new CLI couldn't trust the raw cursors older CLIs stored. Now every cursor `paginate()` returns is a Sentry cursor: - **Page boundary:** the server's cursor, unchanged. - **Mid-page:** the cursor of the partly returned page, with the number of rows already returned written in binary into `is_prev`. After a leading `0`, a 1 bit is `_0` and a 0 bit is `0`, most significant first. So `84:2:0_00_000_00` means page `84:2:0`, skip 82 (`0b1010010`), and the worst case is about 15 characters. - Sentry parses `is_prev` with Python's `int()` (`Cursor.from_string` in `src/sentry/utils/cursors.py`). `int()` allows single underscores between digits (PEP 515), so every such string is `0`, and every endpoint accepts it as a normal next-page cursor. - `paginate()` refetches the page and skips those rows. An older CLI, or anything else that sends the cursor as is, sees those rows again but never misses any. - `encodeSkip()` documents the format. - **Cursors that can't carry a skip:** a server cursor that isn't a Sentry next-page cursor (e.g. a pasted `is_prev=1` cursor) is returned as is. Resuming refetches that page. - **Page size:** no longer stored in the cursor. `OffsetPaginator` cursors (`<per_page>:<page>:<is_prev>`) already encode it, so `paginate()` reads it back, and pasted or stored raw cursors resume with the right window. Other paginators (`BasePaginator`, `GenericOffsetPaginator`, `SequencePaginator`) treat `offset` as a row offset and take any page size. When resuming mid-page, the first request also fetches the rows to skip, so it uses as few requests as possible. Because both directions work, stored `-c next` history is shared across versions again: no namespacing, and nothing is discarded on upgrade or downgrade. Cursors stored by older CLIs are raw server cursors and resume correctly. `cli1~` cursors saved by nightly builds since #1625 are still read but never written. A damaged one is ignored with a warning, and the walk starts from the first page. Known limit: an older CLI that sends a page-number cursor with a *different* `--limit` still misreads it. That's the pre-#1625 bug, and this PR can't fix it on the old side. Tests: - Properties over ports of `OffsetPaginator`, `GenericOffsetPaginator`, and a server that ignores `per_page`. They check that every row comes back exactly once across `-c next` with changing limits, that every returned cursor is a Sentry cursor (with `is_prev` a Python int literal equal to 0) that the API resumes from without skipping a row, and that a pasted page-number cursor resumes without skipping. - Unit tests for the request sequences, encoded skips, cursors that can't carry a skip, and nightly `cli1~` cursors. - I checked the encoding against real Python `int()` for skips from 1 to 1000. Local checks: `tsc` and lint pass. CLI unit suite: 11,300 pass. The only failures are 6 chmod/permission tests (completions, code-scanner, file-changes, telemetry, upgrade) that also fail on clean `main` in this sandbox. <!-- junior-request-attribution:start --> via **BYK**. <!-- junior-request-attribution:end --> <!-- junior-session-footer:start --> <!-- junior-conversation-id:local%3Aweb%3A4269baf0761dc42b4e7fc238 --> -- [View Junior Session in Sentry](https://sentry.sentry.io/explore/conversations/local%3Aweb%3A4269baf0761dc42b4e7fc238/?project=4510944073809921) <!-- junior-session-footer:end --> --------- Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: BYK <[email protected]>
This branch was previously deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #1608. That PR fixed one hand-rolled loop. This one makes the shared helper the only way to do limit-bounded pagination, and has lint and a property test enforce it.
The "remaining budget" approach skips rows on page-number paginators.
OffsetPaginator(used for org projects, teams, members, repositories, and dashboard revisions) encodes the page size in the cursor as<per_page>:<page>:0. If you send that cursor with a smallerper_page, the server reads the end of the old-sized window (results[-(limit + 1):]insrc/sentry/api/paginator.py). So--limit 150came back as rows 0–99 plus 150–199, with 100–149 silently missing. Since #1615,paginate()and the loop from #1608 both did this. It wasn't caught because every test modeled cursors as absolute offsets.paginate()now returns opaque CLI cursors,cli1~<perPage>~<skip>~<server cursor>, which record the walk's page size and how many rows of the current page were already returned:per_pageof its first request (min(limit, 100)), including later-c nextcalls with a different--limit.--limit, or a server that ignoredper_page), the next call refetches that page and skips the rows already returned. No rows are skipped or repeated.cli1~cursor is rejected with aValidationError.To keep this class of bug from coming back:
paginate(): alert issue and metric lists, dashboard revisions,listIssuesAllPages,listIssueEvents, andqueryEvents.sentry-cli/no-manual-pagination: rejects any loop insrc/(outsideinfrastructure.ts) that follows cursors and also counts toward alimit. It flags all six original sites, including the fix(cli): Stop dashboard revisions pagination from skipping rows #1608 version of revisions.dashboard/list.tskeeps a disable with a reason, because it filters client-side and resumes mid-page with its own bookmarks.prefer-paginate-helpernow covers all ofsrc/.test/lib/paginate.property.test.ts: ports Sentry'sOffsetPaginatorandGenericOffsetPaginatorcursor math, plus a server that ignoresper_page. It walks-c nextwith a different--limiton each call and asserts every row comes back exactly once, in order.packages/cli/AGENTS.mdused to tell people to write exactly this loop by hand. It now points topaginate()and explains the cursor format.One behavior change:
listIssueEventsused to keep the server cursor after an overshoot, on the theory that offset cursors would re-include the trimmed rows.GenericOffsetPaginatorcomputes the next offset from the server's own page size, so keeping it would skip rows. It now follows the shared contract.Follow-up: #1642 splits large limits evenly across pages (so
--limit 201doesn't fetch a full third page for one row) and makes cursors safe across CLI upgrades and downgrades.via BYK.
--
View Junior Session in Sentry