Thanks to visit codestin.com
Credit goes to github.com

Skip to content

feat!: bound page size on the workspaces list endpoint - #28040

Closed
jscottmiller wants to merge 7 commits into
mainfrom
scott/plat-386-bound-workspace-pagination
Closed

feat!: bound page size on the workspaces list endpoint#28040
jscottmiller wants to merge 7 commits into
mainfrom
scott/plat-386-bound-workspace-pagination

Conversation

@jscottmiller

@jscottmiller jscottmiller commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

GET /api/v2/workspaces treated an omitted or zero limit as "no limit" and passed it to SQL as NULL. It now resolves an omitted limit to 1000 and rejects limit=0 or limit>1000 with a 400. Only this endpoint changes: the shared coderd.ParsePagination is untouched, and ParsePaginationBounded is a second entry point used by the workspaces handler alone. Other list endpoints get bounded individually, later.

Breaking: a client that omitted limit to fetch every workspace now receives the first 1000. limit=0 is rejected.

What this really changes

The unbounded list was not an oversight callers worked around — it was a contract they were built on. Much of the product assumes a single request returns every workspace it cares about, and removing that assumption is what produced most of the diff and all of the risk:

  • Anything that iterated now needs a loop that terminates correctly. A page can be shorter than the limit without the result set being exhausted, because convertWorkspaces drops workspaces whose latest build or template the requester cannot read after the SQL limit is applied. len(page) < pageSize truncates silently and offset += len(page) duplicates rows; both patterns already existed in the tree and are fixed here. The correct rule — advance by the requested page size, stop when the offset reaches count — is now in one helper per language (codersdk.Client.AllWorkspaces, API.getAllWorkspaces) rather than copied per call site.
  • A multi-request read is no longer a snapshot. The endpoint orders by favorite-for-requester and by whether the latest build is running; both change while pages are being read. The helpers de-duplicate by ID, so rows never appear twice, but a row moving the other way can slip past the consumed offset and be missed. One request could not do this.
  • Callers that only needed a number were paying for a list. Those now ask for one row and read count.
  • Anything that took len(list) as a total is now approximate. Where that mattered it is either fixed (support reports what it collected) or called out below.

Raising the limit to 1000 is the hedge. The validated architectures document 600 concurrent running workspaces at 1000 users and 1200 at 2000 users, so for the deployments most customers run, every caller above still gets its answer in a single response and the behaviour is unchanged in practice. Pagination is there for the pathological cases that motivated the issue, not for the common one.

Where a second page is actually expected

Call site Kind Notes
cli/exp_scaletest.go:2228 batch Scale runs create thousands of scaletest- workspaces by design
scaletest/prebuilds/run.go:175,246 batch Walk sits inside a poll loop, so request count multiplies per tick
useWorkspacesToBeDeleted.ts:13 interactive Only admin view that reads every page; a large template blocks the schedule form
codersdk/toolsdk/chatgpt.go:72 interactive Broad query with an admin token walks the deployment
cli/list.go / cli/schedule.go with --all batch Guaranteed multi-page on 3k+ user deployments
support/support.go batch Capped at 10 workspaces by default, so effectively single-page

Everything owner-scoped stays single-page for any realistic user, with one exception worth knowing: prebuilt workspaces are all owned by one pseudo-user, so owner: is not by itself a guarantee of a small result.

Downstream clients

coder/terraform-provider-coderd is unaffected — it never lists workspaces. Worth noting for the next endpoint we bound: internal/provider/user_data_source.go:237-251 pages /api/v2/users with Limit: 100 and stops on len(page) < 100, so any future maximum on /users has to be at least 100, and that loop truncates silently if /users ever drops rows after the SQL limit the way /workspaces does.

Two editor clients list workspaces with no limit and will silently see at most 1000 rows:

  • coder/vscode-coder: the sidebar tree (src/workspace/workspacesProvider.ts:176) and the workspace quick-pick (src/commands.ts:1367). The tree's "All Workspaces" view queries with an empty filter, so it is deployment-wide and refreshes on a poll. Note also that its 400 handler is documented as meaning "this deployment does not support the query filter"; it never sends limit today, but a limit-validation 400 must not be routed there.
  • coder/coder-jetbrains-toolbox: CoderV2RestFacade.kt:35-38 declares the call with only a q parameter and documents it as returning every workspace the user can access.

Neither is blocked by this change, and neither is fixed by it. Follow-ups on those repos should either page or send an explicit limit so the truncation is visible in their UI.

Implementation

Server: codersdk.WorkspacesPageLimit = 1000 (also generated into typesGenerated.ts), ParsePaginationBounded used only by the workspaces handler, @Failure 400 declared, WorkspacesResponse.Count documented as the pre-limit total that can exceed the rows returned, swagger and docs/reference/api/workspaces.md regenerated. An out-of-range limit is rejected rather than clamped, with one message covering the whole range.

