From d46190ca096815f383d440a4837185dd4e17865b Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Tue, 11 Aug 2026 20:59:04 +0000 Subject: [PATCH 1/7] feat!: bound page size on the workspaces list endpoint 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. --- cli/configssh.go | 26 ++--- cli/exp_scaletest.go | 72 ++++-------- cli/list.go | 6 +- cli/ssh.go | 4 +- coderd/apidoc/docs.go | 2 +- coderd/apidoc/swagger.json | 2 +- coderd/pagination.go | 37 ++++++ coderd/pagination_test.go | 85 ++++++++++++++ coderd/workspaces.go | 4 +- coderd/workspaces_test.go | 29 +++++ codersdk/pagination.go | 6 + codersdk/toolsdk/chatgpt.go | 6 +- codersdk/toolsdk/toolsdk.go | 6 +- codersdk/workspaces.go | 25 ++++ codersdk/workspaces_test.go | 107 ++++++++++++++++++ docs/reference/api/workspaces.md | 2 +- scaletest/prebuilds/run.go | 46 +++----- site/src/api/api.test.ts | 59 ++++++++++ site/src/api/api.ts | 26 +++++ site/src/api/queries/workspaces.ts | 18 +++ site/src/api/typesGenerated.ts | 9 ++ site/src/pages/AgentsPage/AgentChatPage.tsx | 4 +- site/src/pages/AgentsPage/AgentCreatePage.tsx | 4 +- .../components/UsageIndicator.stories.tsx | 2 +- .../AgentsPage/components/UsageIndicator.tsx | 3 +- .../DisableWorkspaceSharingDialog.tsx | 3 +- .../TemplatePageHeader.stories.tsx | 2 + .../pages/TemplatePage/TemplatePageHeader.tsx | 3 +- .../useWorkspacesToBeDeleted.ts | 6 +- support/support.go | 15 ++- 30 files changed, 490 insertions(+), 129 deletions(-) diff --git a/cli/configssh.go b/cli/configssh.go index 2164996c1ae2e..193d915d2f145 100644 --- a/cli/configssh.go +++ b/cli/configssh.go @@ -474,25 +474,13 @@ func (r *RootCmd) configSSH() *serpent.Command { if configOptions.noWildcard { // Fetch all workspaces to generate individual host entries. - var wsNames []string - offset := 0 - const pageSize = 100 - for { - res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ - Owner: codersdk.Me, - Offset: offset, - Limit: pageSize, - }) - if err != nil { - return xerrors.Errorf("fetch workspaces: %w", err) - } - for _, ws := range res.Workspaces { - wsNames = append(wsNames, ws.Name) - } - if len(res.Workspaces) < pageSize { - break - } - offset += pageSize + workspaces, err := client.AllWorkspaces(ctx, codersdk.WorkspaceFilter{Owner: codersdk.Me}) + if err != nil { + return xerrors.Errorf("fetch workspaces: %w", err) + } + wsNames := make([]string, 0, len(workspaces)) + for _, ws := range workspaces { + wsNames = append(wsNames, ws.Name) } configOptions.workspaceNames = wsNames } diff --git a/cli/exp_scaletest.go b/cli/exp_scaletest.go index c49a228a54d6d..e3d97ea07f5bf 100644 --- a/cli/exp_scaletest.go +++ b/cli/exp_scaletest.go @@ -551,8 +551,6 @@ func (r *prebuildTemplateCleanupRunner) Run(ctx context.Context, _ string, _ io. // caught in the cleanup. If template is non-empty only workspaces for that // template are returned. func getScaletestPrebuildWorkspaces(ctx context.Context, client *codersdk.Client, template string) ([]codersdk.Workspace, error) { - const pageSize = 100 - templates, err := getScaletestPrebuildsTemplates(ctx, client, template) if err != nil { return nil, xerrors.Errorf("list scaletest prebuild templates: %w", err) @@ -562,23 +560,16 @@ func getScaletestPrebuildWorkspaces(ctx context.Context, client *codersdk.Client var result []codersdk.Workspace for _, tmpl := range templates { - for page := 0; ; page++ { - resp, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ - Template: tmpl.Name, - Offset: page * pageSize, - Limit: pageSize, - }) - if err != nil { - return nil, xerrors.Errorf("list workspaces for template %q (page %d): %w", tmpl.Name, page, err) - } - for _, ws := range resp.Workspaces { - if _, ok := seen[ws.ID]; !ok { - seen[ws.ID] = struct{}{} - result = append(result, ws) - } - } - if len(resp.Workspaces) < pageSize { - break + workspaces, err := client.AllWorkspaces(ctx, codersdk.WorkspaceFilter{ + Template: tmpl.Name, + }) + if err != nil { + return nil, xerrors.Errorf("list workspaces for template %q: %w", tmpl.Name, err) + } + for _, ws := range workspaces { + if _, ok := seen[ws.ID]; !ok { + seen[ws.ID] = struct{}{} + result = append(result, ws) } } } @@ -2219,8 +2210,6 @@ func (r *runnableTraceWrapper) GetMetrics() map[string]any { func getScaletestWorkspaces(ctx context.Context, client *codersdk.Client, owner, template string) ([]codersdk.Workspace, int, error) { var ( - pageNumber = 0 - limit = 100 workspaces []codersdk.Workspace skipped int ) @@ -2236,35 +2225,24 @@ func getScaletestWorkspaces(ctx context.Context, client *codersdk.Client, owner, } noOwnerAccess := dv.Values != nil && dv.Values.DisableOwnerWorkspaceExec.Value() - for { - page, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ - Name: "scaletest-", - Template: template, - Owner: owner, - Offset: pageNumber * limit, - Limit: limit, - }) - if err != nil { - return nil, 0, xerrors.Errorf("fetch scaletest workspaces page %d: %w", pageNumber, err) - } + all, err := client.AllWorkspaces(ctx, codersdk.WorkspaceFilter{ + Name: "scaletest-", + Template: template, + Owner: owner, + }) + if err != nil { + return nil, 0, xerrors.Errorf("fetch scaletest workspaces: %w", err) + } - pageNumber++ - if len(page.Workspaces) == 0 { - break + for _, w := range all { + if !loadtestutil.IsScaleTestWorkspace(w.Name, w.OwnerName) { + continue } - - pageWorkspaces := make([]codersdk.Workspace, 0, len(page.Workspaces)) - for _, w := range page.Workspaces { - if !loadtestutil.IsScaleTestWorkspace(w.Name, w.OwnerName) { - continue - } - if noOwnerAccess && w.OwnerID != me.ID { - skipped++ - continue - } - pageWorkspaces = append(pageWorkspaces, w) + if noOwnerAccess && w.OwnerID != me.ID { + skipped++ + continue } - workspaces = append(workspaces, pageWorkspaces...) + workspaces = append(workspaces, w) } return workspaces, skipped, nil } diff --git a/cli/list.go b/cli/list.go index 8b4c56edbc53f..09e15f2e66310 100644 --- a/cli/list.go +++ b/cli/list.go @@ -168,12 +168,12 @@ func (r *RootCmd) list() *serpent.Command { // convert workspaces to scheduleListRow. func QueryConvertWorkspaces[T any](ctx context.Context, client *codersdk.Client, filter codersdk.WorkspaceFilter, convertF func(time.Time, codersdk.Workspace) T) ([]T, error) { var empty []T - workspaces, err := client.Workspaces(ctx, filter) + workspaces, err := client.AllWorkspaces(ctx, filter) if err != nil { return empty, xerrors.Errorf("query workspaces: %w", err) } - converted := make([]T, len(workspaces.Workspaces)) - for i, workspace := range workspaces.Workspaces { + converted := make([]T, len(workspaces)) + for i, workspace := range workspaces { converted[i] = convertF(time.Now(), workspace) } return converted, nil diff --git a/cli/ssh.go b/cli/ssh.go index d18ac8909f575..4fe13c3dfdd62 100644 --- a/cli/ssh.go +++ b/cli/ssh.go @@ -171,7 +171,7 @@ func (r *RootCmd) ssh() *serpent.Command { return []string{} } - res, err := client.Workspaces(inv.Context(), codersdk.WorkspaceFilter{ + workspaces, err := client.AllWorkspaces(inv.Context(), codersdk.WorkspaceFilter{ Owner: codersdk.Me, }) if err != nil { @@ -181,7 +181,7 @@ func (r *RootCmd) ssh() *serpent.Command { var mu sync.Mutex var completions []string var wg sync.WaitGroup - for _, ws := range res.Workspaces { + for _, ws := range workspaces { wg.Add(1) go func() { defer wg.Done() diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 526fe4b48fad7..1e6541695ca2a 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -13617,7 +13617,7 @@ const docTemplate = `{ }, { "type": "integer", - "description": "Page limit", + "description": "Page limit, from 1 to 100. Defaults to 100 when omitted.", "name": "limit", "in": "query" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index a009b6e708658..4f661c8335fb0 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -12083,7 +12083,7 @@ }, { "type": "integer", - "description": "Page limit", + "description": "Page limit, from 1 to 100. Defaults to 100 when omitted.", "name": "limit", "in": "query" }, diff --git a/coderd/pagination.go b/coderd/pagination.go index 011f8df9e7bd4..1e7168ae49f90 100644 --- a/coderd/pagination.go +++ b/coderd/pagination.go @@ -1,6 +1,7 @@ package coderd import ( + "fmt" "net/http" "github.com/google/uuid" @@ -32,3 +33,39 @@ func ParsePagination(w http.ResponseWriter, r *http.Request) (p codersdk.Paginat return params, true } + +// ParsePaginationBounded extracts pagination query params from the http request +// and resolves limit against maxLimit. An omitted limit resolves to maxLimit. A +// limit that is present must be an integer in [1, maxLimit]; anything else is +// rejected rather than clamped, so a caller never receives fewer rows than it +// asked for without being told. If an error is encountered, the error is written +// to w and ok is set to false. +func ParsePaginationBounded(w http.ResponseWriter, r *http.Request, maxLimit int32) (p codersdk.Pagination, ok bool) { + ctx := r.Context() + queryParams := r.URL.Query() + parser := httpapi.NewQueryParamParser() + params := codersdk.Pagination{ + AfterID: parser.UUID(queryParams, uuid.Nil, "after_id"), + Offset: int(parser.PositiveInt32(queryParams, 0, "offset")), + } + + limitErrs := len(parser.Errors) + params.Limit = int(parser.PositiveInt32(queryParams, maxLimit, "limit")) + limitParsed := len(parser.Errors) == limitErrs + if limitParsed && (params.Limit < 1 || params.Limit > int(maxLimit)) { + parser.Errors = append(parser.Errors, codersdk.ValidationError{ + Field: "limit", + Detail: fmt.Sprintf("Query param \"limit\" must be a positive integer no greater than %d.", maxLimit), + }) + } + + if len(parser.Errors) > 0 { + httpapi.Write(ctx, w, http.StatusBadRequest, codersdk.Response{ + Message: "Query parameters have invalid values.", + Validations: parser.Errors, + }) + return params, false + } + + return params, true +} diff --git a/coderd/pagination_test.go b/coderd/pagination_test.go index f6e1aab7067f4..09a413bac073d 100644 --- a/coderd/pagination_test.go +++ b/coderd/pagination_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" ) @@ -139,3 +140,87 @@ func TestPagination(t *testing.T) { }) } } + +func TestPaginationBounded(t *testing.T) { + t.Parallel() + const maxLimit = 100 + testCases := []struct { + Name string + + // Limit is omitted from the query when nil. + Limit *string + Offset string + + ExpectedError string + ExpectedParams codersdk.Pagination + }{ + { + Name: "OmittedLimitDefaultsToMax", + ExpectedParams: codersdk.Pagination{Limit: maxLimit}, + }, + { + Name: "MaxLimit", + Limit: ptr.Ref("100"), + ExpectedParams: codersdk.Pagination{Limit: maxLimit}, + }, + { + Name: "BelowMaxLimit", + Limit: ptr.Ref("25"), + Offset: "50", + ExpectedParams: codersdk.Pagination{Limit: 25, Offset: 50}, + }, + { + Name: "ZeroLimit", + Limit: ptr.Ref("0"), + ExpectedError: "must be a positive integer no greater than 100", + }, + { + Name: "AboveMaxLimit", + Limit: ptr.Ref("101"), + ExpectedError: "must be a positive integer no greater than 100", + }, + { + Name: "NegativeLimit", + Limit: ptr.Ref("-1"), + ExpectedError: "must be a valid 32-bit positive integer: value is negative", + }, + { + Name: "UnparseableLimit", + Limit: ptr.Ref("bogus"), + ExpectedError: "must be a valid 32-bit positive integer", + }, + } + + for _, c := range testCases { + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + rw := httptest.NewRecorder() + r, err := http.NewRequestWithContext(context.Background(), "GET", "https://example.com", nil) + require.NoError(t, err, "new request") + + query := r.URL.Query() + if c.Limit != nil { + query.Set("limit", *c.Limit) + } + if c.Offset != "" { + query.Set("offset", c.Offset) + } + r.URL.RawQuery = query.Encode() + + params, ok := coderd.ParsePaginationBounded(rw, r, maxLimit) + if c.ExpectedError == "" { + require.True(t, ok, "expect ok") + require.Equal(t, c.ExpectedParams, params, "expected params") + return + } + + require.False(t, ok, "expect !ok") + require.Equal(t, http.StatusBadRequest, rw.Code, "bad request status code") + var apiError codersdk.Error + require.NoError(t, json.NewDecoder(rw.Body).Decode(&apiError), "decode response") + require.Len(t, apiError.Validations, 1, "one validation error") + require.Equal(t, "limit", apiError.Validations[0].Field) + require.Contains(t, apiError.Validations[0].Detail, c.ExpectedError) + }) + } +} diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 21d11d88b7636..d6b637030e52f 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -144,7 +144,7 @@ func (api *API) workspace(rw http.ResponseWriter, r *http.Request) { // @Produce json // @Tags Workspaces // @Param q query string false "Search query in the format `key:value`. Available keys are: owner, template, name, status, has-agent, dormant, last_used_after, last_used_before, has-ai-task, has_external_agent, healthy, include_agent_metadata (expands each agent with the named metadata keys rather than filtering; repeat the key for multiple items)." -// @Param limit query int false "Page limit" +// @Param limit query int false "Page limit, from 1 to 100. Defaults to 100 when omitted." // @Param offset query int false "Page offset" // @Success 200 {object} codersdk.WorkspacesResponse // @Router /api/v2/workspaces [get] @@ -152,7 +152,7 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() apiKey := httpmw.APIKey(r) - page, ok := ParsePagination(rw, r) + page, ok := ParsePaginationBounded(rw, r, codersdk.WorkspacesPageLimit) if !ok { return } diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index fdf33461af1c9..22eb645abad4b 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -11,6 +11,7 @@ import ( "net/http/httptest" "regexp" "slices" + "strconv" "strings" "testing" "time" @@ -3329,6 +3330,34 @@ func TestOffsetLimit(t *testing.T) { require.Error(t, err) } +func TestWorkspacesPageLimit(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + // A limit outside [1, codersdk.WorkspacesPageLimit] is rejected rather than + // clamped. codersdk.Pagination omits a zero limit, so the query is built by + // hand. + for _, limit := range []string{"0", strconv.Itoa(codersdk.WorkspacesPageLimit + 1)} { + res, err := client.Request(ctx, http.MethodGet, "/api/v2/workspaces?limit="+limit, nil) + require.NoError(t, err) + _ = res.Body.Close() + require.Equal(t, http.StatusBadRequest, res.StatusCode) + } + + _, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{Limit: codersdk.WorkspacesPageLimit + 1}) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + require.Len(t, apiErr.Validations, 1) + require.Equal(t, "limit", apiErr.Validations[0].Field) + + // The maximum is accepted. + _, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{Limit: codersdk.WorkspacesPageLimit}) + require.NoError(t, err) +} + func TestWorkspaceUpdateAutostart(t *testing.T) { t.Parallel() dublinLoc := mustLocation(t, "Europe/Dublin") diff --git a/codersdk/pagination.go b/codersdk/pagination.go index 2201277aecaa8..f37a5536bcbd1 100644 --- a/codersdk/pagination.go +++ b/codersdk/pagination.go @@ -7,6 +7,12 @@ import ( "github.com/google/uuid" ) +// WorkspacesPageLimit is the largest page size the workspaces list endpoint +// accepts and the page size clients use when reading that endpoint to +// exhaustion. A request that omits limit receives this many rows; a limit of 0 +// or greater than this is rejected. +const WorkspacesPageLimit = 100 + // Pagination sets pagination options for the endpoints that support it. type Pagination struct { // AfterID returns all or up to Limit results after the given diff --git a/codersdk/toolsdk/chatgpt.go b/codersdk/toolsdk/chatgpt.go index 4761bb7b1fa0b..dec06e6677d3e 100644 --- a/codersdk/toolsdk/chatgpt.go +++ b/codersdk/toolsdk/chatgpt.go @@ -69,14 +69,14 @@ func searchTemplates(ctx context.Context, deps Deps, query string) ([]SearchResu func searchWorkspaces(ctx context.Context, deps Deps, query string) ([]SearchResultItem, error) { serverURL := deps.ServerURL() - workspaces, err := deps.coderClient.Workspaces(ctx, codersdk.WorkspaceFilter{ + workspaces, err := deps.coderClient.AllWorkspaces(ctx, codersdk.WorkspaceFilter{ FilterQuery: query, }) if err != nil { return nil, err } - results := make([]SearchResultItem, len(workspaces.Workspaces)) - for i, workspace := range workspaces.Workspaces { + results := make([]SearchResultItem, len(workspaces)) + for i, workspace := range workspaces { results[i] = SearchResultItem{ ID: createObjectID(ObjectTypeWorkspace, workspace.ID.String()).String(), Title: workspace.Name, diff --git a/codersdk/toolsdk/toolsdk.go b/codersdk/toolsdk/toolsdk.go index 81908820a6132..91390aa8b2c30 100644 --- a/codersdk/toolsdk/toolsdk.go +++ b/codersdk/toolsdk/toolsdk.go @@ -595,14 +595,14 @@ var ListWorkspaces = Tool[ListWorkspacesArgs, []MinimalWorkspace]{ if owner == "" { owner = codersdk.Me } - workspaces, err := deps.coderClient.Workspaces(ctx, codersdk.WorkspaceFilter{ + workspaces, err := deps.coderClient.AllWorkspaces(ctx, codersdk.WorkspaceFilter{ Owner: owner, }) if err != nil { return nil, err } - minimalWorkspaces := make([]MinimalWorkspace, len(workspaces.Workspaces)) - for i, workspace := range workspaces.Workspaces { + minimalWorkspaces := make([]MinimalWorkspace, len(workspaces)) + for i, workspace := range workspaces { minimalWorkspaces[i] = MinimalWorkspace{ ID: workspace.ID.String(), Name: workspace.Name, diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 6a78ecd7b364d..c1a7be70e3069 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -632,6 +632,31 @@ func (c *Client) Workspaces(ctx context.Context, filter WorkspaceFilter) (Worksp return wres, ReadBodyAsJSON(res, &wres) } +// AllWorkspaces requests successive pages of workspaces matching the filter and +// returns every row. Limit and Offset on the filter are ignored. +// +// The offset advances by the requested page size rather than by the number of +// rows received. The endpoint applies its limit in SQL and then drops workspaces +// whose latest build or template the caller cannot read, so a page shorter than +// the page size does not mean the result set is exhausted. Count is the total +// before the limit and offset are applied. +func (c *Client) AllWorkspaces(ctx context.Context, filter WorkspaceFilter) ([]Workspace, error) { + filter.Limit = WorkspacesPageLimit + filter.Offset = 0 + var all []Workspace + for { + page, err := c.Workspaces(ctx, filter) + if err != nil { + return nil, err + } + all = append(all, page.Workspaces...) + filter.Offset += WorkspacesPageLimit + if filter.Offset >= page.Count { + return all, nil + } + } +} + // WorkspaceByOwnerAndName returns a workspace by the owner's UUID and the workspace's name. func (c *Client) WorkspaceByOwnerAndName(ctx context.Context, owner string, name string, params WorkspaceOptions) (Workspace, error) { res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/workspace/%s", owner, name), nil, func(r *http.Request) { diff --git a/codersdk/workspaces_test.go b/codersdk/workspaces_test.go index 63cb99e06241c..734cbae3877a6 100644 --- a/codersdk/workspaces_test.go +++ b/codersdk/workspaces_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "strconv" "sync/atomic" "testing" @@ -310,3 +311,109 @@ func TestResolveWorkspace(t *testing.T) { require.EqualValues(t, 0, hits.Load(), "invalid identifiers should fail before any HTTP request") }) } + +func TestAllWorkspaces(t *testing.T) { + t.Parallel() + + // newClient serves pages of the given sizes, recording the limit and offset + // of every request. count is reported as the total on every response. + newClient := func(t *testing.T, count int, pageSizes ...int) (*codersdk.Client, *[][2]int) { + t.Helper() + var requests [][2]int + page := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) + requests = append(requests, [2]int{limit, offset}) + + rows := 0 + if page < len(pageSizes) { + rows = pageSizes[page] + } + page++ + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(codersdk.WorkspacesResponse{ + Workspaces: make([]codersdk.Workspace, rows), + Count: count, + }) + })) + t.Cleanup(srv.Close) + + u, err := url.Parse(srv.URL) + require.NoError(t, err) + return codersdk.New(u), &requests + } + + t.Run("SinglePage", func(t *testing.T) { + t.Parallel() + + client, requests := newClient(t, 3, 3) + ctx := testutil.Context(t, testutil.WaitShort) + + workspaces, err := client.AllWorkspaces(ctx, codersdk.WorkspaceFilter{}) + require.NoError(t, err) + require.Len(t, workspaces, 3) + require.Equal(t, [][2]int{{codersdk.WorkspacesPageLimit, 0}}, *requests) + }) + + t.Run("AdvancesByPageSize", func(t *testing.T) { + t.Parallel() + + const count = codersdk.WorkspacesPageLimit*2 + 10 + client, requests := newClient(t, count, + codersdk.WorkspacesPageLimit, codersdk.WorkspacesPageLimit, 10) + ctx := testutil.Context(t, testutil.WaitShort) + + workspaces, err := client.AllWorkspaces(ctx, codersdk.WorkspaceFilter{}) + require.NoError(t, err) + require.Len(t, workspaces, count) + require.Equal(t, [][2]int{ + {codersdk.WorkspacesPageLimit, 0}, + {codersdk.WorkspacesPageLimit, codersdk.WorkspacesPageLimit}, + {codersdk.WorkspacesPageLimit, codersdk.WorkspacesPageLimit * 2}, + }, *requests) + }) + + // A page can be shorter than the requested limit because the endpoint drops + // rows after applying the limit, so a short page must not end the scan. + t.Run("ShortPageContinues", func(t *testing.T) { + t.Parallel() + + const count = codersdk.WorkspacesPageLimit + 20 + client, requests := newClient(t, count, 40, 20) + ctx := testutil.Context(t, testutil.WaitShort) + + workspaces, err := client.AllWorkspaces(ctx, codersdk.WorkspaceFilter{}) + require.NoError(t, err) + require.Len(t, workspaces, 60) + require.Len(t, *requests, 2) + }) + + t.Run("EmptyResult", func(t *testing.T) { + t.Parallel() + + client, requests := newClient(t, 0, 0) + ctx := testutil.Context(t, testutil.WaitShort) + + workspaces, err := client.AllWorkspaces(ctx, codersdk.WorkspaceFilter{}) + require.NoError(t, err) + require.Empty(t, workspaces) + require.Len(t, *requests, 1) + }) + + t.Run("OverridesCallerPagination", func(t *testing.T) { + t.Parallel() + + client, requests := newClient(t, 1, 1) + ctx := testutil.Context(t, testutil.WaitShort) + + _, err := client.AllWorkspaces(ctx, codersdk.WorkspaceFilter{ + Limit: codersdk.WorkspacesPageLimit + 50, + Offset: 500, + }) + require.NoError(t, err) + require.Equal(t, [][2]int{{codersdk.WorkspacesPageLimit, 0}}, *requests) + }) +} diff --git a/docs/reference/api/workspaces.md b/docs/reference/api/workspaces.md index 7a22c111e5e5f..6b0c80ecbfa40 100644 --- a/docs/reference/api/workspaces.md +++ b/docs/reference/api/workspaces.md @@ -1107,7 +1107,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \ | Name | In | Type | Required | Description | |----------|-------|---------|----------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `q` | query | string | false | Search query in the format `key:value`. Available keys are: owner, template, name, status, has-agent, dormant, last_used_after, last_used_before, has-ai-task, has_external_agent, healthy, include_agent_metadata (expands each agent with the named metadata keys rather than filtering; repeat the key for multiple items). | -| `limit` | query | integer | false | Page limit | +| `limit` | query | integer | false | Page limit, from 1 to 100. Defaults to 100 when omitted. | | `offset` | query | integer | false | Page offset | ### Example responses diff --git a/scaletest/prebuilds/run.go b/scaletest/prebuilds/run.go index 59c902a90b2e1..c04e2738c5434 100644 --- a/scaletest/prebuilds/run.go +++ b/scaletest/prebuilds/run.go @@ -172,19 +172,19 @@ func (r *Runner) measureCreation(ctx context.Context, logger slog.Logger) error defer cancel() tkr := r.cfg.Clock.TickerFunc(workspacesCtx, workspacePollInterval, func() error { - workspaces, err := r.client.Workspaces(workspacesCtx, codersdk.WorkspaceFilter{ + workspaces, err := r.client.AllWorkspaces(workspacesCtx, codersdk.WorkspaceFilter{ Template: r.template.Name, }) if err != nil { return xerrors.Errorf("list workspaces: %w", err) } - createdCount := len(workspaces.Workspaces) + createdCount := len(workspaces) runningCount := 0 failedCount := 0 succeededCount := 0 - for _, ws := range workspaces.Workspaces { + for _, ws := range workspaces { switch ws.LatestBuild.Job.Status { case codersdk.ProvisionerJobRunning: runningCount++ @@ -228,13 +228,13 @@ func (r *Runner) measureDeletion(ctx context.Context, logger slog.Logger) error // The reconciler may have created extra workspaces beyond the configured // target (e.g. replacements for failed builds), so using targetNumWorkspaces // as the denominator would undercount completed deletions. - initialWorkspaces, err := r.client.Workspaces(deletionCtx, codersdk.WorkspaceFilter{ + initialWorkspaces, err := r.client.AllWorkspaces(deletionCtx, codersdk.WorkspaceFilter{ Template: r.template.Name, }) if err != nil { return xerrors.Errorf("list workspaces at deletion start: %w", err) } - initialWorkspaceCount := len(initialWorkspaces.Workspaces) + initialWorkspaceCount := len(initialWorkspaces) // retryCount tracks how many delete builds we've submitted per workspace. // lastRetriedBuildID prevents submitting a second retry for the same failed @@ -243,7 +243,7 @@ func (r *Runner) measureDeletion(ctx context.Context, logger slog.Logger) error lastRetriedBuildID := make(map[uuid.UUID]uuid.UUID) tkr := r.cfg.Clock.TickerFunc(deletionCtx, workspacePollInterval, func() error { - workspaces, err := r.client.Workspaces(deletionCtx, codersdk.WorkspaceFilter{ + workspaces, err := r.client.AllWorkspaces(deletionCtx, codersdk.WorkspaceFilter{ Template: r.template.Name, }) if err != nil { @@ -255,7 +255,7 @@ func (r *Runner) measureDeletion(ctx context.Context, logger slog.Logger) error failedCount := 0 exhaustedCount := 0 - for _, ws := range workspaces.Workspaces { + for _, ws := range workspaces { if ws.LatestBuild.Transition != codersdk.WorkspaceTransitionDelete { // The reconciler hasn't submitted a delete build yet. continue @@ -298,7 +298,7 @@ func (r *Runner) measureDeletion(ctx context.Context, logger slog.Logger) error } } - completedCount := initialWorkspaceCount - len(workspaces.Workspaces) + completedCount := initialWorkspaceCount - len(workspaces) createdCount += completedCount r.cfg.Metrics.SetDeletionJobsCreated(createdCount, r.template.Name) @@ -306,13 +306,13 @@ func (r *Runner) measureDeletion(ctx context.Context, logger slog.Logger) error r.cfg.Metrics.SetDeletionJobsFailed(failedCount, r.template.Name) r.cfg.Metrics.SetDeletionJobsCompleted(completedCount, r.template.Name) - if len(workspaces.Workspaces) == 0 { + if len(workspaces) == 0 { return errTickerDone } // If every remaining workspace has exhausted all retries, fail // immediately rather than waiting for the timeout. - if exhaustedCount > 0 && exhaustedCount == len(workspaces.Workspaces) { + if exhaustedCount > 0 && exhaustedCount == len(workspaces) { return xerrors.Errorf("%d workspace(s) failed to delete after %d attempts", exhaustedCount, maxDeletionRetries+1) } @@ -408,7 +408,9 @@ func (r *Runner) Cleanup(ctx context.Context, _ string, logs io.Writer) error { } // Workspaces must be deleted before the template can be deleted. - workspaces, err := allWorkspacesForTemplate(ctx, r.client, r.template.Name) + workspaces, err := r.client.AllWorkspaces(ctx, codersdk.WorkspaceFilter{ + Template: r.template.Name, + }) if err != nil { return xerrors.Errorf("list workspaces for template %q: %w", r.template.Name, err) } @@ -461,28 +463,6 @@ func (r *Runner) Cleanup(ctx context.Context, _ string, logs io.Writer) error { return nil } -// allWorkspacesForTemplate returns all workspaces belonging to templateName, -// paginating through results until exhausted. -func allWorkspacesForTemplate(ctx context.Context, client *codersdk.Client, templateName string) ([]codersdk.Workspace, error) { - const pageSize = 100 - var workspaces []codersdk.Workspace - for page := 0; ; page++ { - resp, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ - Template: templateName, - Offset: page * pageSize, - Limit: pageSize, - }) - if err != nil { - return nil, xerrors.Errorf("list workspaces page %d: %w", page, err) - } - workspaces = append(workspaces, resp.Workspaces...) - if len(resp.Workspaces) < pageSize { - break - } - } - return workspaces, nil -} - //go:embed tf/main.tf.tpl var templateContent string diff --git a/site/src/api/api.test.ts b/site/src/api/api.test.ts index f832533a53bf3..ea46018edc0d1 100644 --- a/site/src/api/api.test.ts +++ b/site/src/api/api.test.ts @@ -8,6 +8,7 @@ import { } from "#/testHelpers/entities"; import { API, getURLWithSearchParams, ParameterValidationError } from "./api"; import type * as TypesGen from "./typesGenerated"; +import { WorkspacesPageLimit } from "./typesGenerated"; const axiosInstance = API.getAxiosInstance(); @@ -660,3 +661,61 @@ describe("api.ts", () => { }); }); }); + +describe("getAllWorkspaces", () => { + const workspacePage = (count: number, rows: number) => ({ + workspaces: Array.from({ length: rows }, (_, i) => ({ + ...MockWorkspace, + id: `ws-${i}`, + })), + count, + }); + + it("issues a single request when the total fits in one page", async () => { + const page = workspacePage(3, 3); + const getWorkspaces = vi + .spyOn(API, "getWorkspaces") + .mockResolvedValueOnce(page); + + const result = await API.getAllWorkspaces({ q: "owner:me" }); + + expect(getWorkspaces).toHaveBeenCalledTimes(1); + expect(getWorkspaces).toHaveBeenCalledWith({ + q: "owner:me", + limit: WorkspacesPageLimit, + offset: 0, + }); + expect(result).toStrictEqual(page); + }); + + it("advances the offset by the page size until the total is reached", async () => { + const getWorkspaces = vi + .spyOn(API, "getWorkspaces") + .mockResolvedValueOnce(workspacePage(250, WorkspacesPageLimit)) + .mockResolvedValueOnce(workspacePage(250, WorkspacesPageLimit)) + .mockResolvedValueOnce(workspacePage(250, 50)); + + const result = await API.getAllWorkspaces(); + + expect(getWorkspaces.mock.calls.map(([req]) => req?.offset)).toStrictEqual([ + 0, + WorkspacesPageLimit, + WorkspacesPageLimit * 2, + ]); + expect(result.workspaces).toHaveLength(250); + expect(result.count).toBe(250); + }); + + it("keeps requesting pages when a page is shorter than the page size", async () => { + const getWorkspaces = vi + .spyOn(API, "getWorkspaces") + .mockResolvedValueOnce(workspacePage(150, 40)) + .mockResolvedValueOnce(workspacePage(150, 50)); + + const result = await API.getAllWorkspaces(); + + expect(getWorkspaces).toHaveBeenCalledTimes(2); + expect(result.workspaces).toHaveLength(90); + expect(result.count).toBe(150); + }); +}); diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 078da917beb1e..d057b134dc82f 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1259,6 +1259,32 @@ class ApiMethods { return response.data; }; + /** + * Requests successive pages of workspaces until the offset reaches the total + * the server reports. The offset advances by the page size rather than by the + * number of rows received: the endpoint applies its limit in SQL and then + * drops workspaces whose latest build or template the caller cannot read, so + * a short page does not mean the result set is exhausted. + */ + getAllWorkspaces = async ( + req: Omit = {}, + ): Promise => { + const workspaces: TypesGen.Workspace[] = []; + let count = 0; + let offset = 0; + do { + const page = await this.getWorkspaces({ + ...req, + limit: TypesGen.WorkspacesPageLimit, + offset, + }); + workspaces.push(...page.workspaces); + count = page.count; + offset += TypesGen.WorkspacesPageLimit; + } while (offset < count); + return { workspaces, count }; + }; + getWorkspaceByOwnerAndName = async ( username: string, workspaceName: string, diff --git a/site/src/api/queries/workspaces.ts b/site/src/api/queries/workspaces.ts index 0b89fd5f1f77f..7a306b424f26b 100644 --- a/site/src/api/queries/workspaces.ts +++ b/site/src/api/queries/workspaces.ts @@ -229,6 +229,24 @@ export function workspaces(req: WorkspacesRequest = {}) { } as const satisfies QueryOptions; } +type AllWorkspacesRequest = Omit; + +export function allWorkspacesKey(req: AllWorkspacesRequest = {}) { + // The `all` marker keeps this distinct from the single-page key for the same + // filter. The key stays two segments long with an object at the end so it is + // still matched by invalidateWorkspaceListQueries. + return [...workspacesQueryKeyPrefix, { ...req, all: true }] as const; +} + +// allWorkspaces fetches every page of the filtered result set. Prefer a +// server-side filter and a single page when the caller can express one. +export function allWorkspaces(req: AllWorkspacesRequest = {}) { + return { + queryKey: allWorkspacesKey(req), + queryFn: () => API.getAllWorkspaces(req), + } as const satisfies QueryOptions; +} + const isWorkspacesListQuery = (query: { queryKey: readonly unknown[]; }): boolean => { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 0222095336d24..13b3faf49c30b 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -11499,6 +11499,15 @@ export interface WorkspaceUser extends MinimalUser { readonly role: WorkspaceRole; } +// From codersdk/pagination.go +/** + * WorkspacesPageLimit is the largest page size the workspaces list endpoint + * accepts and the page size clients use when reading that endpoint to + * exhaustion. A request that omits limit receives this many rows; a limit of 0 + * or greater than this is rejected. + */ +export const WorkspacesPageLimit = 100; + // From codersdk/workspaces.go export interface WorkspacesRequest extends Pagination { readonly q?: string; diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 4fd0b0d09a34b..3b4f5e9008ba0 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -52,9 +52,9 @@ import { deploymentSSHConfig } from "#/api/queries/deployment"; import { userSkills } from "#/api/queries/userSkills"; import { preferenceSettings } from "#/api/queries/users"; import { + allWorkspaces, workspaceById, workspaceByIdKey, - workspaces, } from "#/api/queries/workspaces"; import type * as TypesGen from "#/api/typesGenerated"; import type { ChatMessagePart } from "#/api/typesGenerated"; @@ -949,7 +949,7 @@ const AgentChatPage: FC = () => { const preferencesQuery = useQuery(preferenceSettings()); const userDebugLoggingQuery = useQuery(userChatDebugLogging()); const mcpServersQuery = useQuery(mcpServerConfigs()); - const workspacesQuery = useQuery(workspaces({ q: "owner:me", limit: 0 })); + const workspacesQuery = useQuery(allWorkspaces({ q: "owner:me" })); const workspaceOptions = getWorkspaceOptionsWithLinkedWorkspace( workspacesQuery.data?.workspaces ?? [], workspace, diff --git a/site/src/pages/AgentsPage/AgentCreatePage.tsx b/site/src/pages/AgentsPage/AgentCreatePage.tsx index 38f019e9cd867..10afbf4775a29 100644 --- a/site/src/pages/AgentsPage/AgentCreatePage.tsx +++ b/site/src/pages/AgentsPage/AgentCreatePage.tsx @@ -13,7 +13,7 @@ import { userChatProviderConfigs, } from "#/api/queries/chats"; import { preferenceSettings } from "#/api/queries/users"; -import { workspaces } from "#/api/queries/workspaces"; +import { allWorkspaces } from "#/api/queries/workspaces"; import type * as TypesGen from "#/api/typesGenerated"; import { useWebpushNotifications } from "#/contexts/useWebpushNotifications"; import { useAuthenticated } from "#/hooks/useAuthenticated"; @@ -55,7 +55,7 @@ const AgentCreatePage: FC = () => { ); const preferencesQuery = useQuery(preferenceSettings()); const mcpServersQuery = useQuery(mcpServerConfigs()); - const workspacesQuery = useQuery(workspaces({ q: "owner:me", limit: 0 })); + const workspacesQuery = useQuery(allWorkspaces({ q: "owner:me" })); const createMutation = useMutation(createChat(queryClient)); const webPush = useWebpushNotifications(); const [chimeEnabled, setChimeEnabledState] = useState(getChimeEnabled); diff --git a/site/src/pages/AgentsPage/components/UsageIndicator.stories.tsx b/site/src/pages/AgentsPage/components/UsageIndicator.stories.tsx index 2b449c7e4163a..51c26809cc654 100644 --- a/site/src/pages/AgentsPage/components/UsageIndicator.stories.tsx +++ b/site/src/pages/AgentsPage/components/UsageIndicator.stories.tsx @@ -99,7 +99,7 @@ const noBudgetStatus = aiSpendStatus({ const userWorkspacesRequest = { q: `owner:me organization:${MockDefaultOrganization.name}`, - limit: 0, + limit: 1, }; const noWorkspaceQuota = { credits_consumed: 0, diff --git a/site/src/pages/AgentsPage/components/UsageIndicator.tsx b/site/src/pages/AgentsPage/components/UsageIndicator.tsx index 872e20b76a1ff..7086a83fe4bf0 100644 --- a/site/src/pages/AgentsPage/components/UsageIndicator.tsx +++ b/site/src/pages/AgentsPage/components/UsageIndicator.tsx @@ -69,7 +69,8 @@ export const UsageIndicator: FC = () => { const workspacesQuery = useQuery({ ...workspaces({ q: `owner:me organization:${organizationName}`, - limit: 0, + // Only the total is read, so a single row is enough. + limit: 1, }), enabled: hasWorkspaceQuotaUsage && organizationName !== "", }); diff --git a/site/src/pages/OrganizationSettingsPage/DisableWorkspaceSharingDialog.tsx b/site/src/pages/OrganizationSettingsPage/DisableWorkspaceSharingDialog.tsx index b95b9aea1d1c7..290011f7d48de 100644 --- a/site/src/pages/OrganizationSettingsPage/DisableWorkspaceSharingDialog.tsx +++ b/site/src/pages/OrganizationSettingsPage/DisableWorkspaceSharingDialog.tsx @@ -39,7 +39,8 @@ export const DisableWorkspaceSharingDialog: FC< queryFn: async () => { const response = await API.getWorkspaces({ q: `organization:${organizationId} shared:true`, - limit: 0, // Avoid fetching workspaces as we only need the count. + // Only the total is read, so a single row is enough. + limit: 1, }); return response.count; }, diff --git a/site/src/pages/TemplatePage/TemplatePageHeader.stories.tsx b/site/src/pages/TemplatePage/TemplatePageHeader.stories.tsx index b3246423799be..2557f782a6dda 100644 --- a/site/src/pages/TemplatePage/TemplatePageHeader.stories.tsx +++ b/site/src/pages/TemplatePage/TemplatePageHeader.stories.tsx @@ -18,6 +18,7 @@ const meta: Meta = { { key: workspacesKey({ q: `organization:${MockTemplate.organization_name} template:${MockTemplate.name}`, + limit: 1, }), data: { workspaces: [], @@ -57,6 +58,7 @@ export const HasWorkspaces: Story = { { key: workspacesKey({ q: `organization:${MockTemplate.organization_name} template:${MockTemplate.name}`, + limit: 1, }), data: { workspaces: [MockWorkspace], diff --git a/site/src/pages/TemplatePage/TemplatePageHeader.tsx b/site/src/pages/TemplatePage/TemplatePageHeader.tsx index 2caa17843f5e0..fe28a19a4a296 100644 --- a/site/src/pages/TemplatePage/TemplatePageHeader.tsx +++ b/site/src/pages/TemplatePage/TemplatePageHeader.tsx @@ -69,7 +69,8 @@ const TemplateMenu: FC = ({ const getLink = useLinks(); const queryText = `organization:${organizationName} template:${templateName}`; const workspaceCountQuery = useQuery({ - ...workspaces({ q: queryText }), + // Only the total is read, so a single row is enough. + ...workspaces({ q: queryText, limit: 1 }), select: (res) => res.count, }); const safeToDeleteTemplate = workspaceCountQuery.data === 0; diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/useWorkspacesToBeDeleted.ts b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/useWorkspacesToBeDeleted.ts index 4ef61dc79ed4f..f6c3567e65ba1 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/useWorkspacesToBeDeleted.ts +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/useWorkspacesToBeDeleted.ts @@ -1,6 +1,6 @@ import dayjs from "dayjs"; import { useQuery } from "react-query"; -import { workspaces } from "#/api/queries/workspaces"; +import { allWorkspaces } from "#/api/queries/workspaces"; import type { Template, Workspace } from "#/api/typesGenerated"; import type { TemplateScheduleFormValues } from "./formHelpers"; @@ -10,7 +10,7 @@ export const useWorkspacesToGoDormant = ( fromDate: Date, ) => { const { data } = useQuery( - workspaces({ + allWorkspaces({ q: `template:${template.name}`, }), ); @@ -44,7 +44,7 @@ export const useWorkspacesToBeDeleted = ( fromDate: Date, ) => { const { data } = useQuery( - workspaces({ + allWorkspaces({ q: `template:${template.name} dormant:true`, }), ); diff --git a/support/support.go b/support/support.go index 40be6ddf62c51..816e5a5395802 100644 --- a/support/support.go +++ b/support/support.go @@ -266,11 +266,14 @@ func DeploymentInfo(ctx context.Context, client *codersdk.Client, log slog.Logge eg.Go(func() error { var ( offset int - limit = 200 + limit = codersdk.WorkspacesPageLimit all []codersdk.Workspace count int ) capTotal := workspacesCap + if capTotal > 0 && capTotal < limit { + limit = capTotal + } for { resp, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{Offset: offset, Limit: limit}) if err != nil { @@ -303,10 +306,16 @@ func DeploymentInfo(ctx context.Context, client *codersdk.Client, log slog.Logge } break } - if offset+len(resp.Workspaces) >= count || len(resp.Workspaces) == 0 { + // The offset advances by the requested limit rather than by the number + // of rows returned. The endpoint applies its limit in SQL and then + // drops rows whose latest build or template the caller cannot read, so + // advancing by len(resp.Workspaces) would re-request rows already + // collected. Count is the total before the limit and offset are + // applied. + offset += limit + if offset >= count { break } - offset += len(resp.Workspaces) } if d.Workspaces != nil { // Replace with aggregated list From 5ba9219bd4b422f204547cde78e97452d826fd2b Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Thu, 13 Aug 2026 19:50:43 +0000 Subject: [PATCH 2/7] fix(codersdk,site): skip repeated rows when paging workspaces 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. --- codersdk/workspaces.go | 20 ++++++++++++++-- codersdk/workspaces_test.go | 47 +++++++++++++++++++++++++++++++++++-- site/src/api/api.test.ts | 22 +++++++++++++++-- site/src/api/api.ts | 23 ++++++++++++++++-- 4 files changed, 104 insertions(+), 8 deletions(-) diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index c1a7be70e3069..1b68dfc882281 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -633,23 +633,39 @@ func (c *Client) Workspaces(ctx context.Context, filter WorkspaceFilter) (Worksp } // AllWorkspaces requests successive pages of workspaces matching the filter and -// returns every row. Limit and Offset on the filter are ignored. +// returns every row it receives, skipping rows it has already seen. Limit and +// Offset on the filter are ignored. // // The offset advances by the requested page size rather than by the number of // rows received. The endpoint applies its limit in SQL and then drops workspaces // whose latest build or template the caller cannot read, so a page shorter than // the page size does not mean the result set is exhausted. Count is the total // before the limit and offset are applied. +// +// The pages are separate requests, so the result is not a snapshot. The +// endpoint orders by whether the workspace is a favorite of the requester and +// whether its latest build is running, both of which change while the pages are +// being read. A row that moves later in that order is filtered out here by ID; +// a row that moves earlier can pass the current offset and be missed entirely. +// A caller that needs an exact result should narrow the filter until it fits in +// one page. func (c *Client) AllWorkspaces(ctx context.Context, filter WorkspaceFilter) ([]Workspace, error) { filter.Limit = WorkspacesPageLimit filter.Offset = 0 var all []Workspace + seen := make(map[uuid.UUID]struct{}) for { page, err := c.Workspaces(ctx, filter) if err != nil { return nil, err } - all = append(all, page.Workspaces...) + for _, workspace := range page.Workspaces { + if _, ok := seen[workspace.ID]; ok { + continue + } + seen[workspace.ID] = struct{}{} + all = append(all, workspace) + } filter.Offset += WorkspacesPageLimit if filter.Offset >= page.Count { return all, nil diff --git a/codersdk/workspaces_test.go b/codersdk/workspaces_test.go index 734cbae3877a6..9cead348dd4d1 100644 --- a/codersdk/workspaces_test.go +++ b/codersdk/workspaces_test.go @@ -316,7 +316,8 @@ func TestAllWorkspaces(t *testing.T) { t.Parallel() // newClient serves pages of the given sizes, recording the limit and offset - // of every request. count is reported as the total on every response. + // of every request. count is reported as the total on every response, and + // every row across every page gets a distinct ID. newClient := func(t *testing.T, count int, pageSizes ...int) (*codersdk.Client, *[][2]int) { t.Helper() var requests [][2]int @@ -332,10 +333,15 @@ func TestAllWorkspaces(t *testing.T) { } page++ + workspaces := make([]codersdk.Workspace, rows) + for i := range workspaces { + workspaces[i].ID = uuid.New() + } + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(codersdk.WorkspacesResponse{ - Workspaces: make([]codersdk.Workspace, rows), + Workspaces: workspaces, Count: count, }) })) @@ -416,4 +422,41 @@ func TestAllWorkspaces(t *testing.T) { require.NoError(t, err) require.Equal(t, [][2]int{{codersdk.WorkspacesPageLimit, 0}}, *requests) }) + + // The order the endpoint applies depends on workspace state, so a row can + // move to a later page while the pages are being read and be returned twice. + t.Run("SkipsRepeatedRows", func(t *testing.T) { + t.Parallel() + + repeated := codersdk.Workspace{ID: uuid.New()} + unique := codersdk.Workspace{ID: uuid.New()} + pages := [][]codersdk.Workspace{ + {repeated}, + {repeated, unique}, + } + page := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var workspaces []codersdk.Workspace + if page < len(pages) { + workspaces = pages[page] + } + page++ + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(codersdk.WorkspacesResponse{ + Workspaces: workspaces, + Count: codersdk.WorkspacesPageLimit + 1, + }) + })) + t.Cleanup(srv.Close) + + u, err := url.Parse(srv.URL) + require.NoError(t, err) + ctx := testutil.Context(t, testutil.WaitShort) + + workspaces, err := codersdk.New(u).AllWorkspaces(ctx, codersdk.WorkspaceFilter{}) + require.NoError(t, err) + require.Equal(t, []codersdk.Workspace{repeated, unique}, workspaces) + }) } diff --git a/site/src/api/api.test.ts b/site/src/api/api.test.ts index ea46018edc0d1..9f53114515ea7 100644 --- a/site/src/api/api.test.ts +++ b/site/src/api/api.test.ts @@ -663,10 +663,14 @@ describe("api.ts", () => { }); describe("getAllWorkspaces", () => { + let nextWorkspaceID = 0; + + // Every row gets an ID distinct from every other row handed out by this + // helper, so pages never overlap unless a test builds them that way. const workspacePage = (count: number, rows: number) => ({ - workspaces: Array.from({ length: rows }, (_, i) => ({ + workspaces: Array.from({ length: rows }, () => ({ ...MockWorkspace, - id: `ws-${i}`, + id: `ws-${nextWorkspaceID++}`, })), count, }); @@ -718,4 +722,18 @@ describe("getAllWorkspaces", () => { expect(result.workspaces).toHaveLength(90); expect(result.count).toBe(150); }); + + // The order the endpoint applies depends on workspace state, so a row can + // move to a later page while the pages are being read and be returned twice. + it("skips a workspace an earlier page already returned", async () => { + const repeated = { ...MockWorkspace, id: "repeated" }; + const unique = { ...MockWorkspace, id: "unique" }; + vi.spyOn(API, "getWorkspaces") + .mockResolvedValueOnce({ workspaces: [repeated], count: 150 }) + .mockResolvedValueOnce({ workspaces: [repeated, unique], count: 150 }); + + const result = await API.getAllWorkspaces(); + + expect(result.workspaces).toStrictEqual([repeated, unique]); + }); }); diff --git a/site/src/api/api.ts b/site/src/api/api.ts index d057b134dc82f..0262f73a224ff 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1261,15 +1261,28 @@ class ApiMethods { /** * Requests successive pages of workspaces until the offset reaches the total - * the server reports. The offset advances by the page size rather than by the + * the server reports, skipping workspaces that were already returned by an + * earlier page. The offset advances by the page size rather than by the * number of rows received: the endpoint applies its limit in SQL and then * drops workspaces whose latest build or template the caller cannot read, so * a short page does not mean the result set is exhausted. + * + * The pages are separate requests, so the result is not a snapshot. The + * endpoint orders by whether the workspace is a favorite of the requester and + * whether its latest build is running, both of which change while the pages + * are being read. A workspace that moves later in that order is skipped here + * by ID; one that moves earlier can pass the current offset and be missed. + * Prefer a server-side filter that fits in one page when the result has to be + * exact. + * + * `count` is the total the server reported for the last page and can exceed + * the length of `workspaces`. */ getAllWorkspaces = async ( req: Omit = {}, ): Promise => { const workspaces: TypesGen.Workspace[] = []; + const seen = new Set(); let count = 0; let offset = 0; do { @@ -1278,7 +1291,13 @@ class ApiMethods { limit: TypesGen.WorkspacesPageLimit, offset, }); - workspaces.push(...page.workspaces); + for (const workspace of page.workspaces) { + if (seen.has(workspace.id)) { + continue; + } + seen.add(workspace.id); + workspaces.push(workspace); + } count = page.count; offset += TypesGen.WorkspacesPageLimit; } while (offset < count); From c9d332fcddb76b60d0b9db9844fc3d9003d462b5 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Thu, 13 Aug 2026 20:00:10 +0000 Subject: [PATCH 3/7] fix(site): cancel workspace list requests when the query is dropped 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. --- codersdk/workspaces.go | 4 ++++ site/src/api/api.test.ts | 36 +++++++++++++++++++++++++----- site/src/api/api.ts | 24 +++++++++++++++----- site/src/api/queries/workspaces.ts | 9 +++++--- 4 files changed, 59 insertions(+), 14 deletions(-) diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 1b68dfc882281..996ffdd989e78 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -649,6 +649,10 @@ func (c *Client) Workspaces(ctx context.Context, filter WorkspaceFilter) (Worksp // a row that moves earlier can pass the current offset and be missed entirely. // A caller that needs an exact result should narrow the filter until it fits in // one page. +// +// A page that fails ends the call; the pages already read are discarded rather +// than returned as a shorter list. The number of requests follows the total the +// endpoint reports, which is not bounded here, so cancel ctx to stop the walk. func (c *Client) AllWorkspaces(ctx context.Context, filter WorkspaceFilter) ([]Workspace, error) { filter.Limit = WorkspacesPageLimit filter.Offset = 0 diff --git a/site/src/api/api.test.ts b/site/src/api/api.test.ts index 9f53114515ea7..050d1c59edf4f 100644 --- a/site/src/api/api.test.ts +++ b/site/src/api/api.test.ts @@ -684,11 +684,14 @@ describe("getAllWorkspaces", () => { const result = await API.getAllWorkspaces({ q: "owner:me" }); expect(getWorkspaces).toHaveBeenCalledTimes(1); - expect(getWorkspaces).toHaveBeenCalledWith({ - q: "owner:me", - limit: WorkspacesPageLimit, - offset: 0, - }); + expect(getWorkspaces).toHaveBeenCalledWith( + { + q: "owner:me", + limit: WorkspacesPageLimit, + offset: 0, + }, + undefined, + ); expect(result).toStrictEqual(page); }); @@ -736,4 +739,27 @@ describe("getAllWorkspaces", () => { expect(result.workspaces).toStrictEqual([repeated, unique]); }); + + it("passes the abort signal to every page", async () => { + const controller = new AbortController(); + const getWorkspaces = vi + .spyOn(API, "getWorkspaces") + .mockResolvedValueOnce(workspacePage(150, WorkspacesPageLimit)) + .mockResolvedValueOnce(workspacePage(150, 50)); + + await API.getAllWorkspaces({ q: "owner:me" }, controller.signal); + + expect(getWorkspaces).toHaveBeenCalledTimes(2); + for (const [, signal] of getWorkspaces.mock.calls) { + expect(signal).toBe(controller.signal); + } + }); + + it("rejects without returning the pages it already read", async () => { + vi.spyOn(API, "getWorkspaces") + .mockResolvedValueOnce(workspacePage(150, WorkspacesPageLimit)) + .mockRejectedValueOnce(new Error("canceled")); + + await expect(API.getAllWorkspaces()).rejects.toThrow("canceled"); + }); }); diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 0262f73a224ff..cdbd3a6a4fec7 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1253,9 +1253,12 @@ class ApiMethods { getWorkspaces = async ( req: TypesGen.WorkspacesRequest, + signal?: AbortSignal, ): Promise => { const url = getURLWithSearchParams("/api/v2/workspaces", req); - const response = await this.axios.get(url); + const response = await this.axios.get(url, { + signal, + }); return response.data; }; @@ -1277,20 +1280,29 @@ class ApiMethods { * * `count` is the total the server reported for the last page and can exceed * the length of `workspaces`. + * + * A page that fails rejects the whole call; the pages already read are + * discarded rather than returned as a shorter list. Aborting `signal` rejects + * the page in flight and stops the walk. The number of requests follows the + * total the server reports, which is not bounded here. */ getAllWorkspaces = async ( req: Omit = {}, + signal?: AbortSignal, ): Promise => { const workspaces: TypesGen.Workspace[] = []; const seen = new Set(); let count = 0; let offset = 0; do { - const page = await this.getWorkspaces({ - ...req, - limit: TypesGen.WorkspacesPageLimit, - offset, - }); + const page = await this.getWorkspaces( + { + ...req, + limit: TypesGen.WorkspacesPageLimit, + offset, + }, + signal, + ); for (const workspace of page.workspaces) { if (seen.has(workspace.id)) { continue; diff --git a/site/src/api/queries/workspaces.ts b/site/src/api/queries/workspaces.ts index 7a306b424f26b..efdb01a535d32 100644 --- a/site/src/api/queries/workspaces.ts +++ b/site/src/api/queries/workspaces.ts @@ -225,7 +225,7 @@ export function workspacesKey(req: WorkspacesRequest = {}) { export function workspaces(req: WorkspacesRequest = {}) { return { queryKey: workspacesKey(req), - queryFn: () => API.getWorkspaces(req), + queryFn: ({ signal }) => API.getWorkspaces(req, signal), } as const satisfies QueryOptions; } @@ -239,11 +239,14 @@ export function allWorkspacesKey(req: AllWorkspacesRequest = {}) { } // allWorkspaces fetches every page of the filtered result set. Prefer a -// server-side filter and a single page when the caller can express one. +// server-side filter and a single page when the caller can express one: the +// request count follows the size of the result set, the pages are read one at a +// time, and a page that fails discards the ones already read. The query is +// cancelled on unmount, which stops the walk at the page in flight. export function allWorkspaces(req: AllWorkspacesRequest = {}) { return { queryKey: allWorkspacesKey(req), - queryFn: () => API.getAllWorkspaces(req), + queryFn: ({ signal }) => API.getAllWorkspaces(req, signal), } as const satisfies QueryOptions; } From 5cd1d8fcbd61887b4f8b967a4a357b77b7e60bcc Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Thu, 13 Aug 2026 20:22:18 +0000 Subject: [PATCH 4/7] docs(coderd,codersdk,site): describe the bounded workspace page 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. --- coderd/apidoc/docs.go | 7 +++++++ coderd/apidoc/swagger.json | 7 +++++++ coderd/pagination.go | 11 +++++------ coderd/pagination_test.go | 8 ++++---- coderd/workspaces.go | 1 + codersdk/pagination.go | 7 ++++--- codersdk/workspaces.go | 11 +++++++++-- docs/reference/api/schemas.md | 8 ++++---- docs/reference/api/workspaces.md | 7 ++++--- site/src/api/queries/workspaces.test.ts | 7 ++++++- site/src/api/queries/workspaces.ts | 5 ++++- site/src/api/typesGenerated.ts | 14 +++++++++++--- .../AgentsPage/components/AgentCreateForm.tsx | 11 +++++------ 13 files changed, 71 insertions(+), 33 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 1e6541695ca2a..3e03eb264a5cc 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -13634,6 +13634,12 @@ const docTemplate = `{ "schema": { "$ref": "#/definitions/codersdk.WorkspacesResponse" } + }, + "400": { + "description": "Invalid query parameters, including a limit outside 1 to 100", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } } }, "security": [ @@ -28726,6 +28732,7 @@ const docTemplate = `{ "type": "object", "properties": { "count": { + "description": "Count is the number of workspaces matching the filter before the limit and\noffset are applied. It can exceed the length of Workspaces for a reason\nother than the limit: the endpoint drops workspaces whose latest build or\ntemplate the requester cannot read after the limit is applied in SQL, so a\npage shorter than the limit does not mean the result set is exhausted.", "type": "integer" }, "workspaces": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 4f661c8335fb0..7f2979321ea2f 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -12100,6 +12100,12 @@ "schema": { "$ref": "#/definitions/codersdk.WorkspacesResponse" } + }, + "400": { + "description": "Invalid query parameters, including a limit outside 1 to 100", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } } }, "security": [ @@ -26476,6 +26482,7 @@ "type": "object", "properties": { "count": { + "description": "Count is the number of workspaces matching the filter before the limit and\noffset are applied. It can exceed the length of Workspaces for a reason\nother than the limit: the endpoint drops workspaces whose latest build or\ntemplate the requester cannot read after the limit is applied in SQL, so a\npage shorter than the limit does not mean the result set is exhausted.", "type": "integer" }, "workspaces": { diff --git a/coderd/pagination.go b/coderd/pagination.go index 1e7168ae49f90..90db39002d403 100644 --- a/coderd/pagination.go +++ b/coderd/pagination.go @@ -37,10 +37,9 @@ func ParsePagination(w http.ResponseWriter, r *http.Request) (p codersdk.Paginat // ParsePaginationBounded extracts pagination query params from the http request // and resolves limit against maxLimit. An omitted limit resolves to maxLimit. A // limit that is present must be an integer in [1, maxLimit]; anything else is -// rejected rather than clamped, so a caller never receives fewer rows than it -// asked for without being told. If an error is encountered, the error is written +// rejected rather than clamped. If an error is encountered, the error is written // to w and ok is set to false. -func ParsePaginationBounded(w http.ResponseWriter, r *http.Request, maxLimit int32) (p codersdk.Pagination, ok bool) { +func ParsePaginationBounded(w http.ResponseWriter, r *http.Request, maxLimit int) (p codersdk.Pagination, ok bool) { ctx := r.Context() queryParams := r.URL.Query() parser := httpapi.NewQueryParamParser() @@ -50,12 +49,12 @@ func ParsePaginationBounded(w http.ResponseWriter, r *http.Request, maxLimit int } limitErrs := len(parser.Errors) - params.Limit = int(parser.PositiveInt32(queryParams, maxLimit, "limit")) + params.Limit = parser.Int(queryParams, maxLimit, "limit") limitParsed := len(parser.Errors) == limitErrs - if limitParsed && (params.Limit < 1 || params.Limit > int(maxLimit)) { + if limitParsed && (params.Limit < 1 || params.Limit > maxLimit) { parser.Errors = append(parser.Errors, codersdk.ValidationError{ Field: "limit", - Detail: fmt.Sprintf("Query param \"limit\" must be a positive integer no greater than %d.", maxLimit), + Detail: fmt.Sprintf("Query param \"limit\" must be an integer between 1 and %d.", maxLimit), }) } diff --git a/coderd/pagination_test.go b/coderd/pagination_test.go index 09a413bac073d..3fd39a9867a63 100644 --- a/coderd/pagination_test.go +++ b/coderd/pagination_test.go @@ -172,22 +172,22 @@ func TestPaginationBounded(t *testing.T) { { Name: "ZeroLimit", Limit: ptr.Ref("0"), - ExpectedError: "must be a positive integer no greater than 100", + ExpectedError: "must be an integer between 1 and 100", }, { Name: "AboveMaxLimit", Limit: ptr.Ref("101"), - ExpectedError: "must be a positive integer no greater than 100", + ExpectedError: "must be an integer between 1 and 100", }, { Name: "NegativeLimit", Limit: ptr.Ref("-1"), - ExpectedError: "must be a valid 32-bit positive integer: value is negative", + ExpectedError: "must be an integer between 1 and 100", }, { Name: "UnparseableLimit", Limit: ptr.Ref("bogus"), - ExpectedError: "must be a valid 32-bit positive integer", + ExpectedError: "must be a valid integer", }, } diff --git a/coderd/workspaces.go b/coderd/workspaces.go index d6b637030e52f..4ce1b6e9d1a4a 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -147,6 +147,7 @@ func (api *API) workspace(rw http.ResponseWriter, r *http.Request) { // @Param limit query int false "Page limit, from 1 to 100. Defaults to 100 when omitted." // @Param offset query int false "Page offset" // @Success 200 {object} codersdk.WorkspacesResponse +// @Failure 400 {object} codersdk.Response "Invalid query parameters, including a limit outside 1 to 100" // @Router /api/v2/workspaces [get] func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() diff --git a/codersdk/pagination.go b/codersdk/pagination.go index f37a5536bcbd1..e6d1f772643c7 100644 --- a/codersdk/pagination.go +++ b/codersdk/pagination.go @@ -21,9 +21,10 @@ type Pagination struct { // set AfterID to the last UUID returned by the previous // request. AfterID uuid.UUID `json:"after_id,omitempty" format:"uuid"` - // Limit sets the maximum number of users to be returned - // in a single page. If the limit is <= 0, there is no limit - // and all users are returned. + // Limit sets the maximum number of records to be returned in a single + // page. Endpoints that bound their page size document the accepted range + // and reject a limit outside it. On an endpoint that does not, a limit + // <= 0 means no limit and every record is returned. Limit int `json:"limit,omitempty"` // Offset is used to indicate which page to return. An offset of 0 // returns the first 'limit' number of users. diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 996ffdd989e78..8dc0dea373787 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -96,7 +96,12 @@ type WorkspacesRequest struct { type WorkspacesResponse struct { Workspaces []Workspace `json:"workspaces"` - Count int `json:"count"` + // Count is the number of workspaces matching the filter before the limit and + // offset are applied. It can exceed the length of Workspaces for a reason + // other than the limit: the endpoint drops workspaces whose latest build or + // template the requester cannot read after the limit is applied in SQL, so a + // page shorter than the limit does not mean the result set is exhausted. + Count int `json:"count"` } type ProvisionerLogLevel string @@ -612,7 +617,9 @@ func (f WorkspaceFilter) asRequestOption() RequestOption { } } -// Workspaces returns all workspaces the authenticated user has access to. +// Workspaces returns a single page of the workspaces the authenticated user has +// access to. The endpoint bounds the page size, so an unset filter Limit does +// not return every workspace; see AllWorkspaces to read every page. func (c *Client) Workspaces(ctx context.Context, filter WorkspaceFilter) (WorkspacesResponse, error) { page := Pagination{ Offset: filter.Offset, diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 1e6dbe6e0eff7..50b6c014a7987 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -17657,10 +17657,10 @@ If the schedule is empty, the user will be updated to use the default schedule.| ### Properties -| Name | Type | Required | Restrictions | Description | -|--------------|---------------------------------------------------|----------|--------------|-------------| -| `count` | integer | false | | | -| `workspaces` | array of [codersdk.Workspace](#codersdkworkspace) | false | | | +| Name | Type | Required | Restrictions | Description | +|--------------|---------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `count` | integer | false | | Count is the number of workspaces matching the filter before the limit and offset are applied. It can exceed the length of Workspaces for a reason other than the limit: the endpoint drops workspaces whose latest build or template the requester cannot read after the limit is applied in SQL, so a page shorter than the limit does not mean the result set is exhausted. | +| `workspaces` | array of [codersdk.Workspace](#codersdkworkspace) | false | | | ## derp.BytesSentRecv diff --git a/docs/reference/api/workspaces.md b/docs/reference/api/workspaces.md index 6b0c80ecbfa40..10e8275e65e80 100644 --- a/docs/reference/api/workspaces.md +++ b/docs/reference/api/workspaces.md @@ -1385,9 +1385,10 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \ ### Responses -| Status | Meaning | Description | Schema | -|--------|---------------------------------------------------------|-------------|----------------------------------------------------------------------| -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.WorkspacesResponse](schemas.md#codersdkworkspacesresponse) | +| Status | Meaning | Description | Schema | +|--------|------------------------------------------------------------------|--------------------------------------------------------------|----------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.WorkspacesResponse](schemas.md#codersdkworkspacesresponse) | +| 400 | [Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1) | Invalid query parameters, including a limit outside 1 to 100 | [codersdk.Response](schemas.md#codersdkresponse) | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/site/src/api/queries/workspaces.test.ts b/site/src/api/queries/workspaces.test.ts index a4ef076b1a762..dba77b3c93896 100644 --- a/site/src/api/queries/workspaces.test.ts +++ b/site/src/api/queries/workspaces.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import type { WorkspacesResponse } from "#/api/typesGenerated"; import { getWorkspaceQuotaQueryKey } from "./workspaceQuota"; import { + allWorkspacesKey, autoCreateWorkspace, buildLogsKey, createWorkspace, @@ -38,6 +39,9 @@ const seedWorkspaceFamilyQueries = (queryClient: QueryClient) => { limit: 25, offset: 50, }); + const exhaustiveListKey = allWorkspacesKey({ + q: "owner:me organization:default", + }); const usageKey = workspaceUsage({ usageApp: "reconnecting-pty", connectionStatus: "connected", @@ -66,6 +70,7 @@ const seedWorkspaceFamilyQueries = (queryClient: QueryClient) => { queryClient.setQueryData(rawListKey, workspacesResponse); queryClient.setQueryData(defaultListKey, workspacesResponse); queryClient.setQueryData(filteredListKey, workspacesResponse); + queryClient.setQueryData(exhaustiveListKey, workspacesResponse); queryClient.setQueryData(usageKey, { tracked: true }); queryClient.setQueryData(buildLogs, []); queryClient.setQueryData(workspacePermissionsKey, { read: true }); @@ -73,7 +78,7 @@ const seedWorkspaceFamilyQueries = (queryClient: QueryClient) => { queryClient.setQueryData(organizationWorkspacePermissionsKey, { read: true }); return { - listKeys: [rawListKey, defaultListKey, filteredListKey], + listKeys: [rawListKey, defaultListKey, filteredListKey, exhaustiveListKey], nonListKeys: [ usageKey, buildLogs, diff --git a/site/src/api/queries/workspaces.ts b/site/src/api/queries/workspaces.ts index efdb01a535d32..7ad5392101250 100644 --- a/site/src/api/queries/workspaces.ts +++ b/site/src/api/queries/workspaces.ts @@ -229,7 +229,10 @@ export function workspaces(req: WorkspacesRequest = {}) { } as const satisfies QueryOptions; } -type AllWorkspacesRequest = Omit; +type AllWorkspacesRequest = Omit< + WorkspacesRequest, + "limit" | "offset" | "after_id" +>; export function allWorkspacesKey(req: AllWorkspacesRequest = {}) { // The `all` marker keeps this distinct from the single-page key for the same diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 13b3faf49c30b..1527b39a9eb7a 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -6984,9 +6984,10 @@ export interface Pagination { */ readonly after_id?: string; /** - * Limit sets the maximum number of users to be returned - * in a single page. If the limit is <= 0, there is no limit - * and all users are returned. + * Limit sets the maximum number of records to be returned in a single + * page. Endpoints that bound their page size document the accepted range + * and reject a limit outside it. On an endpoint that does not, a limit + * <= 0 means no limit and every record is returned. */ readonly limit?: number; /** @@ -11516,5 +11517,12 @@ export interface WorkspacesRequest extends Pagination { // From codersdk/workspaces.go export interface WorkspacesResponse { readonly workspaces: readonly Workspace[]; + /** + * Count is the number of workspaces matching the filter before the limit and + * offset are applied. It can exceed the length of Workspaces for a reason + * other than the limit: the endpoint drops workspaces whose latest build or + * template the requester cannot read after the limit is applied in SQL, so a + * page shorter than the limit does not mean the result set is exhausted. + */ readonly count: number; } diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index e70a0914eee57..55235e0546d82 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -380,13 +380,12 @@ export const AgentCreateForm: FC = ({ const isForbidden = !canCreateChat; // Filter workspaces by the selected organization. We use - // client-side filtering of the full "owner:me" fetch rather + // client-side filtering of the fetched "owner:me" list rather // than re-querying with an org filter because it avoids - // extra loading/error states on org change. The full list is - // already small (user's own workspaces) and limit: 0 - // guarantees completeness. If workspace counts grow large - // enough to warrant pagination, this should switch to a - // server-side organization: query filter. + // extra loading/error states on org change. The list is read + // a page at a time, so a user with many workspaces costs + // several requests; a server-side organization: filter + // would make it one. const filteredWorkspaces = showOrganizations && selectedOrg ? workspaceOptions.filter((ws) => ws.organization_id === selectedOrg.id) From 7a8400684d304c80b929b855478e98a0ec7b9764 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Thu, 13 Aug 2026 20:26:21 +0000 Subject: [PATCH 5/7] fix(support): keep the workspace page size independent of the total cap 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. --- support/support.go | 42 +++++++++++------- support/support_test.go | 97 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 15 deletions(-) diff --git a/support/support.go b/support/support.go index 816e5a5395802..03e001797a528 100644 --- a/support/support.go +++ b/support/support.go @@ -265,21 +265,20 @@ func DeploymentInfo(ctx context.Context, client *codersdk.Client, log slog.Logge // List workspaces (paginated) eg.Go(func() error { var ( - offset int - limit = codersdk.WorkspacesPageLimit - all []codersdk.Workspace - count int + offset int + all []codersdk.Workspace + seen = make(map[uuid.UUID]struct{}) + count int + incomplete bool ) capTotal := workspacesCap - if capTotal > 0 && capTotal < limit { - limit = capTotal - } for { - resp, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{Offset: offset, Limit: limit}) + resp, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{Offset: offset, Limit: codersdk.WorkspacesPageLimit}) if err != nil { // Log and continue if forbidden; otherwise return error if cerr, ok := codersdk.AsError(err); ok && (cerr.StatusCode() == http.StatusForbidden || cerr.StatusCode() == http.StatusUnauthorized) { - log.Warn(ctx, "unable to list workspaces") + log.Warn(ctx, "unable to list workspaces", slog.F("offset", offset)) + incomplete = true break } return xerrors.Errorf("list workspaces: %w", err) @@ -287,19 +286,26 @@ func DeploymentInfo(ctx context.Context, client *codersdk.Client, log slog.Logge if d.Workspaces == nil { d.Workspaces = &resp } - // sanitize env vars on agents in each workspace before appending - for i := range resp.Workspaces { - ws := &resp.Workspaces[i] + // The endpoint orders by whether the latest build is running, which + // changes between requests, so a workspace can move to a later page and + // be returned again. + for _, ws := range resp.Workspaces { + if _, ok := seen[ws.ID]; ok { + continue + } + seen[ws.ID] = struct{}{} for _, res := range ws.LatestBuild.Resources { for _, agt := range res.Agents { // safe to call even if map is nil (range in sanitizeEnv would be empty) sanitizeEnv(agt.EnvironmentVariables) } } + all = append(all, ws) } - all = append(all, resp.Workspaces...) count = resp.Count - // Stop early once we've reached the cap; trim any overflow from the last page. + // Stop early once we've reached the cap; trim any overflow from the last + // page. The cap limits how many workspaces are kept, not how many are + // requested per page. if capTotal > 0 && len(all) >= capTotal { if len(all) > capTotal { all = all[:capTotal] @@ -312,7 +318,7 @@ func DeploymentInfo(ctx context.Context, client *codersdk.Client, log slog.Logge // advancing by len(resp.Workspaces) would re-request rows already // collected. Count is the total before the limit and offset are // applied. - offset += limit + offset += codersdk.WorkspacesPageLimit if offset >= count { break } @@ -322,6 +328,12 @@ func DeploymentInfo(ctx context.Context, client *codersdk.Client, log slog.Logge d.Workspaces.Workspaces = all // Preserve server-reported total so Run() can log accurate truncation. d.Workspaces.Count = count + if incomplete { + // The scan stopped on a permissions error, so the rows collected are + // all there are to report. The server-reported total describes a set + // this list does not cover. + d.Workspaces.Count = len(all) + } } return nil }) diff --git a/support/support_test.go b/support/support_test.go index 35bfa556731b0..f8f2870e9947e 100644 --- a/support/support_test.go +++ b/support/support_test.go @@ -7,11 +7,14 @@ import ( "fmt" "io" "net/http" + "net/http/httptest" + "net/url" "os" "path/filepath" "testing" "time" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/goleak" @@ -334,3 +337,97 @@ func assertNotNilNotEmpty[T any](t *testing.T, v T, msg string) { assert.NotEmpty(t, v, msg+" but was empty") } } + +// TestDeploymentInfoWorkspacesIncomplete serves one full page and then denies +// the next one, which is what a token losing access mid-scan looks like. +func TestDeploymentInfoWorkspacesIncomplete(t *testing.T) { + t.Parallel() + + const serverTotal = codersdk.WorkspacesPageLimit * 2 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v2/workspaces" { + w.WriteHeader(http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + // The client omits a zero offset, so an absent value is the first page. + if offset := r.URL.Query().Get("offset"); offset != "" && offset != "0" { + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(codersdk.Response{Message: "forbidden"}) + return + } + + workspaces := make([]codersdk.Workspace, codersdk.WorkspacesPageLimit) + for i := range workspaces { + workspaces[i].ID = uuid.New() + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(codersdk.WorkspacesResponse{ + Workspaces: workspaces, + Count: serverTotal, + }) + })) + t.Cleanup(srv.Close) + + u, err := url.Parse(srv.URL) + require.NoError(t, err) + ctx := testutil.Context(t, testutil.WaitShort) + + d := support.DeploymentInfo(ctx, codersdk.New(u), + slog.Make(sloghuman.Sink(io.Discard)), 0) + + require.NotNil(t, d.Workspaces) + require.Len(t, d.Workspaces.Workspaces, codersdk.WorkspacesPageLimit) + // The count describes the rows in the bundle, not the larger set the server + // reported for a scan that did not finish. + require.Equal(t, codersdk.WorkspacesPageLimit, d.Workspaces.Count) +} + +// TestDeploymentInfoWorkspacesRepeated returns the same workspace on two pages, +// which is what a build finishing between requests looks like. +func TestDeploymentInfoWorkspacesRepeated(t *testing.T) { + t.Parallel() + + repeated := codersdk.Workspace{ID: uuid.New(), Name: "repeated"} + unique := codersdk.Workspace{ID: uuid.New(), Name: "unique"} + pages := [][]codersdk.Workspace{ + {repeated}, + {repeated, unique}, + } + var page int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v2/workspaces" { + w.WriteHeader(http.StatusNotFound) + return + } + + var workspaces []codersdk.Workspace + if page < len(pages) { + workspaces = pages[page] + } + page++ + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(codersdk.WorkspacesResponse{ + Workspaces: workspaces, + Count: codersdk.WorkspacesPageLimit + 1, + }) + })) + t.Cleanup(srv.Close) + + u, err := url.Parse(srv.URL) + require.NoError(t, err) + ctx := testutil.Context(t, testutil.WaitShort) + + d := support.DeploymentInfo(ctx, codersdk.New(u), + slog.Make(sloghuman.Sink(io.Discard)), 0) + + require.NotNil(t, d.Workspaces) + names := make([]string, 0, len(d.Workspaces.Workspaces)) + for _, ws := range d.Workspaces.Workspaces { + names = append(names, ws.Name) + } + require.Equal(t, []string{"repeated", "unique"}, names) +} From 296d032785d7209d433d1d043a24e8c714e34db9 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Thu, 13 Aug 2026 20:55:44 +0000 Subject: [PATCH 6/7] feat(codersdk): raise the workspace page limit to 1000 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. --- coderd/apidoc/docs.go | 4 ++-- coderd/apidoc/swagger.json | 4 ++-- coderd/workspaces.go | 4 ++-- codersdk/pagination.go | 6 +++++- docs/reference/api/workspaces.md | 10 +++++----- site/src/api/api.test.ts | 32 +++++++++++++++++++------------- site/src/api/typesGenerated.ts | 6 +++++- 7 files changed, 40 insertions(+), 26 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 3e03eb264a5cc..06d5c72e3814e 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -13617,7 +13617,7 @@ const docTemplate = `{ }, { "type": "integer", - "description": "Page limit, from 1 to 100. Defaults to 100 when omitted.", + "description": "Page limit, from 1 to 1000. Defaults to 1000 when omitted.", "name": "limit", "in": "query" }, @@ -13636,7 +13636,7 @@ const docTemplate = `{ } }, "400": { - "description": "Invalid query parameters, including a limit outside 1 to 100", + "description": "Invalid query parameters, including a limit outside 1 to 1000", "schema": { "$ref": "#/definitions/codersdk.Response" } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 7f2979321ea2f..21d573a9baab7 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -12083,7 +12083,7 @@ }, { "type": "integer", - "description": "Page limit, from 1 to 100. Defaults to 100 when omitted.", + "description": "Page limit, from 1 to 1000. Defaults to 1000 when omitted.", "name": "limit", "in": "query" }, @@ -12102,7 +12102,7 @@ } }, "400": { - "description": "Invalid query parameters, including a limit outside 1 to 100", + "description": "Invalid query parameters, including a limit outside 1 to 1000", "schema": { "$ref": "#/definitions/codersdk.Response" } diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 4ce1b6e9d1a4a..4cacb8c03c82d 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -144,10 +144,10 @@ func (api *API) workspace(rw http.ResponseWriter, r *http.Request) { // @Produce json // @Tags Workspaces // @Param q query string false "Search query in the format `key:value`. Available keys are: owner, template, name, status, has-agent, dormant, last_used_after, last_used_before, has-ai-task, has_external_agent, healthy, include_agent_metadata (expands each agent with the named metadata keys rather than filtering; repeat the key for multiple items)." -// @Param limit query int false "Page limit, from 1 to 100. Defaults to 100 when omitted." +// @Param limit query int false "Page limit, from 1 to 1000. Defaults to 1000 when omitted." // @Param offset query int false "Page offset" // @Success 200 {object} codersdk.WorkspacesResponse -// @Failure 400 {object} codersdk.Response "Invalid query parameters, including a limit outside 1 to 100" +// @Failure 400 {object} codersdk.Response "Invalid query parameters, including a limit outside 1 to 1000" // @Router /api/v2/workspaces [get] func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() diff --git a/codersdk/pagination.go b/codersdk/pagination.go index e6d1f772643c7..375a856d5f54d 100644 --- a/codersdk/pagination.go +++ b/codersdk/pagination.go @@ -11,7 +11,11 @@ import ( // accepts and the page size clients use when reading that endpoint to // exhaustion. A request that omits limit receives this many rows; a limit of 0 // or greater than this is rejected. -const WorkspacesPageLimit = 100 +// +// It is set well above the number of workspaces a deployment is expected to +// hold so that a caller reading the list still receives it in one response, and +// bounds the response for the cases that exceed it. +const WorkspacesPageLimit = 1000 // Pagination sets pagination options for the endpoints that support it. type Pagination struct { diff --git a/docs/reference/api/workspaces.md b/docs/reference/api/workspaces.md index 10e8275e65e80..2017fe60b9a0d 100644 --- a/docs/reference/api/workspaces.md +++ b/docs/reference/api/workspaces.md @@ -1107,7 +1107,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \ | Name | In | Type | Required | Description | |----------|-------|---------|----------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `q` | query | string | false | Search query in the format `key:value`. Available keys are: owner, template, name, status, has-agent, dormant, last_used_after, last_used_before, has-ai-task, has_external_agent, healthy, include_agent_metadata (expands each agent with the named metadata keys rather than filtering; repeat the key for multiple items). | -| `limit` | query | integer | false | Page limit, from 1 to 100. Defaults to 100 when omitted. | +| `limit` | query | integer | false | Page limit, from 1 to 1000. Defaults to 1000 when omitted. | | `offset` | query | integer | false | Page offset | ### Example responses @@ -1385,10 +1385,10 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \ ### Responses -| Status | Meaning | Description | Schema | -|--------|------------------------------------------------------------------|--------------------------------------------------------------|----------------------------------------------------------------------| -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.WorkspacesResponse](schemas.md#codersdkworkspacesresponse) | -| 400 | [Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1) | Invalid query parameters, including a limit outside 1 to 100 | [codersdk.Response](schemas.md#codersdkresponse) | +| Status | Meaning | Description | Schema | +|--------|------------------------------------------------------------------|---------------------------------------------------------------|----------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.WorkspacesResponse](schemas.md#codersdkworkspacesresponse) | +| 400 | [Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1) | Invalid query parameters, including a limit outside 1 to 1000 | [codersdk.Response](schemas.md#codersdkresponse) | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/site/src/api/api.test.ts b/site/src/api/api.test.ts index 050d1c59edf4f..5a4c83996f6c5 100644 --- a/site/src/api/api.test.ts +++ b/site/src/api/api.test.ts @@ -696,11 +696,12 @@ describe("getAllWorkspaces", () => { }); it("advances the offset by the page size until the total is reached", async () => { + const total = WorkspacesPageLimit * 2 + 10; const getWorkspaces = vi .spyOn(API, "getWorkspaces") - .mockResolvedValueOnce(workspacePage(250, WorkspacesPageLimit)) - .mockResolvedValueOnce(workspacePage(250, WorkspacesPageLimit)) - .mockResolvedValueOnce(workspacePage(250, 50)); + .mockResolvedValueOnce(workspacePage(total, WorkspacesPageLimit)) + .mockResolvedValueOnce(workspacePage(total, WorkspacesPageLimit)) + .mockResolvedValueOnce(workspacePage(total, 10)); const result = await API.getAllWorkspaces(); @@ -709,31 +710,33 @@ describe("getAllWorkspaces", () => { WorkspacesPageLimit, WorkspacesPageLimit * 2, ]); - expect(result.workspaces).toHaveLength(250); - expect(result.count).toBe(250); + expect(result.workspaces).toHaveLength(total); + expect(result.count).toBe(total); }); it("keeps requesting pages when a page is shorter than the page size", async () => { + const total = WorkspacesPageLimit + 20; const getWorkspaces = vi .spyOn(API, "getWorkspaces") - .mockResolvedValueOnce(workspacePage(150, 40)) - .mockResolvedValueOnce(workspacePage(150, 50)); + .mockResolvedValueOnce(workspacePage(total, 40)) + .mockResolvedValueOnce(workspacePage(total, 50)); const result = await API.getAllWorkspaces(); expect(getWorkspaces).toHaveBeenCalledTimes(2); expect(result.workspaces).toHaveLength(90); - expect(result.count).toBe(150); + expect(result.count).toBe(total); }); // The order the endpoint applies depends on workspace state, so a row can // move to a later page while the pages are being read and be returned twice. it("skips a workspace an earlier page already returned", async () => { + const total = WorkspacesPageLimit + 1; const repeated = { ...MockWorkspace, id: "repeated" }; const unique = { ...MockWorkspace, id: "unique" }; vi.spyOn(API, "getWorkspaces") - .mockResolvedValueOnce({ workspaces: [repeated], count: 150 }) - .mockResolvedValueOnce({ workspaces: [repeated, unique], count: 150 }); + .mockResolvedValueOnce({ workspaces: [repeated], count: total }) + .mockResolvedValueOnce({ workspaces: [repeated, unique], count: total }); const result = await API.getAllWorkspaces(); @@ -741,11 +744,12 @@ describe("getAllWorkspaces", () => { }); it("passes the abort signal to every page", async () => { + const total = WorkspacesPageLimit + 50; const controller = new AbortController(); const getWorkspaces = vi .spyOn(API, "getWorkspaces") - .mockResolvedValueOnce(workspacePage(150, WorkspacesPageLimit)) - .mockResolvedValueOnce(workspacePage(150, 50)); + .mockResolvedValueOnce(workspacePage(total, WorkspacesPageLimit)) + .mockResolvedValueOnce(workspacePage(total, 50)); await API.getAllWorkspaces({ q: "owner:me" }, controller.signal); @@ -757,7 +761,9 @@ describe("getAllWorkspaces", () => { it("rejects without returning the pages it already read", async () => { vi.spyOn(API, "getWorkspaces") - .mockResolvedValueOnce(workspacePage(150, WorkspacesPageLimit)) + .mockResolvedValueOnce( + workspacePage(WorkspacesPageLimit + 50, WorkspacesPageLimit), + ) .mockRejectedValueOnce(new Error("canceled")); await expect(API.getAllWorkspaces()).rejects.toThrow("canceled"); diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 1527b39a9eb7a..871ece26402b8 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -11506,8 +11506,12 @@ export interface WorkspaceUser extends MinimalUser { * accepts and the page size clients use when reading that endpoint to * exhaustion. A request that omits limit receives this many rows; a limit of 0 * or greater than this is rejected. + * + * It is set well above the number of workspaces a deployment is expected to + * hold so that a caller reading the list still receives it in one response, and + * bounds the response for the cases that exceed it. */ -export const WorkspacesPageLimit = 100; +export const WorkspacesPageLimit = 1000; // From codersdk/workspaces.go export interface WorkspacesRequest extends Pagination { From 8495603fed8e6453a7ef7238bf8da8893a9d5b69 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Thu, 13 Aug 2026 21:38:44 +0000 Subject: [PATCH 7/7] docs: trim the workspace pagination comments --- coderd/apidoc/docs.go | 2 +- coderd/apidoc/swagger.json | 2 +- codersdk/pagination.go | 10 ++---- codersdk/workspaces.go | 36 +++++++------------ docs/reference/api/schemas.md | 8 ++--- site/src/api/api.ts | 27 ++++++-------- site/src/api/queries/workspaces.ts | 12 +++---- site/src/api/typesGenerated.ts | 17 ++++----- .../AgentsPage/components/AgentCreateForm.tsx | 10 ++---- support/support.go | 17 ++++----- 10 files changed, 52 insertions(+), 89 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 06d5c72e3814e..22afa444e4b4d 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -28732,7 +28732,7 @@ const docTemplate = `{ "type": "object", "properties": { "count": { - "description": "Count is the number of workspaces matching the filter before the limit and\noffset are applied. It can exceed the length of Workspaces for a reason\nother than the limit: the endpoint drops workspaces whose latest build or\ntemplate the requester cannot read after the limit is applied in SQL, so a\npage shorter than the limit does not mean the result set is exhausted.", + "description": "Count is the number of workspaces matching the filter before the limit and\noffset are applied. Workspaces the requester cannot fully read are omitted\nfrom the page after the limit is applied, so a page shorter than the limit\ndoes not mean the result set is exhausted.", "type": "integer" }, "workspaces": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 21d573a9baab7..468f71567e8d6 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -26482,7 +26482,7 @@ "type": "object", "properties": { "count": { - "description": "Count is the number of workspaces matching the filter before the limit and\noffset are applied. It can exceed the length of Workspaces for a reason\nother than the limit: the endpoint drops workspaces whose latest build or\ntemplate the requester cannot read after the limit is applied in SQL, so a\npage shorter than the limit does not mean the result set is exhausted.", + "description": "Count is the number of workspaces matching the filter before the limit and\noffset are applied. Workspaces the requester cannot fully read are omitted\nfrom the page after the limit is applied, so a page shorter than the limit\ndoes not mean the result set is exhausted.", "type": "integer" }, "workspaces": { diff --git a/codersdk/pagination.go b/codersdk/pagination.go index 375a856d5f54d..3e6c8427af0f9 100644 --- a/codersdk/pagination.go +++ b/codersdk/pagination.go @@ -11,10 +11,6 @@ import ( // accepts and the page size clients use when reading that endpoint to // exhaustion. A request that omits limit receives this many rows; a limit of 0 // or greater than this is rejected. -// -// It is set well above the number of workspaces a deployment is expected to -// hold so that a caller reading the list still receives it in one response, and -// bounds the response for the cases that exceed it. const WorkspacesPageLimit = 1000 // Pagination sets pagination options for the endpoints that support it. @@ -26,9 +22,9 @@ type Pagination struct { // request. AfterID uuid.UUID `json:"after_id,omitempty" format:"uuid"` // Limit sets the maximum number of records to be returned in a single - // page. Endpoints that bound their page size document the accepted range - // and reject a limit outside it. On an endpoint that does not, a limit - // <= 0 means no limit and every record is returned. + // page. An endpoint that bounds its page size rejects a limit outside its + // range; otherwise a limit <= 0 means no limit and every record is + // returned. Limit int `json:"limit,omitempty"` // Offset is used to indicate which page to return. An offset of 0 // returns the first 'limit' number of users. diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 8dc0dea373787..9017324a8e41c 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -97,10 +97,9 @@ type WorkspacesRequest struct { type WorkspacesResponse struct { Workspaces []Workspace `json:"workspaces"` // Count is the number of workspaces matching the filter before the limit and - // offset are applied. It can exceed the length of Workspaces for a reason - // other than the limit: the endpoint drops workspaces whose latest build or - // template the requester cannot read after the limit is applied in SQL, so a - // page shorter than the limit does not mean the result set is exhausted. + // offset are applied. Workspaces the requester cannot fully read are omitted + // from the page after the limit is applied, so a page shorter than the limit + // does not mean the result set is exhausted. Count int `json:"count"` } @@ -618,8 +617,7 @@ func (f WorkspaceFilter) asRequestOption() RequestOption { } // Workspaces returns a single page of the workspaces the authenticated user has -// access to. The endpoint bounds the page size, so an unset filter Limit does -// not return every workspace; see AllWorkspaces to read every page. +// access to. An unset filter Limit resolves to the endpoint's maximum page size. func (c *Client) Workspaces(ctx context.Context, filter WorkspaceFilter) (WorkspacesResponse, error) { page := Pagination{ Offset: filter.Offset, @@ -640,26 +638,18 @@ func (c *Client) Workspaces(ctx context.Context, filter WorkspaceFilter) (Worksp } // AllWorkspaces requests successive pages of workspaces matching the filter and -// returns every row it receives, skipping rows it has already seen. Limit and -// Offset on the filter are ignored. +// returns every row it receives, skipping rows it has already returned. Limit +// and Offset on the filter are ignored. // // The offset advances by the requested page size rather than by the number of -// rows received. The endpoint applies its limit in SQL and then drops workspaces -// whose latest build or template the caller cannot read, so a page shorter than -// the page size does not mean the result set is exhausted. Count is the total -// before the limit and offset are applied. +// rows received, since a page can be shorter than the limit without the result +// set being exhausted. The scan ends when the offset reaches Count. // -// The pages are separate requests, so the result is not a snapshot. The -// endpoint orders by whether the workspace is a favorite of the requester and -// whether its latest build is running, both of which change while the pages are -// being read. A row that moves later in that order is filtered out here by ID; -// a row that moves earlier can pass the current offset and be missed entirely. -// A caller that needs an exact result should narrow the filter until it fits in -// one page. -// -// A page that fails ends the call; the pages already read are discarded rather -// than returned as a shorter list. The number of requests follows the total the -// endpoint reports, which is not bounded here, so cancel ctx to stop the walk. +// The pages are separate requests, so the result is not a snapshot. The order +// depends on workspace state that changes between requests: a row that moves +// later is skipped here by ID, and one that moves earlier can pass the current +// offset and be missed. A page that fails ends the call and discards the rows +// already read. func (c *Client) AllWorkspaces(ctx context.Context, filter WorkspaceFilter) ([]Workspace, error) { filter.Limit = WorkspacesPageLimit filter.Offset = 0 diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 50b6c014a7987..e13521e889f90 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -17657,10 +17657,10 @@ If the schedule is empty, the user will be updated to use the default schedule.| ### Properties -| Name | Type | Required | Restrictions | Description | -|--------------|---------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `count` | integer | false | | Count is the number of workspaces matching the filter before the limit and offset are applied. It can exceed the length of Workspaces for a reason other than the limit: the endpoint drops workspaces whose latest build or template the requester cannot read after the limit is applied in SQL, so a page shorter than the limit does not mean the result set is exhausted. | -| `workspaces` | array of [codersdk.Workspace](#codersdkworkspace) | false | | | +| Name | Type | Required | Restrictions | Description | +|--------------|---------------------------------------------------|----------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `count` | integer | false | | Count is the number of workspaces matching the filter before the limit and offset are applied. Workspaces the requester cannot fully read are omitted from the page after the limit is applied, so a page shorter than the limit does not mean the result set is exhausted. | +| `workspaces` | array of [codersdk.Workspace](#codersdkworkspace) | false | | | ## derp.BytesSentRecv diff --git a/site/src/api/api.ts b/site/src/api/api.ts index cdbd3a6a4fec7..624cdf71adaf0 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1264,27 +1264,20 @@ class ApiMethods { /** * Requests successive pages of workspaces until the offset reaches the total - * the server reports, skipping workspaces that were already returned by an - * earlier page. The offset advances by the page size rather than by the - * number of rows received: the endpoint applies its limit in SQL and then - * drops workspaces whose latest build or template the caller cannot read, so - * a short page does not mean the result set is exhausted. + * the server reports, skipping workspaces an earlier page already returned. + * The offset advances by the page size rather than by the number of rows + * received, since a page can be shorter than the limit without the result set + * being exhausted. * - * The pages are separate requests, so the result is not a snapshot. The - * endpoint orders by whether the workspace is a favorite of the requester and - * whether its latest build is running, both of which change while the pages - * are being read. A workspace that moves later in that order is skipped here - * by ID; one that moves earlier can pass the current offset and be missed. - * Prefer a server-side filter that fits in one page when the result has to be - * exact. + * The pages are separate requests, so the result is not a snapshot. The order + * depends on workspace state that changes between requests: a workspace that + * moves later is skipped here by ID, and one that moves earlier can pass the + * current offset and be missed. A page that fails rejects the whole call and + * discards the rows already read; aborting `signal` rejects the page in + * flight. * * `count` is the total the server reported for the last page and can exceed * the length of `workspaces`. - * - * A page that fails rejects the whole call; the pages already read are - * discarded rather than returned as a shorter list. Aborting `signal` rejects - * the page in flight and stops the walk. The number of requests follows the - * total the server reports, which is not bounded here. */ getAllWorkspaces = async ( req: Omit = {}, diff --git a/site/src/api/queries/workspaces.ts b/site/src/api/queries/workspaces.ts index 7ad5392101250..e14d351b39c35 100644 --- a/site/src/api/queries/workspaces.ts +++ b/site/src/api/queries/workspaces.ts @@ -236,16 +236,14 @@ type AllWorkspacesRequest = Omit< export function allWorkspacesKey(req: AllWorkspacesRequest = {}) { // The `all` marker keeps this distinct from the single-page key for the same - // filter. The key stays two segments long with an object at the end so it is - // still matched by invalidateWorkspaceListQueries. + // filter. The key stays two segments long with an object at the end so it + // keeps the shape a workspace list key is matched by. return [...workspacesQueryKeyPrefix, { ...req, all: true }] as const; } -// allWorkspaces fetches every page of the filtered result set. Prefer a -// server-side filter and a single page when the caller can express one: the -// request count follows the size of the result set, the pages are read one at a -// time, and a page that fails discards the ones already read. The query is -// cancelled on unmount, which stops the walk at the page in flight. +// allWorkspaces reads every page of the filtered result set. The request count +// scales with the result set and a page that fails discards the rows already +// read, so prefer a filter that fits in one page. export function allWorkspaces(req: AllWorkspacesRequest = {}) { return { queryKey: allWorkspacesKey(req), diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 871ece26402b8..8556b1fe6991f 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -6985,9 +6985,9 @@ export interface Pagination { readonly after_id?: string; /** * Limit sets the maximum number of records to be returned in a single - * page. Endpoints that bound their page size document the accepted range - * and reject a limit outside it. On an endpoint that does not, a limit - * <= 0 means no limit and every record is returned. + * page. An endpoint that bounds its page size rejects a limit outside its + * range; otherwise a limit <= 0 means no limit and every record is + * returned. */ readonly limit?: number; /** @@ -11506,10 +11506,6 @@ export interface WorkspaceUser extends MinimalUser { * accepts and the page size clients use when reading that endpoint to * exhaustion. A request that omits limit receives this many rows; a limit of 0 * or greater than this is rejected. - * - * It is set well above the number of workspaces a deployment is expected to - * hold so that a caller reading the list still receives it in one response, and - * bounds the response for the cases that exceed it. */ export const WorkspacesPageLimit = 1000; @@ -11523,10 +11519,9 @@ export interface WorkspacesResponse { readonly workspaces: readonly Workspace[]; /** * Count is the number of workspaces matching the filter before the limit and - * offset are applied. It can exceed the length of Workspaces for a reason - * other than the limit: the endpoint drops workspaces whose latest build or - * template the requester cannot read after the limit is applied in SQL, so a - * page shorter than the limit does not mean the result set is exhausted. + * offset are applied. Workspaces the requester cannot fully read are omitted + * from the page after the limit is applied, so a page shorter than the limit + * does not mean the result set is exhausted. */ readonly count: number; } diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 55235e0546d82..9c4f86a90c29b 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -379,13 +379,9 @@ export const AgentCreateForm: FC = ({ const isForbidden = !canCreateChat; - // Filter workspaces by the selected organization. We use - // client-side filtering of the fetched "owner:me" list rather - // than re-querying with an org filter because it avoids - // extra loading/error states on org change. The list is read - // a page at a time, so a user with many workspaces costs - // several requests; a server-side organization: filter - // would make it one. + // Filter workspaces by the selected organization. Filtering the fetched + // "owner:me" list on the client rather than re-querying with an org filter + // avoids extra loading and error states on org change. const filteredWorkspaces = showOrganizations && selectedOrg ? workspaceOptions.filter((ws) => ws.organization_id === selectedOrg.id) diff --git a/support/support.go b/support/support.go index 03e001797a528..0cf1081b9476e 100644 --- a/support/support.go +++ b/support/support.go @@ -286,9 +286,8 @@ func DeploymentInfo(ctx context.Context, client *codersdk.Client, log slog.Logge if d.Workspaces == nil { d.Workspaces = &resp } - // The endpoint orders by whether the latest build is running, which - // changes between requests, so a workspace can move to a later page and - // be returned again. + // The order depends on build state, which changes between requests, so a + // workspace can move to a page that has not been read yet. for _, ws := range resp.Workspaces { if _, ok := seen[ws.ID]; ok { continue @@ -313,11 +312,8 @@ func DeploymentInfo(ctx context.Context, client *codersdk.Client, log slog.Logge break } // The offset advances by the requested limit rather than by the number - // of rows returned. The endpoint applies its limit in SQL and then - // drops rows whose latest build or template the caller cannot read, so - // advancing by len(resp.Workspaces) would re-request rows already - // collected. Count is the total before the limit and offset are - // applied. + // of rows returned, since a page can be shorter than the limit without + // the set being exhausted. offset += codersdk.WorkspacesPageLimit if offset >= count { break @@ -329,9 +325,8 @@ func DeploymentInfo(ctx context.Context, client *codersdk.Client, log slog.Logge // Preserve server-reported total so Run() can log accurate truncation. d.Workspaces.Count = count if incomplete { - // The scan stopped on a permissions error, so the rows collected are - // all there are to report. The server-reported total describes a set - // this list does not cover. + // The scan stopped early, so the server total describes a set this + // list does not cover. d.Workspaces.Count = len(all) } }