Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions architecture/gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -544,14 +544,23 @@ modes:
backfill and sandbox annotation updates).

**Lists.** Public list RPCs follow AIP-158: requests carry direct `page_size`
and `page_token` fields, and responses carry `next_page_token`. The gateway
clamps page sizes to 1,000 and returns opaque base64url continuation tokens.
and `page_token` fields, and responses carry `next_page_token`. The sole
exception is `ListSandboxProviders`: a sandbox can have at most 32 attached
providers, so the gateway returns its complete bounded result in one response
and does not expose pagination fields. The gateway clamps page sizes to 1,000
and returns opaque base64url continuation tokens.
Tokens bind the RPC and every request parameter except `page_size`, contain no
authorization grant, and use immutable keyset cursors rather than database
offsets. Each page repeats normal authentication and authorization. Pagination
is weakly consistent under concurrent writes and deletes; it does not provide a
historical snapshot.

Object-backed lists (`ListSandboxTemplates`, `ListSandboxes`, `ListServices`,
`ListProviders`, `ListWorkspaces`, and `ListWorkspaceMembers`) sort ascending
by `(created_at_ms, name, workspace, id)`. `ListProviderProfiles` sorts
ascending by `(id, scope)`, and `ListSandboxPolicies` sorts by descending policy
version. `ListSandboxProviders` preserves the sandbox's attachment order.

The token wire format is a private shared protobuf used only by the gateway.
Public request and response messages repeat the standard AIP fields directly
instead of wrapping them in a shared pagination message.
Expand All @@ -567,6 +576,16 @@ Curated Rust, Python, Go, and TypeScript SDK list methods return lazy pagers.
Advancing a pager issues one list RPC and exposes its continuation token;
explicit `list_all` helpers are the only curated APIs that exhaust a collection.

