From 8d08b9a844e1ac23ef6db5af021fe61acab5bfed Mon Sep 17 00:00:00 2001 From: Stefan Ernst <24554551+stefan-ernst@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:14:27 +0100 Subject: [PATCH] feat(workspaces): resolve workspace ids in one directory read (WI-1923) The request-type config modal resolved a channel's allowed workspaces by issuing one GET /workspaces/{id} per id missing from the cached directory page, so opening it for a channel with K allowed workspaces fanned out K parallel reads and bypassed the read scheduler. Add an optional `ids` filter to GET /workspaces. The service intersects the requested ids with the caller's visible set and returns exactly those in one query, dropping ids the caller cannot view. The frontend resolves the missing set with one bounded read per chunk (cap 200) and opts into the foreground read scheduler; unresolvable ids are reported without dropping the rest of the picker. Regenerate contract-metadata.json and api/openapi-v2.json. --- api/openapi-v2.json | 9 +++++++ frontend/src/lib/api/workspaces.js | 26 ++++++++++++++++++ .../src/lib/dialogs/RequestTypeModal.svelte | 21 ++++++++------- internal/restapi/v2/contract-metadata.json | 9 +++++++ internal/restapi/v2/router.go | 2 ++ internal/restapi/v2/scoped_catalog.go | 16 +++++++++++ internal/services/catalog_read_service.go | 27 +++++++++++++++++++ 7 files changed, 101 insertions(+), 9 deletions(-) diff --git a/api/openapi-v2.json b/api/openapi-v2.json index f830aefec..520dbec9a 100644 --- a/api/openapi-v2.json +++ b/api/openapi-v2.json @@ -121157,6 +121157,15 @@ "schema": { "type": "string" } + }, + { + "description": "Comma-separated workspace IDs to resolve directly, at most 200. When set, the response contains exactly those workspaces the caller can view; search and pagination are ignored.", + "in": "query", + "name": "ids", + "required": false, + "schema": { + "type": "string" + } } ], "responses": { diff --git a/frontend/src/lib/api/workspaces.js b/frontend/src/lib/api/workspaces.js index 924ef0646..c0df6db06 100644 --- a/frontend/src/lib/api/workspaces.js +++ b/frontend/src/lib/api/workspaces.js @@ -5,6 +5,9 @@ import { normalizeStatuses } from './workflows.js'; const workspaceCRUD = createCrudClient('/workspaces', { v2: true, allV2: true }); +// Matches the server's cap on a direct workspace id resolve. +const WORKSPACE_ID_BATCH = 200; + export const workspaces = { ...workspaceCRUD, getAll: (filters = {}, requestOptions = {}) => @@ -14,6 +17,29 @@ export const workspaces = { // the cached first page reachable without fetch-all loops (WI-1442). getPage: (filters = {}, requestOptions = {}) => fetchAPIV2(`/workspaces${buildQueryString(filters)}`, requestOptions), + // Directly resolve a known id set in one read per chunk instead of one read + // per id. The server omits ids the caller cannot view, so callers compare + // the requested and returned sets to report unresolvable ids. + getByIds: async (ids, requestOptions = {}) => { + const unique = [ + ...new Set((ids || []).map(Number).filter((id) => Number.isInteger(id) && id > 0)), + ]; + if (unique.length === 0) return []; + const chunks = []; + for (let i = 0; i < unique.length; i += WORKSPACE_ID_BATCH) { + chunks.push(unique.slice(i, i + WORKSPACE_ID_BATCH)); + } + const pages = await Promise.allSettled( + chunks.map((chunk) => + fetchAPIV2(`/workspaces?ids=${chunk.join(',')}&page_size=${chunk.length}`, requestOptions) + ) + ); + const resolved = []; + for (const page of pages) { + if (page.status === 'fulfilled') resolved.push(...(page.value?.data || [])); + } + return resolved; + }, get: (id, requestOptions = {}) => fetchV2Data(`/workspaces/${id}`, requestOptions), getBootstrap: (id, requestOptions = {}) => fetchAPI(`/workspaces/${id}/bootstrap`, requestOptions), diff --git a/frontend/src/lib/dialogs/RequestTypeModal.svelte b/frontend/src/lib/dialogs/RequestTypeModal.svelte index 9c5b535ed..2cc977d9e 100644 --- a/frontend/src/lib/dialogs/RequestTypeModal.svelte +++ b/frontend/src/lib/dialogs/RequestTypeModal.svelte @@ -2,6 +2,7 @@ import { workspaces } from '../api/workspaces.js'; import { requestTypes } from '../api/channels.js'; import { workspacesStore } from '../stores/workspaces.svelte.js'; + import { FOREGROUND_READ } from '../api/readScheduler.js'; import Button from '../components/Button.svelte'; import Input from '../components/Input.svelte'; import BasePicker from '../pickers/BasePicker.svelte'; @@ -53,9 +54,9 @@ // Load workspaces filtered to channel's configured IDs. The directory // store caches only the first server page, so a restricted channel - // resolves each configured id it names explicitly — the allowed set is the - // channel's own small list, not whatever the cache happens to hold - // (WI-1585). Unrestricted channels keep the cached page and gain + // resolves the configured ids it names explicitly in one batched read — the + // allowed set is the channel's own list, not whatever the cache happens to + // hold (WI-1585). Unrestricted channels keep the cached page and gain // server-backed search over the rest of the directory. let workspaceSearchResults = $state(null); @@ -65,12 +66,14 @@ const byId = new Map(cached.map(ws => [ws.id, ws])); const configured = channelWorkspaceIds || []; const missing = configured.filter(id => !byId.has(id)); - const fetched = await Promise.allSettled(missing.map(id => workspaces.get(id))); - for (const result of fetched) { - if (result.status === 'fulfilled' && result.value?.id) { - byId.set(result.value.id, result.value); - } else if (result.status === 'rejected') { - console.error('Failed to resolve configured workspace:', result.reason); + if (missing.length > 0) { + const resolved = await workspaces.getByIds(missing, FOREGROUND_READ); + for (const workspace of resolved) { + if (workspace?.id != null) byId.set(workspace.id, workspace); + } + const unresolved = missing.filter(id => !byId.has(id)); + if (unresolved.length > 0) { + console.error('Failed to resolve configured workspaces:', unresolved.join(', ')); } } availableWorkspaces = configured.length > 0 diff --git a/internal/restapi/v2/contract-metadata.json b/internal/restapi/v2/contract-metadata.json index 6d5bdf8e3..20a723627 100644 --- a/internal/restapi/v2/contract-metadata.json +++ b/internal/restapi/v2/contract-metadata.json @@ -11308,6 +11308,15 @@ "Schema": { "type": "string" } + }, + { + "Name": "ids", + "In": "query", + "Description": "Comma-separated workspace IDs to resolve directly, at most 200. When set, the response contains exactly those workspaces the caller can view; search and pagination are ignored.", + "Required": false, + "Schema": { + "type": "string" + } } ], "responses": { diff --git a/internal/restapi/v2/router.go b/internal/restapi/v2/router.go index 4ef64576b..d0e890b80 100644 --- a/internal/restapi/v2/router.go +++ b/internal/restapi/v2/router.go @@ -1155,6 +1155,8 @@ func applyParameterCorrections(route *Route) { map[string]any{"type": "string", "pattern": "^[A-Za-z0-9]{2,10}-[1-9][0-9]*$", "example": "WI-1238"}, }}, }) + case "GET /workspaces": + upsertParameter(route, stringQuery("ids", "Comma-separated workspace IDs to resolve directly, at most 200. When set, the response contains exactly those workspaces the caller can view; search and pagination are ignored.")) case "GET /query-language/values": source := enumQuery("source", "Completion value catalog to search.", "workspaces", "statuses", "status_categories", "priorities", "users", "milestones", "iterations", "projects", "item_types", "labels") diff --git a/internal/restapi/v2/scoped_catalog.go b/internal/restapi/v2/scoped_catalog.go index 6b77f4de8..267929a54 100644 --- a/internal/restapi/v2/scoped_catalog.go +++ b/internal/restapi/v2/scoped_catalog.go @@ -141,11 +141,23 @@ func listWorkspaces(catalog catalogReader) pageOperation[workspaceDTO] { if err != nil { return nil, Pagination{}, 0, err } + // An explicit id set resolves exactly those rows in one read. The + // page size mirrors the requested set so the page is self-describing + // even when the caller omits page_size. + var ids []int + if strings.TrimSpace(r.URL.Query().Get("ids")) != "" { + ids, err = queryItemIDs(r, "ids", maxWorkspaceIDBatch) + if err != nil { + return nil, Pagination{}, 0, err + } + page.PageSize = len(ids) + } // Server-side directory search over name, key, and description — // the same fields the client-side filter matches, so results are // consistent whether they come from cache or the server. pageParams := catalogPage(page) pageParams.Search = clampSearch(r.URL.Query().Get("search")) + pageParams.IDs = ids user, err := principal(r) if err != nil { return nil, Pagination{}, 0, err @@ -172,6 +184,10 @@ func clampSearch(raw string) string { return search } +// maxWorkspaceIDBatch bounds a direct workspace id resolve so one request +// cannot name an unbounded set. +const maxWorkspaceIDBatch = 200 + func listWorkspaceTemplates(catalog catalogReader) readOperation[[]workspaceTemplateDTO] { return func(r *http.Request) ([]workspaceTemplateDTO, error) { user, err := principal(r) diff --git a/internal/services/catalog_read_service.go b/internal/services/catalog_read_service.go index 78e47354c..b152228c5 100644 --- a/internal/services/catalog_read_service.go +++ b/internal/services/catalog_read_service.go @@ -62,6 +62,9 @@ type CatalogPageParams struct { Sort string Desc bool Search string + // IDs, when set, restricts the page to exactly these resources. Callers + // use it to resolve a known id set in one read instead of one read per id. + IDs []int } func (s *CatalogReadService) ListWorkspaces(userID int, page CatalogPageParams) ([]models.Workspace, int, error) { @@ -87,6 +90,14 @@ func (s *CatalogReadService) ListWorkspaces(userID int, page CatalogPageParams) visibleIDs = append(visibleIDs, candidate.ID) } } + if len(page.IDs) > 0 { + // The caller named the exact set, so return all of it in one page: + // ids outside the visible set are dropped and never leak existence. + visibleIDs = intersectIDs(visibleIDs, page.IDs) + page.Search = "" + page.Limit = len(visibleIDs) + page.Offset = 0 + } workspaces, total, err := s.workspaceRepo.FindByIDsPage(repository.WorkspaceIDPageParams{ IDs: visibleIDs, Search: page.Search, @@ -108,6 +119,22 @@ func (s *CatalogReadService) ListWorkspaces(userID int, page CatalogPageParams) return workspaces, total, nil } +// intersectIDs returns the available ids that appear in requested, in the +// available order. Requested ids absent from available are omitted. +func intersectIDs(available, requested []int) []int { + wanted := make(map[int]struct{}, len(requested)) + for _, id := range requested { + wanted[id] = struct{}{} + } + result := make([]int, 0, len(requested)) + for _, id := range available { + if _, ok := wanted[id]; ok { + result = append(result, id) + } + } + return result +} + func (s *CatalogReadService) ListWorkspaceTemplates(ctx context.Context, userID int) ([]models.WorkspaceTemplateSummary, error) { items, err := s.workspaces.ListTemplateSummaries(ctx) if err != nil {