From 84eaccaa2c9454a60481420bbbf3fc3b280437f9 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Tue, 11 Aug 2026 21:33:46 +0000 Subject: [PATCH 1/5] perf(coderd): build the workspace build fan-out maps once per batch convertWorkspaceBuild rebuilt seven maps from the caller's global slices on every call and rescanned the provisioner daemon rows, so convertWorkspaceBuilds paid that cost once per build over the same rows. The maps move into a workspaceBuildIndex built once per batch, keyed as before and now including daemons by job ID. Agents are sorted while the index is built, so a resource read by several builds is sorted once rather than once per build; the comparison and the rows are unchanged, so the order is the same. --- coderd/workspacebuilds.go | 206 ++++++++++++++---------- coderd/workspacebuilds_internal_test.go | 205 +++++++++++++++++++++++ coderd/workspaces.go | 9 +- 3 files changed, 323 insertions(+), 97 deletions(-) create mode 100644 coderd/workspacebuilds_internal_test.go diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index 32b45bd6f9f..f79d9b4d7ba 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -81,15 +81,17 @@ func (api *API) workspaceBuild(rw http.ResponseWriter, r *http.Request) { workspaceBuild, workspace, data.jobs[0], - data.resources, - data.metadata, - data.agents, - data.apps, - data.appStatuses, - data.scripts, - data.logSources, + newWorkspaceBuildIndex( + data.resources, + data.metadata, + data.agents, + data.apps, + data.appStatuses, + data.scripts, + data.logSources, + nil, + ), data.templateVersions[0], - nil, ) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -291,15 +293,17 @@ func (api *API) workspaceBuildByBuildNumber(rw http.ResponseWriter, r *http.Requ workspaceBuild, workspace, data.jobs[0], - data.resources, - data.metadata, - data.agents, - data.apps, - data.appStatuses, - data.scripts, - data.logSources, + newWorkspaceBuildIndex( + data.resources, + data.metadata, + data.agents, + data.apps, + data.appStatuses, + data.scripts, + data.logSources, + data.provisionerDaemons, + ), data.templateVersions[0], - data.provisionerDaemons, ) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -616,15 +620,8 @@ func (api *API) postWorkspaceBuildsInternal( *workspaceBuild, workspace, queuePos, - []database.WorkspaceResource{}, - []database.WorkspaceResourceMetadatum{}, - []database.WorkspaceAgent{}, - []database.WorkspaceApp{}, - []database.WorkspaceAppStatus{}, - []database.GetWorkspaceAgentScriptsByAgentIDsRow{}, - []database.WorkspaceAgentLogSource{}, + newWorkspaceBuildIndex(nil, nil, nil, nil, nil, nil, nil, provisionerDaemons), database.TemplateVersion{}, - provisionerDaemons, ) if err != nil { return codersdk.WorkspaceBuild{}, httperror.NewResponseError( @@ -1245,6 +1242,81 @@ func (api *API) workspaceBuildsData(ctx context.Context, workspaceBuilds []datab }, nil } +// workspaceBuildIndex groups the rows that convertWorkspaceBuild reads by the +// ID it looks them up with. It is built once per batch of builds because every +// build in a batch is converted from the same rows. +type workspaceBuildIndex struct { + resourcesByJobID map[uuid.UUID][]database.WorkspaceResource + metadataByResourceID map[uuid.UUID][]database.WorkspaceResourceMetadatum + agentsByResourceID map[uuid.UUID][]database.WorkspaceAgent + appsByAgentID map[uuid.UUID][]database.WorkspaceApp + statusesByAgentID map[uuid.UUID][]database.WorkspaceAppStatus + scriptsByAgentID map[uuid.UUID][]database.GetWorkspaceAgentScriptsByAgentIDsRow + logSourcesByAgentID map[uuid.UUID][]database.WorkspaceAgentLogSource + daemonsByJobID map[uuid.UUID][]database.ProvisionerDaemon +} + +// newWorkspaceBuildIndex makes one pass over each slice. The slices in the +// returned maps alias the slices passed in. +func newWorkspaceBuildIndex( + workspaceResources []database.WorkspaceResource, + resourceMetadata []database.WorkspaceResourceMetadatum, + resourceAgents []database.WorkspaceAgent, + agentApps []database.WorkspaceApp, + agentAppStatuses []database.WorkspaceAppStatus, + agentScripts []database.GetWorkspaceAgentScriptsByAgentIDsRow, + agentLogSources []database.WorkspaceAgentLogSource, + provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow, +) *workspaceBuildIndex { + index := &workspaceBuildIndex{ + resourcesByJobID: make(map[uuid.UUID][]database.WorkspaceResource), + metadataByResourceID: make(map[uuid.UUID][]database.WorkspaceResourceMetadatum), + agentsByResourceID: make(map[uuid.UUID][]database.WorkspaceAgent), + appsByAgentID: make(map[uuid.UUID][]database.WorkspaceApp), + statusesByAgentID: make(map[uuid.UUID][]database.WorkspaceAppStatus), + scriptsByAgentID: make(map[uuid.UUID][]database.GetWorkspaceAgentScriptsByAgentIDsRow), + logSourcesByAgentID: make(map[uuid.UUID][]database.WorkspaceAgentLogSource), + daemonsByJobID: make(map[uuid.UUID][]database.ProvisionerDaemon), + } + for _, resource := range workspaceResources { + index.resourcesByJobID[resource.JobID] = append(index.resourcesByJobID[resource.JobID], resource) + } + for _, metadata := range resourceMetadata { + index.metadataByResourceID[metadata.WorkspaceResourceID] = append(index.metadataByResourceID[metadata.WorkspaceResourceID], metadata) + } + for _, agent := range resourceAgents { + index.agentsByResourceID[agent.ResourceID] = append(index.agentsByResourceID[agent.ResourceID], agent) + } + for _, app := range agentApps { + index.appsByAgentID[app.AgentID] = append(index.appsByAgentID[app.AgentID], app) + } + for _, status := range agentAppStatuses { + index.statusesByAgentID[status.AgentID] = append(index.statusesByAgentID[status.AgentID], status) + } + for _, script := range agentScripts { + index.scriptsByAgentID[script.WorkspaceAgentID] = append(index.scriptsByAgentID[script.WorkspaceAgentID], script) + } + for _, logSource := range agentLogSources { + index.logSourcesByAgentID[logSource.WorkspaceAgentID] = append(index.logSourcesByAgentID[logSource.WorkspaceAgentID], logSource) + } + for _, daemon := range provisionerDaemons { + index.daemonsByJobID[daemon.JobID] = append(index.daemonsByJobID[daemon.JobID], daemon.ProvisionerDaemon) + } + // Agents are sorted here rather than per build so that a resource shared by + // several builds is sorted once. The order is by display order then name, + // and the same comparison runs on the same rows, so the result does not + // depend on how many builds read it. + for _, agents := range index.agentsByResourceID { + sort.Slice(agents, func(i, j int) bool { + if agents[i].DisplayOrder != agents[j].DisplayOrder { + return agents[i].DisplayOrder < agents[j].DisplayOrder + } + return agents[i].Name < agents[j].Name + }) + } + return index +} + func (api *API) convertWorkspaceBuilds( workspaceBuilds []database.WorkspaceBuild, workspaces []database.Workspace, @@ -1272,6 +1344,17 @@ func (api *API) convertWorkspaceBuilds( templateVersionByID[templateVersion.ID] = templateVersion } + index := newWorkspaceBuildIndex( + workspaceResources, + resourceMetadata, + resourceAgents, + agentApps, + agentAppStatuses, + agentScripts, + agentLogSources, + provisionerDaemons, + ) + // Should never be nil for API consistency apiBuilds := []codersdk.WorkspaceBuild{} for _, build := range workspaceBuilds { @@ -1292,15 +1375,8 @@ func (api *API) convertWorkspaceBuilds( build, workspace, job, - workspaceResources, - resourceMetadata, - resourceAgents, - agentApps, - agentAppStatuses, - agentScripts, - agentLogSources, + index, templateVersion, - provisionerDaemons, ) if err != nil { return nil, xerrors.Errorf("converting workspace build: %w", err) @@ -1316,64 +1392,16 @@ func (api *API) convertWorkspaceBuild( build database.WorkspaceBuild, workspace database.Workspace, job database.GetProvisionerJobsByIDsWithQueuePositionRow, - workspaceResources []database.WorkspaceResource, - resourceMetadata []database.WorkspaceResourceMetadatum, - resourceAgents []database.WorkspaceAgent, - agentApps []database.WorkspaceApp, - agentAppStatuses []database.WorkspaceAppStatus, - agentScripts []database.GetWorkspaceAgentScriptsByAgentIDsRow, - agentLogSources []database.WorkspaceAgentLogSource, + index *workspaceBuildIndex, templateVersion database.TemplateVersion, - provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow, ) (codersdk.WorkspaceBuild, error) { - resourcesByJobID := map[uuid.UUID][]database.WorkspaceResource{} - for _, resource := range workspaceResources { - resourcesByJobID[resource.JobID] = append(resourcesByJobID[resource.JobID], resource) - } - metadataByResourceID := map[uuid.UUID][]database.WorkspaceResourceMetadatum{} - for _, metadata := range resourceMetadata { - metadataByResourceID[metadata.WorkspaceResourceID] = append(metadataByResourceID[metadata.WorkspaceResourceID], metadata) - } - agentsByResourceID := map[uuid.UUID][]database.WorkspaceAgent{} - for _, agent := range resourceAgents { - agentsByResourceID[agent.ResourceID] = append(agentsByResourceID[agent.ResourceID], agent) - } - appsByAgentID := map[uuid.UUID][]database.WorkspaceApp{} - for _, app := range agentApps { - appsByAgentID[app.AgentID] = append(appsByAgentID[app.AgentID], app) - } - scriptsByAgentID := map[uuid.UUID][]database.GetWorkspaceAgentScriptsByAgentIDsRow{} - for _, script := range agentScripts { - scriptsByAgentID[script.WorkspaceAgentID] = append(scriptsByAgentID[script.WorkspaceAgentID], script) - } - logSourcesByAgentID := map[uuid.UUID][]database.WorkspaceAgentLogSource{} - for _, logSource := range agentLogSources { - logSourcesByAgentID[logSource.WorkspaceAgentID] = append(logSourcesByAgentID[logSource.WorkspaceAgentID], logSource) - } - provisionerDaemonsForThisWorkspaceBuild := []database.ProvisionerDaemon{} - for _, provisionerDaemon := range provisionerDaemons { - if provisionerDaemon.JobID != job.ProvisionerJob.ID { - continue - } - provisionerDaemonsForThisWorkspaceBuild = append(provisionerDaemonsForThisWorkspaceBuild, provisionerDaemon.ProvisionerDaemon) - } - matchedProvisioners := db2sdk.MatchedProvisioners(provisionerDaemonsForThisWorkspaceBuild, job.ProvisionerJob.CreatedAt, provisionerdserver.StaleInterval) - statusesByAgentID := map[uuid.UUID][]database.WorkspaceAppStatus{} - for _, status := range agentAppStatuses { - statusesByAgentID[status.AgentID] = append(statusesByAgentID[status.AgentID], status) - } + matchedProvisioners := db2sdk.MatchedProvisioners(index.daemonsByJobID[job.ProvisionerJob.ID], job.ProvisionerJob.CreatedAt, provisionerdserver.StaleInterval) - resources := resourcesByJobID[job.ProvisionerJob.ID] + resources := index.resourcesByJobID[job.ProvisionerJob.ID] apiResources := make([]codersdk.WorkspaceResource, 0) resourceAgentsMinOrder := map[uuid.UUID]int32{} // map[resource.ID]minOrder for _, resource := range resources { - agents := agentsByResourceID[resource.ID] - sort.Slice(agents, func(i, j int) bool { - if agents[i].DisplayOrder != agents[j].DisplayOrder { - return agents[i].DisplayOrder < agents[j].DisplayOrder - } - return agents[i].Name < agents[j].Name - }) + agents := index.agentsByResourceID[resource.ID] apiAgents := make([]codersdk.WorkspaceAgent, 0) resourceAgentsMinOrder[resource.ID] = math.MaxInt32 @@ -1381,10 +1409,10 @@ func (api *API) convertWorkspaceBuild( for _, agent := range agents { resourceAgentsMinOrder[resource.ID] = min(resourceAgentsMinOrder[resource.ID], agent.DisplayOrder) - apps := appsByAgentID[agent.ID] - scripts := scriptsByAgentID[agent.ID] - statuses := statusesByAgentID[agent.ID] - logSources := logSourcesByAgentID[agent.ID] + apps := index.appsByAgentID[agent.ID] + scripts := index.scriptsByAgentID[agent.ID] + statuses := index.statusesByAgentID[agent.ID] + logSources := index.logSourcesByAgentID[agent.ID] apiAgent, err := db2sdk.WorkspaceAgent( api.DERPMap(), *api.TailnetCoordinator.Load(), agent, db2sdk.Apps(apps, statuses, agent, workspace.OwnerUsername, workspace.WorkspaceTable()), convertScripts(scripts), convertLogSources(logSources), api.AgentInactiveDisconnectTimeout, api.DeploymentValues.AgentFallbackTroubleshootingURL.String(), @@ -1394,7 +1422,7 @@ func (api *API) convertWorkspaceBuild( } apiAgents = append(apiAgents, apiAgent) } - metadata := append(make([]database.WorkspaceResourceMetadatum, 0), metadataByResourceID[resource.ID]...) + metadata := append(make([]database.WorkspaceResourceMetadatum, 0), index.metadataByResourceID[resource.ID]...) apiResources = append(apiResources, convertWorkspaceResource(resource, apiAgents, metadata)) } sort.Slice(apiResources, func(i, j int) bool { diff --git a/coderd/workspacebuilds_internal_test.go b/coderd/workspacebuilds_internal_test.go new file mode 100644 index 00000000000..c108cd9c4ba --- /dev/null +++ b/coderd/workspacebuilds_internal_test.go @@ -0,0 +1,205 @@ +package coderd + +import ( + "database/sql" + "fmt" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/tailnet" +) + +// buildFixture holds one batch of rows shaped like the output of +// workspaceBuildsData: a set of builds plus the flat resource, agent, app, +// script, log source, status, and daemon rows for all of them. +type buildFixture struct { + builds []database.WorkspaceBuild + workspaces []database.Workspace + jobs []database.GetProvisionerJobsByIDsWithQueuePositionRow + templateVersions []database.TemplateVersion + resources []database.WorkspaceResource + metadata []database.WorkspaceResourceMetadatum + agents []database.WorkspaceAgent + apps []database.WorkspaceApp + statuses []database.WorkspaceAppStatus + scripts []database.GetWorkspaceAgentScriptsByAgentIDsRow + logSources []database.WorkspaceAgentLogSource + daemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow +} + +func newBuildFixture(builds, resourcesPerBuild int, agentsPerResource int32, appsPerAgent int) buildFixture { + now := time.Date(2026, 8, 11, 0, 0, 0, 0, time.UTC) + f := buildFixture{} + for b := range builds { + workspaceID := uuid.New() + jobID := uuid.New() + templateVersionID := uuid.New() + + f.workspaces = append(f.workspaces, database.Workspace{ + ID: workspaceID, + OwnerID: uuid.New(), + OwnerUsername: fmt.Sprintf("owner-%d", b), + Name: fmt.Sprintf("workspace-%d", b), + }) + f.jobs = append(f.jobs, database.GetProvisionerJobsByIDsWithQueuePositionRow{ + ProvisionerJob: database.ProvisionerJob{ + ID: jobID, + CreatedAt: now, + StartedAt: sql.NullTime{Time: now, Valid: true}, + }, + }) + f.templateVersions = append(f.templateVersions, database.TemplateVersion{ + ID: templateVersionID, + Name: fmt.Sprintf("version-%d", b), + }) + f.builds = append(f.builds, database.WorkspaceBuild{ + ID: uuid.New(), + WorkspaceID: workspaceID, + JobID: jobID, + TemplateVersionID: templateVersionID, + BuildNumber: 1, + Transition: database.WorkspaceTransitionStart, + }) + f.daemons = append(f.daemons, database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{ + JobID: jobID, + ProvisionerDaemon: database.ProvisionerDaemon{ + ID: uuid.New(), + Name: fmt.Sprintf("daemon-%d", b), + LastSeenAt: sql.NullTime{Time: now, Valid: true}, + Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho}, + }, + }) + + for r := range resourcesPerBuild { + resourceID := uuid.New() + f.resources = append(f.resources, database.WorkspaceResource{ + ID: resourceID, + JobID: jobID, + Transition: database.WorkspaceTransitionStart, + Type: "example", + Name: fmt.Sprintf("resource-%d-%d", b, r), + }) + f.metadata = append(f.metadata, database.WorkspaceResourceMetadatum{ + WorkspaceResourceID: resourceID, + Key: "key", + Value: sql.NullString{String: "value", Valid: true}, + }) + + for a := range agentsPerResource { + agentID := uuid.New() + f.agents = append(f.agents, database.WorkspaceAgent{ + ID: agentID, + ResourceID: resourceID, + Name: fmt.Sprintf("agent-%d-%d-%d", b, r, a), + DisplayOrder: agentsPerResource - a, + APIKeyScope: database.AgentKeyScopeEnumAll, + }) + f.scripts = append(f.scripts, database.GetWorkspaceAgentScriptsByAgentIDsRow{ + WorkspaceAgentID: agentID, + Script: "echo hello", + }) + f.logSources = append(f.logSources, database.WorkspaceAgentLogSource{ + WorkspaceAgentID: agentID, + DisplayName: "startup", + }) + + for p := range appsPerAgent { + appID := uuid.New() + f.apps = append(f.apps, database.WorkspaceApp{ + ID: appID, + AgentID: agentID, + Slug: fmt.Sprintf("app-%d-%d-%d-%d", b, r, a, p), + DisplayName: "App", + Health: database.WorkspaceAppHealthHealthy, + SharingLevel: database.AppSharingLevelOwner, + OpenIn: database.WorkspaceAppOpenInSlimWindow, + }) + f.statuses = append(f.statuses, database.WorkspaceAppStatus{ + ID: uuid.New(), + AgentID: agentID, + AppID: appID, + State: database.WorkspaceAppStatusStateComplete, + }) + } + } + } + } + return f +} + +func newConvertAPI() *API { + api := &API{ + Options: &Options{ + AgentInactiveDisconnectTimeout: time.Minute, + DeploymentValues: &codersdk.DeploymentValues{}, + }, + } + coordinator := tailnet.NewCoordinator(slog.Logger{}) + api.TailnetCoordinator.Store(&coordinator) + return api +} + +func (f buildFixture) convert(api *API) ([]codersdk.WorkspaceBuild, error) { + return api.convertWorkspaceBuilds( + f.builds, + f.workspaces, + f.jobs, + f.resources, + f.metadata, + f.agents, + f.apps, + f.statuses, + f.scripts, + f.logSources, + f.templateVersions, + f.daemons, + ) +} + +// TestConvertWorkspaceBuildsAgentOrder covers the ordering that the shared +// index is responsible for: agents come back sorted by display order, and the +// order does not depend on how many builds read the same rows. The fixture +// assigns display order in reverse of agent name, so the expected result is +// names in descending order. +func TestConvertWorkspaceBuildsAgentOrder(t *testing.T) { + t.Parallel() + + api := newConvertAPI() + fixture := newBuildFixture(3, 2, 3, 1) + + builds, err := fixture.convert(api) + require.NoError(t, err) + require.Len(t, builds, 3) + + for _, build := range builds { + require.NotEmpty(t, build.Resources) + for _, resource := range build.Resources { + require.Len(t, resource.Agents, 3) + for i := 1; i < len(resource.Agents); i++ { + require.Greater(t, resource.Agents[i-1].Name, resource.Agents[i].Name) + } + } + } +} + +func BenchmarkConvertWorkspaceBuilds(b *testing.B) { + for _, builds := range []int{1, 25, 100} { + b.Run(fmt.Sprintf("Builds%d", builds), func(b *testing.B) { + api := newConvertAPI() + fixture := newBuildFixture(builds, 5, 2, 4) + + b.ReportAllocs() + for b.Loop() { + if _, err := fixture.convert(api); err != nil { + b.Fatal(err) + } + } + }) + } +} diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 21d11d88b76..d48f6d5dd72 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -834,15 +834,8 @@ func createWorkspace( ProvisionerJob: *provisionerJob, QueuePosition: 0, }, - []database.WorkspaceResource{}, - []database.WorkspaceResourceMetadatum{}, - []database.WorkspaceAgent{}, - []database.WorkspaceApp{}, - []database.WorkspaceAppStatus{}, - []database.GetWorkspaceAgentScriptsByAgentIDsRow{}, - []database.WorkspaceAgentLogSource{}, + newWorkspaceBuildIndex(nil, nil, nil, nil, nil, nil, nil, provisionerDaemons), database.TemplateVersion{}, - provisionerDaemons, ) if err != nil { return codersdk.Workspace{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ From 60167958f8ee7ebaddc62dbb1886541978e96d80 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Thu, 13 Aug 2026 18:41:19 +0000 Subject: [PATCH 2/5] perf(coderd): correct workspace build index comments and use slices.SortFunc The aliasing note claimed the index maps aliased the input slices, but each bucket is a freshly allocated slice of copied rows. The agent sort comment described a resource shared by several builds, which cannot happen because resources are grouped by job ID and each build has its own job. Document that conversion reorders bucket contents in place, and replace sort.Slice with slices.SortFunc. --- coderd/workspacebuilds.go | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index f79d9b4d7ba..c9b53f515e3 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -1,6 +1,7 @@ package coderd import ( + "cmp" "context" "database/sql" "encoding/json" @@ -1245,6 +1246,10 @@ func (api *API) workspaceBuildsData(ctx context.Context, workspaceBuilds []datab // workspaceBuildIndex groups the rows that convertWorkspaceBuild reads by the // ID it looks them up with. It is built once per batch of builds because every // build in a batch is converted from the same rows. +// +// Conversion reorders rows within a bucket in place, so an index must be read +// by a single goroutine and cannot be retained beyond the conversion it was +// built for. type workspaceBuildIndex struct { resourcesByJobID map[uuid.UUID][]database.WorkspaceResource metadataByResourceID map[uuid.UUID][]database.WorkspaceResourceMetadatum @@ -1256,8 +1261,10 @@ type workspaceBuildIndex struct { daemonsByJobID map[uuid.UUID][]database.ProvisionerDaemon } -// newWorkspaceBuildIndex makes one pass over each slice. The slices in the -// returned maps alias the slices passed in. +// newWorkspaceBuildIndex makes one pass over each slice. Rows are copied into +// freshly allocated per-ID slices, so the input slices are neither modified nor +// aliased. Reference-typed fields within a row, such as tags and string slices, +// still share memory with the input rows. func newWorkspaceBuildIndex( workspaceResources []database.WorkspaceResource, resourceMetadata []database.WorkspaceResourceMetadatum, @@ -1302,16 +1309,13 @@ func newWorkspaceBuildIndex( for _, daemon := range provisionerDaemons { index.daemonsByJobID[daemon.JobID] = append(index.daemonsByJobID[daemon.JobID], daemon.ProvisionerDaemon) } - // Agents are sorted here rather than per build so that a resource shared by - // several builds is sorted once. The order is by display order then name, - // and the same comparison runs on the same rows, so the result does not - // depend on how many builds read it. + // Agents within a resource are ordered by display order, then name. for _, agents := range index.agentsByResourceID { - sort.Slice(agents, func(i, j int) bool { - if agents[i].DisplayOrder != agents[j].DisplayOrder { - return agents[i].DisplayOrder < agents[j].DisplayOrder - } - return agents[i].Name < agents[j].Name + slices.SortFunc(agents, func(a, b database.WorkspaceAgent) int { + return cmp.Or( + cmp.Compare(a.DisplayOrder, b.DisplayOrder), + cmp.Compare(a.Name, b.Name), + ) }) } return index From 0a8ed38a622ea9fd2cac600ed939ccb785a3589a Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Thu, 13 Aug 2026 18:50:54 +0000 Subject: [PATCH 3/5] test(coderd): pin per-build rows and matched provisioners in build conversion The batch conversion reads one index for every build, so cover the rows each build receives: resource count and job ID, build-scoped resource, agent and app names, metadata, scripts, log sources and per-app statuses. Add a build with no eligible daemon rows alongside builds that have them, and a case where agents share a display order so the name comparison runs. --- coderd/workspacebuilds_internal_test.go | 109 +++++++++++++++++++++++- 1 file changed, 105 insertions(+), 4 deletions(-) diff --git a/coderd/workspacebuilds_internal_test.go b/coderd/workspacebuilds_internal_test.go index c108cd9c4ba..e58931b44ef 100644 --- a/coderd/workspacebuilds_internal_test.go +++ b/coderd/workspacebuilds_internal_test.go @@ -3,6 +3,7 @@ package coderd import ( "database/sql" "fmt" + "strings" "testing" "time" @@ -163,10 +164,9 @@ func (f buildFixture) convert(api *API) ([]codersdk.WorkspaceBuild, error) { } // TestConvertWorkspaceBuildsAgentOrder covers the ordering that the shared -// index is responsible for: agents come back sorted by display order, and the -// order does not depend on how many builds read the same rows. The fixture -// assigns display order in reverse of agent name, so the expected result is -// names in descending order. +// index is responsible for: agents come back sorted by display order. The +// fixture assigns display order in reverse of agent name, so the expected +// result is names in descending order. func TestConvertWorkspaceBuildsAgentOrder(t *testing.T) { t.Parallel() @@ -188,6 +188,107 @@ func TestConvertWorkspaceBuildsAgentOrder(t *testing.T) { } } +// TestConvertWorkspaceBuildsAgentNameTiebreak covers the second half of the +// agent comparison: agents sharing a display order are ordered by name. +func TestConvertWorkspaceBuildsAgentNameTiebreak(t *testing.T) { + t.Parallel() + + api := newConvertAPI() + fixture := newBuildFixture(1, 1, 3, 1) + for i := range fixture.agents { + fixture.agents[i].DisplayOrder = 0 + } + + builds, err := fixture.convert(api) + require.NoError(t, err) + require.Len(t, builds, 1) + require.Len(t, builds[0].Resources, 1) + + agents := builds[0].Resources[0].Agents + require.Len(t, agents, 3) + for i := 1; i < len(agents); i++ { + require.Less(t, agents[i-1].Name, agents[i].Name) + } +} + +// TestConvertWorkspaceBuildsRowsPerBuild asserts that a build receives only the +// rows keyed to its own job and agents. The fixture encodes the build index in +// every row name, so rows read from another build are detectable by name. +func TestConvertWorkspaceBuildsRowsPerBuild(t *testing.T) { + t.Parallel() + + const ( + buildCount = 3 + resourcesPerJob = 2 + agentsPerRes = 2 + appsPerAgent = 2 + ) + + api := newConvertAPI() + fixture := newBuildFixture(buildCount, resourcesPerJob, agentsPerRes, appsPerAgent) + + builds, err := fixture.convert(api) + require.NoError(t, err) + require.Len(t, builds, buildCount) + + for b, build := range builds { + require.Len(t, build.Resources, resourcesPerJob) + + for _, resource := range build.Resources { + require.Equal(t, fixture.jobs[b].ProvisionerJob.ID, resource.JobID) + require.True(t, strings.HasPrefix(resource.Name, fmt.Sprintf("resource-%d-", b)), resource.Name) + require.Len(t, resource.Metadata, 1) + require.Equal(t, "key", resource.Metadata[0].Key) + require.Equal(t, "value", resource.Metadata[0].Value) + require.Len(t, resource.Agents, agentsPerRes) + + for _, agent := range resource.Agents { + require.True(t, strings.HasPrefix(agent.Name, fmt.Sprintf("agent-%d-", b)), agent.Name) + require.Len(t, agent.Scripts, 1) + require.Equal(t, "echo hello", agent.Scripts[0].Script) + require.Len(t, agent.LogSources, 1) + require.Equal(t, "startup", agent.LogSources[0].DisplayName) + require.Len(t, agent.Apps, appsPerAgent) + + for _, app := range agent.Apps { + require.True(t, strings.HasPrefix(app.Slug, fmt.Sprintf("app-%d-", b)), app.Slug) + require.Len(t, app.Statuses, 1) + require.Equal(t, app.ID, app.Statuses[0].AppID) + } + } + } + } +} + +// TestConvertWorkspaceBuildsMatchedProvisioners asserts that eligible daemons +// are matched to the build whose job they were fetched for. The last build in +// the batch has no daemon rows, so it reports zero counts while the others +// report one. +func TestConvertWorkspaceBuildsMatchedProvisioners(t *testing.T) { + t.Parallel() + + api := newConvertAPI() + fixture := newBuildFixture(3, 1, 1, 1) + fixture.daemons = fixture.daemons[:len(fixture.daemons)-1] + + builds, err := fixture.convert(api) + require.NoError(t, err) + require.Len(t, builds, 3) + + for _, build := range builds[:2] { + require.NotNil(t, build.MatchedProvisioners) + require.Equal(t, 1, build.MatchedProvisioners.Count) + require.Equal(t, 1, build.MatchedProvisioners.Available) + require.True(t, build.MatchedProvisioners.MostRecentlySeen.Valid) + } + + last := builds[2] + require.NotNil(t, last.MatchedProvisioners) + require.Equal(t, 0, last.MatchedProvisioners.Count) + require.Equal(t, 0, last.MatchedProvisioners.Available) + require.False(t, last.MatchedProvisioners.MostRecentlySeen.Valid) +} + func BenchmarkConvertWorkspaceBuilds(b *testing.B) { for _, builds := range []int{1, 25, 100} { b.Run(fmt.Sprintf("Builds%d", builds), func(b *testing.B) { From 9bf84fb30fb4d2cf6a520c0554f25489e94f85e6 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Thu, 13 Aug 2026 18:55:41 +0000 Subject: [PATCH 4/5] docs(coderd): trim workspace build index comments Keep the two facts the field names do not carry: buckets are reordered in place during conversion, and the input slices are not modified or aliased. --- coderd/workspacebuilds.go | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index c9b53f515e3..070135e6d1c 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -1243,13 +1243,9 @@ func (api *API) workspaceBuildsData(ctx context.Context, workspaceBuilds []datab }, nil } -// workspaceBuildIndex groups the rows that convertWorkspaceBuild reads by the -// ID it looks them up with. It is built once per batch of builds because every -// build in a batch is converted from the same rows. -// +// workspaceBuildIndex groups build rows by the ID they are looked up with. // Conversion reorders rows within a bucket in place, so an index must be read -// by a single goroutine and cannot be retained beyond the conversion it was -// built for. +// by a single goroutine. type workspaceBuildIndex struct { resourcesByJobID map[uuid.UUID][]database.WorkspaceResource metadataByResourceID map[uuid.UUID][]database.WorkspaceResourceMetadatum @@ -1261,10 +1257,8 @@ type workspaceBuildIndex struct { daemonsByJobID map[uuid.UUID][]database.ProvisionerDaemon } -// newWorkspaceBuildIndex makes one pass over each slice. Rows are copied into -// freshly allocated per-ID slices, so the input slices are neither modified nor -// aliased. Reference-typed fields within a row, such as tags and string slices, -// still share memory with the input rows. +// newWorkspaceBuildIndex makes one pass over each slice, copying rows into +// per-ID buckets. The input slices are neither modified nor aliased. func newWorkspaceBuildIndex( workspaceResources []database.WorkspaceResource, resourceMetadata []database.WorkspaceResourceMetadatum, From fe3368a371809c86858945056526939abc70e71a Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Thu, 13 Aug 2026 19:16:31 +0000 Subject: [PATCH 5/5] test(coderd): trim redundant assertions from build conversion test Row counts and the resource job ID already pin the index keying, so drop the resource and agent name prefixes and the assertions on fixture field values, which cover conversion of individual rows rather than how rows are grouped. --- coderd/workspacebuilds_internal_test.go | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/coderd/workspacebuilds_internal_test.go b/coderd/workspacebuilds_internal_test.go index e58931b44ef..f7e9e55c04d 100644 --- a/coderd/workspacebuilds_internal_test.go +++ b/coderd/workspacebuilds_internal_test.go @@ -213,7 +213,7 @@ func TestConvertWorkspaceBuildsAgentNameTiebreak(t *testing.T) { // TestConvertWorkspaceBuildsRowsPerBuild asserts that a build receives only the // rows keyed to its own job and agents. The fixture encodes the build index in -// every row name, so rows read from another build are detectable by name. +// app slugs, so apps read from another build are detectable by slug. func TestConvertWorkspaceBuildsRowsPerBuild(t *testing.T) { t.Parallel() @@ -236,24 +236,17 @@ func TestConvertWorkspaceBuildsRowsPerBuild(t *testing.T) { for _, resource := range build.Resources { require.Equal(t, fixture.jobs[b].ProvisionerJob.ID, resource.JobID) - require.True(t, strings.HasPrefix(resource.Name, fmt.Sprintf("resource-%d-", b)), resource.Name) require.Len(t, resource.Metadata, 1) - require.Equal(t, "key", resource.Metadata[0].Key) - require.Equal(t, "value", resource.Metadata[0].Value) require.Len(t, resource.Agents, agentsPerRes) for _, agent := range resource.Agents { - require.True(t, strings.HasPrefix(agent.Name, fmt.Sprintf("agent-%d-", b)), agent.Name) require.Len(t, agent.Scripts, 1) - require.Equal(t, "echo hello", agent.Scripts[0].Script) require.Len(t, agent.LogSources, 1) - require.Equal(t, "startup", agent.LogSources[0].DisplayName) require.Len(t, agent.Apps, appsPerAgent) for _, app := range agent.Apps { require.True(t, strings.HasPrefix(app.Slug, fmt.Sprintf("app-%d-", b)), app.Slug) require.Len(t, app.Statuses, 1) - require.Equal(t, app.ID, app.Statuses[0].AppID) } } }