**Migration.** This pagination contract is a breaking replacement for the
former `limit`/`offset` list APIs. Protocol clients must send `page_size` and
resume only with the returned `next_page_token`; an empty token is the sole
completion signal. SDK callers that need every item must use the explicit
full-iteration helper (for example, Rust's `list_all_sandboxes`) rather than
awaiting `list_sandboxes`, which now returns one-page `Pager` state. Callers
that need one page should advance that pager once and retain its token. Internal
full scans use the reusable iteration helpers from the persistence pagination
audit rather than manually advancing offsets.

Persistence distinguishes one-page operations from exhaustive scans.
`list_object_page` and `list_message_page` return one keyset page and its next
cursor. `collect_records` and `collect_messages` exhaust those pages, fail on
Expand Down
8 changes: 5 additions & 3 deletions crates/openshell-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,11 @@ workspace. Cross-workspace listing uses the separate `*_all_workspaces`
methods and requires Platform Admin access.

Curated `list_*` methods return a lazy `Pager<T>`. Each `next_page()` call
issues at most one RPC and returns a `Page<T>` with its opaque continuation
token. The explicit `list_all_*` conveniences exhaust that pager; `page_size`
always controls one gateway request, and `page_token` resumes a saved traversal.
fetches one logical page and returns a `Page<T>` with its opaque continuation
token. OIDC authentication can retry that page once after an `Unauthenticated`
response. The explicit `list_all_*` conveniences exhaust that pager;
`page_size` always controls one gateway request, and `page_token` resumes a
saved traversal.

```rust
let mut pages = client.list_sandboxes(ListOptions {
Expand Down
4 changes: 3 additions & 1 deletion crates/openshell-sdk/src/pagination.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ pub struct Page<T> {
/// A lazy, single-pass iterator over response pages.
///
/// Constructed by curated `list_*` methods. No RPC is issued until
/// [`Pager::next_page`] is called, and each call fetches at most one page.
/// [`Pager::next_page`] is called, and each call fetches one logical page.
/// A refreshed OIDC credential may retry that page once after an
/// `Unauthenticated` response.
pub struct Pager<T> {
fetch: PageFetcher<T>,
next_page_token: Option<String>,
Expand Down
72 changes: 72 additions & 0 deletions crates/openshell-server/src/pagination.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,78 @@ fn fingerprint(parameters: &[&str]) -> Vec<u8> {
mod tests {
use super::*;

fn message_body<'a>(proto: &'a str, message: &str) -> &'a str {
let marker = format!("message {message} {{");
let start = proto
.find(&marker)
.unwrap_or_else(|| panic!("{message} message must exist"))
+ marker.len();
proto[start..]
.split_once("\n}")
.unwrap_or_else(|| panic!("{message} message must close"))
.0
}

fn message_documentation<'a>(proto: &'a str, message: &str) -> &'a str {
let marker = format!("message {message} {{");
let message_start = proto
.find(&marker)
.unwrap_or_else(|| panic!("{message} message must exist"));
let documentation_start = proto[..message_start]
.rfind("\n\n")
.map_or(0, |boundary| boundary + 2);
&proto[documentation_start..message_start]
}

#[test]
fn every_public_list_rpc_is_paginated_or_explicitly_bounded() {
let proto = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../proto/openshell.proto"
));
let normalized = proto.lines().map(str::trim).collect::<Vec<_>>().join(" ");
let mut remaining = normalized.as_str();

while let Some(start) = remaining.find("rpc List") {
remaining = &remaining[start + "rpc ".len()..];
let (method, after_request) = remaining
.split_once('(')
.expect("List RPC must declare a request type");
let (_, after_returns) = after_request
.split_once("returns (")
.expect("List RPC must declare a response type");
let (response, after_response) = after_returns
.split_once(')')
.expect("List RPC response type must close");
let request = format!("{method}Request");

if method == "ListSandboxProviders" {
assert!(
message_documentation(proto, &request)
.contains("bounded list intentionally has no pagination"),
"{request} must document why it has no pagination"
);
assert!(message_body(proto, response).contains("complete bounded set"));
} else {
let request_body = message_body(proto, &request);
assert!(
request_body.contains("int32 page_size"),
"{request} must declare page_size"
);
assert!(
request_body.contains("string page_token"),
"{request} must declare page_token"
);
assert!(
message_body(proto, response).contains("string next_page_token"),
"{response} must declare next_page_token"
);
}

remaining = after_response;
}
}

#[test]
fn page_size_defaults_clamps_and_rejects_negative_values() {
assert_eq!(
Expand Down
5 changes: 4 additions & 1 deletion proto/openshell.proto
Original file line number Diff line number Diff line change
Expand Up @@ -1244,7 +1244,9 @@ message ListSandboxesRequest {
openshell.datamodel.v1.WorkspaceSelector workspace_scope = 6;
}

// List providers attached to a sandbox request.
// List providers attached to a sandbox request. A sandbox can have at most 32
// attached providers, so this bounded list intentionally has no pagination
// fields.
message ListSandboxProvidersRequest {
reserved 2;
reserved "workspace";
Expand Down Expand Up @@ -1332,6 +1334,7 @@ message ListSandboxesResponse {

// List providers attached to a sandbox response.
message ListSandboxProvidersResponse {
// The complete bounded set of providers attached to the sandbox.
repeated openshell.datamodel.v1.Provider providers = 1;
}

Expand Down
13 changes: 10 additions & 3 deletions python/openshell/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,20 +47,27 @@ class Page(Generic[T]):


class Pager(Generic[T]):
"""Lazy, single-pass iterator that fetches one RPC page per advance."""
"""Lazy, single-pass iterator over the continuation-token contract."""

def __init__(self, fetch: Callable[[str], Page[T]], page_token: str = "") -> None:
self._fetch = fetch
self._page_token: str | None = page_token
self._consumed_page_tokens: set[str] = set()

def __iter__(self) -> Pager[T]:
return self

def __next__(self) -> Page[T]:
if self._page_token is None:
raise StopIteration
page = self._fetch(self._page_token)
self._page_token = page.next_page_token or None
page_token = self._page_token
page = self._fetch(page_token)
if page_token:
self._consumed_page_tokens.add(page_token)
next_page_token = page.next_page_token
if next_page_token and next_page_token in self._consumed_page_tokens:
raise SandboxError("pager received a repeated continuation token")
self._page_token = next_page_token or None
return page

def all(self) -> builtins.list[T]:
Expand Down
10 changes: 10 additions & 0 deletions python/openshell/sandbox_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -2610,6 +2610,16 @@ def fetch(token: str) -> Page[int]:
assert tokens == ["resume", "resume"]


def test_pager_rejects_a_repeated_continuation_token() -> None:
pager = Pager(
lambda token: Page(items=[1], next_page_token=token),
page_token="resume",
)

with pytest.raises(SandboxError, match="repeated continuation token"):
next(pager)


def test_list_ids_forwards_label_selector() -> None:
stub = _FakeSandboxStub(listed=[_make_sandbox_proto("sandbox-1", "job-1")])
client = _client_with_fake_stub(stub)
Expand Down
15 changes: 11 additions & 4 deletions sdk/go/openshell/v1/pager.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,18 @@ type Page[T any] struct {

type pageFetcher[T any] func(context.Context, string) (*Page[T], error)

// Pager lazily fetches one RPC page per call to NextPage.
// Pager lazily fetches pages from the continuation-token contract.
//
// A Pager is single-pass and must not be used concurrently.
type Pager[T any] struct {
fetch pageFetcher[T]
nextPageToken *string
fetch pageFetcher[T]
nextPageToken *string
consumedTokens map[string]struct{}
}

// NewPager constructs a pager from an RPC page fetcher.
func NewPager[T any](pageToken string, fetch func(context.Context, string) (*Page[T], error)) *Pager[T] {
return &Pager[T]{fetch: fetch, nextPageToken: &pageToken}
return &Pager[T]{fetch: fetch, nextPageToken: &pageToken, consumedTokens: make(map[string]struct{})}
}

func newPager[T any](pageToken string, fetch pageFetcher[T]) *Pager[T] {
Expand All @@ -48,6 +49,12 @@ func (p *Pager[T]) NextPage(ctx context.Context) (*Page[T], error) {
if page.Items == nil {
page.Items = make([]T, 0)
}
if *p.nextPageToken != "" {
p.consumedTokens[*p.nextPageToken] = struct{}{}
}
if _, seen := p.consumedTokens[page.NextPageToken]; page.NextPageToken != "" && seen {
return nil, errors.New("pager received a repeated continuation token")
}
if page.NextPageToken == "" {
p.nextPageToken = nil
} else {
Expand Down
10 changes: 10 additions & 0 deletions sdk/go/openshell/v1/pager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ func TestPagerAllRetriesTheCurrentTokenAfterError(t *testing.T) {
assert.Equal(t, []int{1, 2}, items)
}

func TestPagerRejectsRepeatedContinuationToken(t *testing.T) {
pager := NewPager("resume-token", func(_ context.Context, token string) (*Page[string], error) {
return &Page[string]{Items: []string{"first"}, NextPageToken: token}, nil
})

page, err := pager.NextPage(context.Background())
assert.Nil(t, page)
assert.EqualError(t, err, "pager received a repeated continuation token")
}

func TestPagerNormalizesEmptyItems(t *testing.T) {
pager := NewPager("", func(_ context.Context, _ string) (*Page[string], error) {
return &Page[string]{}, nil
Expand Down
7 changes: 5 additions & 2 deletions sdk/go/proto/openshellv1/openshell.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading