diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index 32b45bd6f9f..070135e6d1c 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -1,6 +1,7 @@ package coderd import ( + "cmp" "context" "database/sql" "encoding/json" @@ -81,15 +82,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 +294,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 +621,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 +1243,78 @@ func (api *API) workspaceBuildsData(ctx context.Context, workspaceBuilds []datab }, nil } +// 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. +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, copying rows into +// per-ID buckets. The input slices are neither modified nor aliased. +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 within a resource are ordered by display order, then name. + for _, agents := range index.agentsByResourceID { + 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 +} + func (api *API) convertWorkspaceBuilds( workspaceBuilds []database.WorkspaceBuild, workspaces []database.Workspace, @@ -1272,6 +1342,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 +1373,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 +1390,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 +1407,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 +1420,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..f7e9e55c04d --- /dev/null +++ b/coderd/workspacebuilds_internal_test.go @@ -0,0 +1,299 @@ +package coderd + +import ( + "database/sql" + "fmt" + "strings" + "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. 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) + } + } + } +} + +// 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 +// app slugs, so apps read from another build are detectable by slug. +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.Len(t, resource.Metadata, 1) + require.Len(t, resource.Agents, agentsPerRes) + + for _, agent := range resource.Agents { + require.Len(t, agent.Scripts, 1) + require.Len(t, agent.LogSources, 1) + 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) + } + } + } + } +} + +// 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) { + 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{