Skip to content

feat(pagination): add Cursor field and NextCursor for key-set pagination - #382

Open
iamralch wants to merge 2 commits into
einride:masterfrom
iamralch:master
Open

iamralch wants to merge 2 commits into
einride:masterfrom
iamralch:master

Conversation

@iamralch

Copy link
Copy Markdown

Summary

  • Add Cursor []any to PageToken so a single opaque token can carry key-set state alongside Offset.
  • Add PageToken.NextCursor(msg proto.Message, fields ...string) (PageToken, error) — reads named fields off msg (typically the last row of the current page) via protoreflect and stores them in Cursor. Enums are normalized to int32; repeated/map/message fields are rejected.
  • Next(request) continues to advance Offset for offset-style pagination. The two modes are independent on the same token.

Why both on one token

Per AIP-158, page_token is server-opaque — the server defines its contents. Offset (via skip) and key-set cursors are complementary, not mutually exclusive. A handler can issue offset tokens for early pages and switch to cursor tokens once a stable ordering key is available, all behind the same opaque string.

Keeping both fields on one struct:

  • lets handlers switch modes mid-stream without changing token type
  • preserves backward compatibility — legacy {Offset, RequestChecksum} tokens gob-decode with Cursor left nil (covered by an existing legacy-decode test)
  • keeps RequestChecksum as the single guard against request drift between pages

Test plan

  • go test ./pagination/ — existing suite + new cursor round-trip, NextCursor population, and unknown-field error tests pass
  • legacy token decode verified (pre-Cursor token shape still parses with Cursor == nil)

Extend PageToken with a Cursor []any field and a NextCursor(msg, fields...)
helper that reads named proto fields off a message (typically the last row of
the current page) into Cursor via protoreflect. The existing Next(request)
continues to advance Offset for offset-based pagination; NextCursor is an
additional entry point for key-set pagination.

Why carry Cursor alongside Offset on the same PageToken:

Page tokens are server-opaque. AIP-158 specifies that the server defines
the format and contents of page_token, and clients MUST treat them as opaque
strings (https://google.aip.dev/158#page-tokens). Nothing in the AIP binds a
resource to a single pagination strategy — offset-style `skip`
(https://google.aip.dev/158#skipping-results) and key-set cursors are
complementary, not mutually exclusive. A handler can issue offset tokens
for early pages (cheap, simple) and transition to cursor tokens once it has
a stable ordering key, all behind the same opaque token.

Keeping both fields on one struct:
- lets a handler switch between modes mid-stream without changing token type
- preserves backward compatibility — gob decodes legacy {Offset, RequestChecksum}
  tokens with Cursor left nil (covered by the legacy-decode test)
- keeps RequestChecksum as the single guard against request drift between pages

NextCursor rejects repeated/map and message fields (ambiguous cursor encoding)
and normalizes enums to int32 so gob can round-trip them.
@iamralch
iamralch requested a review from a team as a code owner April 24, 2026 07:04
Decode google.protobuf.Timestamp via AsTime() and
google.protobuf.Duration via AsDuration() when building a key-set
cursor, so callers can order by well-known time fields without
hitting the generic "is a message (unsupported)" error. Other
message types remain rejected.
@iamralch

Copy link
Copy Markdown
Author

@odsod @fredrikaverpil any chance to look at my PR?

@iamralch

iamralch commented May 5, 2026

Copy link
Copy Markdown
Author

@odsod did you have a chance to look at this PR? Let me know if you are keen to accept it or not.

@fredrikaverpil

Copy link
Copy Markdown
Member

@iamralch sorry for the delay in getting a review. We haven't been able to get to this one yet due to other priorities.

I can't tell you right now if we want to adopt this, since it can be seen as a footgun to mix both the offset-based and cursor-based pagination into the same struct. We'll discuss this internally but unfortunately, I cannot give you a timeframe.

You don't have to ping Oscar because he's no longer with the company.

@iamralch

iamralch commented May 5, 2026

Copy link
Copy Markdown
Author

Hi @fredrikaverpil, thanks for the update and for the heads-up about Oscar. I definitely understand having other priorities!

I was mostly just worried the PR had slipped through the cracks, so I appreciate the acknowledgment. I’m happy to be patient while you discuss it internally. Looking forward to hearing your thoughts on the approach whenever you have a moment.

@iamralch

iamralch commented Sep 1, 2026

Copy link
Copy Markdown
Author

@fredrikaverpil did u have a chance to discuss the feature internally?

@alethenorio alethenorio left a comment

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.

Hi @iamralch

I am starting to take a look at your PR and left some comments.

I am not I understand the use case for supporting "switching modes mid-stream". What is the use case? I do however see the value in a service switching from a offset-based to a cursor-based approach over time (maybe that's what you mean?) so I think potentially reusing the same struct might make some sense.

Also I think there may some issues with token decoding and encoding using the gob package when using non-native types such as time.Duration and time.Time so maybe add some tests for those?

Comment thread pagination/pagetoken.go
// Offset of the page.
Offset int64
// Cursor for key set pagination.
Cursor []any

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.

I'm thinking that we do not need to support a token going from cursor to offset midway but we do want to support a service switching implementations and being able to discern which type of token was used when receiving a request. Does it make sense to add some sort of enum field which we could validate against to know which types was used?

Comment thread pagination/pagetoken.go

// NextCursor returns the next page token for key set pagination by reading the
// named fields off msg (typically the last row of the current page) into Cursor.
func (p PageToken) NextCursor(msg proto.Message, fields ...string) (PageToken, error) {

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.

Rather than using a generic string here, would it make sense to use a ordering.Field list? This way we could make it easier for the service using the package to derive this directly from the parsed orderBy value from the request, WDYT?

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