Callers routed through the paging helpers: cli/list.go (QueryConvertWorkspaces, so coder list and coder schedule), cli/ssh.go completions, cli/configssh.go, both cli/exp_scaletest.go helpers, scaletest/prebuilds/run.go (duplicate local helper deleted), both codersdk/toolsdk sites, and the frontend agents pages plus useWorkspacesToBeDeleted. Count-only callers (UsageIndicator, TemplatePageHeader, DisableWorkspaceSharingDialog) send limit: 1.

support keeps its own loop because it stops at --workspaces-total-cap and sanitizes agent environment variables page by page. Its page size is no longer tied to that cap, it de-duplicates in the same pass that sanitizes, and a scan that stops on a permissions error now reports the rows it collected instead of the server's total for a set it could not finish reading.

Frontend: getWorkspaces and getAllWorkspaces take an AbortSignal and both list queries forward the one React Query provides, so navigating away stops the walk instead of leaving pages in flight.

An omitted limit defaults rather than erroring because ~108 client.Workspaces call sites in tests omit it; erroring would turn this into a repo-wide edit and a harsher external break for no added safety.

Deliberately out of scope: bounding any other endpoint (provisionerjobs, provisionerdaemons, exp_chats, agentfirewall, members, audit, AIBridge, template ACL), enterprise/cli/groupedit.go user paging, the undetectable truncation in /templates/{template}/acl/available, unbounded offset and deep-offset cost, and cursor (AfterID) paging. Recorded with the rest of the abandoned broad-bound attempt in coder/scott-misc, notes/PLAT-386-pagination-dead-ends.md.

Tests: TestPaginationBounded, endpoint-level TestWorkspacesPageLimit, TestAllWorkspaces (offset progression, short pages, repeated rows, empty results, caller pagination overridden), TestDeploymentInfoWorkspacesIncomplete and TestDeploymentInfoWorkspacesRepeated in support, and getAllWorkspaces vitest cases expressed relative to the page limit so they exercise multi-page paths at any value.

Known limitations

Multi-page reads are not a snapshot. The endpoint orders by whether the workspace is a favorite of the requester and whether its latest build is running (coderd/database/queries/workspaces.sql:400-407). Both keys change while the pages are being read, so a row can move across the page boundary. AllWorkspaces and getAllWorkspaces track the IDs they have returned and skip repeats, so duplicates are gone; a row that moves the other way passes the consumed offset and is missed. A single unbounded request was previously a consistent snapshot, so this is a real regression for callers that page.

Fixing it properly means a stable order for paging clients (drop the two volatile keys, which are presentation-only) and ideally keyset paging — codersdk.Pagination.AfterID exists but GetWorkspaces never implements it. Adding id as a tiebreaker does not help: (owner_username, name) is already a total order, so the instability is in the leading keys, not in ties. Both helpers document the limitation and tell callers who need an exact result to narrow the filter until it fits in one page.

Counts derived from a walked list are approximate. scaletest/prebuilds/run.go:182,238 uses len() over a multi-page walk as a progress denominator, and cli/exp_scaletest.go:462 indexes into independently-walked lists across separate invocations, so an index is not stably the same workspace. Both are scaletest-only and both were exact when one request returned everything.

Template schedule settings can issue more than one request. useWorkspacesToGoDormant and useWorkspacesToBeDeleted preview which workspaces a pending dormancy/autodelete change would affect, against unsaved form values, so they cannot use the server's own eligibility query (GetWorkspacesEligibleForTransition). Pushing the predicates server-side would make these count-only queries, since every consumer reads .length, but neither predicate is expressible today: dormant is a plain boolean whose false value is a no-op (coderd/searchquery/search.go:42, workspaces.sql:332-335), so "not dormant" cannot be asked for, and there is no filter on dormant_at. That needs a nullable dormant plus a dormant_before filter, which is out of scope here.

Unrelated to this change but adjacent: TemplateScheduleForm.tsx:131-143,213-219 treats an undefined hook result as "no workspaces affected", so a failed or in-flight fetch submits the destructive change without the warning dialog. Pre-existing; untouched here.


Created with Coder Agents on behalf of @jscottmiller.

GET /api/v2/workspaces now resolves an omitted limit to 100 and rejects
limit=0 or limit>100 instead of returning every row. The shared
ParsePagination and every other list endpoint are unchanged.

Callers that previously relied on a single unbounded request now page to
exhaustion through codersdk.Client.AllWorkspaces or API.getAllWorkspaces,
which advance the offset by the requested page size and stop when the
offset reaches the count the server reports. Callers that only read the
total request a single row.
@linear-code

linear-code Bot commented Aug 11, 2026

Copy link
Copy Markdown

PLAT-386

