feat!: bound page size on the workspaces list endpoint - #28040
Closed
jscottmiller wants to merge 7 commits into
Closed
feat!: bound page size on the workspaces list endpoint#28040jscottmiller wants to merge 7 commits into
jscottmiller wants to merge 7 commits into
Conversation
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.
Contributor
Docs previewCheck 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.
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.
GET /api/v2/workspacestreated an omitted or zerolimitas "no limit" and passed it to SQL asNULL. It now resolves an omittedlimitto 1000 and rejectslimit=0orlimit>1000with a400. Only this endpoint changes: the sharedcoderd.ParsePaginationis untouched, andParsePaginationBoundedis a second entry point used by the workspaces handler alone. Other list endpoints get bounded individually, later.Breaking: a client that omitted
limitto fetch every workspace now receives the first 1000.limit=0is 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:
convertWorkspacesdrops workspaces whose latest build or template the requester cannot read after the SQL limit is applied.len(page) < pageSizetruncates silently andoffset += 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 reachescount— is now in one helper per language (codersdk.Client.AllWorkspaces,API.getAllWorkspaces) rather than copied per call site.count.len(list)as a total is now approximate. Where that mattered it is either fixed (supportreports 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
cli/exp_scaletest.go:2228scaletest-workspaces by designscaletest/prebuilds/run.go:175,246useWorkspacesToBeDeleted.ts:13codersdk/toolsdk/chatgpt.go:72cli/list.go/cli/schedule.gowith--allsupport/support.goEverything 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-coderdis unaffected — it never lists workspaces. Worth noting for the next endpoint we bound:internal/provider/user_data_source.go:237-251pages/api/v2/userswithLimit: 100and stops onlen(page) < 100, so any future maximum on/usershas to be at least 100, and that loop truncates silently if/usersever drops rows after the SQL limit the way/workspacesdoes.Two editor clients list workspaces with no
limitand 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 sendslimittoday, but a limit-validation 400 must not be routed there.coder/coder-jetbrains-toolbox:CoderV2RestFacade.kt:35-38declares the call with only aqparameter 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
limitso the truncation is visible in their UI.Implementation
Server:
codersdk.WorkspacesPageLimit = 1000(also generated intotypesGenerated.ts),ParsePaginationBoundedused only by the workspaces handler,@Failure 400declared,WorkspacesResponse.Countdocumented as the pre-limit total that can exceed the rows returned, swagger anddocs/reference/api/workspaces.mdregenerated. An out-of-rangelimitis rejected rather than clamped, with one message covering the whole range.Callers routed through the paging helpers:
cli/list.go(QueryConvertWorkspaces, socoder listandcoder schedule),cli/ssh.gocompletions,cli/configssh.go, bothcli/exp_scaletest.gohelpers,scaletest/prebuilds/run.go(duplicate local helper deleted), bothcodersdk/toolsdksites, and the frontend agents pages plususeWorkspacesToBeDeleted. Count-only callers (UsageIndicator,TemplatePageHeader,DisableWorkspaceSharingDialog) sendlimit: 1.supportkeeps its own loop because it stops at--workspaces-total-capand 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:
getWorkspacesandgetAllWorkspacestake anAbortSignaland both list queries forward the one React Query provides, so navigating away stops the walk instead of leaving pages in flight.An omitted
limitdefaults rather than erroring because ~108client.Workspacescall 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.gouser paging, the undetectable truncation in/templates/{template}/acl/available, unboundedoffsetand deep-offset cost, and cursor (AfterID) paging. Recorded with the rest of the abandoned broad-bound attempt incoder/scott-misc,notes/PLAT-386-pagination-dead-ends.md.Tests:
TestPaginationBounded, endpoint-levelTestWorkspacesPageLimit,TestAllWorkspaces(offset progression, short pages, repeated rows, empty results, caller pagination overridden),TestDeploymentInfoWorkspacesIncompleteandTestDeploymentInfoWorkspacesRepeatedinsupport, andgetAllWorkspacesvitest 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.AllWorkspacesandgetAllWorkspacestrack 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.AfterIDexists butGetWorkspacesnever implements it. Addingidas 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,238useslen()over a multi-page walk as a progress denominator, andcli/exp_scaletest.go:462indexes 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.
useWorkspacesToGoDormantanduseWorkspacesToBeDeletedpreview 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:dormantis a plain boolean whosefalsevalue 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 ondormant_at. That needs a nullabledormantplus adormant_beforefilter, which is out of scope here.Unrelated to this change but adjacent:
TemplateScheduleForm.tsx:131-143,213-219treats anundefinedhook 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.