@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Docs preview

Check off each page once it's been reviewed. If a page changes in a later push, its checkbox clears automatically so it gets a fresh look. Pages not yet wired into the docs navigation aren't listed here.

The workspaces list is ordered by whether the workspace is a favorite of
the requester and whether its latest build is running. Both change while
successive pages are read, so a row can move past the offset already
consumed and be returned again.

AllWorkspaces and getAllWorkspaces now track the IDs they have returned
and skip repeats. A row that moves the other way is still missed; both
helpers say so, and callers that need an exact result are told to narrow
the filter until it fits in one page.
getWorkspaces and getAllWorkspaces take an AbortSignal and pass it to
axios, and both workspace list queries forward the signal React Query
gives their queryFn. Aborting rejects the request in flight, which ends
the page walk in getAllWorkspaces instead of leaving the remaining pages
to resolve into a discarded cache entry.

Both list helpers now also state that a failed page discards the pages
already read and that the request count follows the total the endpoint
reports.
The workspaces endpoint declares its 400, and WorkspacesResponse.Count
documents that it is the total before the limit and offset and can
exceed the rows returned. Both flow into the swagger, the API docs, and
the generated TypeScript.

An out-of-range limit is now reported with one message covering the
whole range instead of one message for zero and above the maximum and
another for negative, which came from the positive-integer parser.
Parsing the value as a plain integer also drops the int32 argument the
bound did not need.

Pagination.Limit no longer states that a limit of zero returns every
record, since that depends on the endpoint. Comments on Workspaces,
AllWorkspacesRequest, and the agent create form that described the
unbounded behavior are updated, and the query key test seeds the
exhaustive list key so list invalidation stays pinned to it.
The scan requested pages of min(cap, 100), so the default cap of 10 read
the deployment ten rows at a time and relied on the cap check firing
before the offset walked the whole set. The cap now only decides how many
collected workspaces are kept.

Workspaces already collected are skipped, since the endpoint orders by
build state and a workspace can move to a page that has not been read
yet. Sanitizing agent environment variables moved into the same pass, so
a repeat is not sanitized twice.

A scan that stops on a permissions error reports the number of rows it
collected rather than the server's total for the set it could not finish
reading, and logs the offset it stopped at. Run() compares that count
against the list length to decide whether the cap truncated the bundle,
so a denied page no longer reads as a cap truncation or leaves a total
that the list does not account for.
The validated architectures document 600 concurrent running workspaces
at 1000 users and 1200 at 2000 users, so a limit of 1000 leaves the
single-response behaviour intact for those deployments and applies to
the sizes where an unbounded response is the actual problem.

The frontend paging tests are expressed relative to the limit rather
than to fixed row counts, so they exercise the same multi-page paths at
any value.
jscottmiller added a commit that referenced this pull request Aug 18, 2026
…28074)

`convertWorkspaceBuild` rebuilt seven maps from the caller's global
slices on every call and rescanned the provisioner daemon rows to filter
by job ID. `convertWorkspaceBuilds` calls it once per build with
identical slices, so map construction cost `O(builds x rows)` where
`O(rows)` suffices — quadratic in the number of workspaces on `GET
/api/v2/workspaces`.

The maps move into a `workspaceBuildIndex` built once per batch, keyed
exactly as before and now including daemons by job ID.
`convertWorkspaceBuild` takes the index instead of eight slices. Its
parent already hoisted `workspaceByID`, `jobByID`, and
`templateVersionByID` out of the same loop; this makes the rest
consistent.

Agents are sorted while the index is built, so a resource read by
several builds is sorted once rather than once per build. Same
comparator over the same rows, so the order is unchanged;
`TestConvertWorkspaceBuildsAgentOrder` covers it.

`BenchmarkConvertWorkspaceBuilds`, 5 resources x 2 agents x 4 apps per
build:

| builds | ns/op | B/op | allocs/op |
| --- | --- | --- | --- |
| 1 | 48.3k -> 48.1k | 121k -> 125k | 342 -> 359 |
| 25 | 12.7M -> 1.34M | 38.0MB -> 3.2MB | 69,621 -> 8,347 |
| 100 | 186M -> 5.67M | 596MB -> 13.0MB | 1,024,941 -> 33,080 |

Single-build conversion is a wash (one extra struct allocation); the
quadratic term is gone.

Addresses the map-allocation half of PLAT-386 / #27205. Bounding the
page size is separate (#28040) and does not remove this cost: at 100
workspaces per page it is still 100 passes over every resource, agent,
app, script, log source, status, and daemon row in the page.

---

Created with Coder Agents on behalf of @jscottmiller.
@github-actions github-actions Bot added the stale This issue is like stale bread. label Aug 27, 2026
@github-actions github-actions Bot closed this Aug 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stale This issue is like stale bread.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant