feat: add workspace restart functionality to API (#25757)

This models restart as durable orchestration of existing stop and
start workspace builds instead of adding a new restart transition.
Keeping restart as two existing transitions preserves the current
build/provisioner model.

The child start build is created only after the parent stop build
succeeds, rather than being inserted immediately in a pending
state. That keeps `workspace_builds` aligned with actual
provisioner-ready work and avoids introducing a second
pending-build lifecycle that the provisioner and build acquisition
paths would need to understand.

Refs: https://linear.app/codercom/issue/PLAT-143
This commit is contained in:
George K
2026-07-07 09:18:30 -07:00
committed by GitHub
parent ba094c5706
commit 6af0f4d698
45 changed files with 4412 additions and 144 deletions
+580
View File
@@ -8800,6 +8800,586 @@ func TestUserSecretsAuthorization(t *testing.T) {
}
}
func TestUpdateWorkspaceBuildOrchestrationRetryByIDMaxAttempts(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitShort)
org := dbgen.Organization(t, db, database.Organization{})
user := dbgen.User(t, db, database.User{})
versionJob := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
OrganizationID: org.ID,
Type: database.ProvisionerJobTypeTemplateVersionImport,
})
version := dbgen.TemplateVersion(t, db, database.TemplateVersion{
OrganizationID: org.ID,
CreatedBy: user.ID,
JobID: versionJob.ID,
})
template := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
ActiveVersionID: version.ID,
CreatedBy: user.ID,
})
workspace := dbgen.Workspace(t, db, database.WorkspaceTable{
OrganizationID: org.ID,
OwnerID: user.ID,
TemplateID: template.ID,
})
buildJob := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
OrganizationID: org.ID,
Type: database.ProvisionerJobTypeWorkspaceBuild,
})
parentBuild := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
WorkspaceID: workspace.ID,
TemplateVersionID: version.ID,
InitiatorID: user.ID,
JobID: buildJob.ID,
Transition: database.WorkspaceTransitionStop,
})
// Given: a pending orchestration row.
now := dbtime.Now()
orchestration, err := db.InsertWorkspaceBuildOrchestration(ctx, database.InsertWorkspaceBuildOrchestrationParams{
ID: uuid.New(),
CreatedAt: now,
UpdatedAt: now,
ParentBuildID: parentBuild.ID,
ChildTransition: database.WorkspaceTransitionStart,
ChildRichParameterValues: json.RawMessage("[]"),
})
require.NoError(t, err)
require.Equal(t, workspace.ID, orchestration.WorkspaceID)
const maxAttemptCount = 3
const retryError = "some retryable child build failure"
recordRetry := func(t *testing.T, wantAttempt int32, wantStatus string, wantNextRetry bool) {
t.Helper()
now := dbtime.Now()
nextRetryAfter := now.Add(time.Minute)
got, err := db.UpdateWorkspaceBuildOrchestrationRetryByID(ctx, database.UpdateWorkspaceBuildOrchestrationRetryByIDParams{
Error: sql.NullString{
String: retryError,
Valid: true,
},
NextRetryAfter: nextRetryAfter,
UpdatedAt: now,
ID: orchestration.ID,
MaxAttemptCount: maxAttemptCount,
})
require.NoError(t, err)
require.Equal(t, wantAttempt, got.AttemptCount)
require.Equal(t, wantStatus, got.Status)
require.True(t, got.Error.Valid)
require.Equal(t, retryError, got.Error.String)
require.Equal(t, wantNextRetry, got.NextRetryAfter.Valid)
if wantNextRetry {
require.False(t, got.NextRetryAfter.Time.Before(nextRetryAfter))
}
}
// When: retryable child build failures are recorded until one
// attempt remains.
recordRetry(t, 1, "pending", true)
recordRetry(t, 2, "pending", true)
// Then: the next attempt fails the row, clears its retry delay,
// and prevents further retry updates.
recordRetry(t, 3, "failed", false)
_, err = db.UpdateWorkspaceBuildOrchestrationRetryByID(ctx, database.UpdateWorkspaceBuildOrchestrationRetryByIDParams{
Error: sql.NullString{
String: retryError,
Valid: true,
},
NextRetryAfter: dbtime.Now().Add(time.Minute),
UpdatedAt: dbtime.Now(),
ID: orchestration.ID,
MaxAttemptCount: maxAttemptCount,
})
require.ErrorIs(t, err, sql.ErrNoRows)
}
func TestUpdateWorkspaceBuildOrchestrationRetryByIDPendingGateUnderContention(t *testing.T) {
t.Parallel()
db, _, _ := dbtestutil.NewDBWithSQLDB(t)
ctx := testutil.Context(t, testutil.WaitShort)
org := dbgen.Organization(t, db, database.Organization{})
user := dbgen.User(t, db, database.User{})
versionJob := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
OrganizationID: org.ID,
Type: database.ProvisionerJobTypeTemplateVersionImport,
})
version := dbgen.TemplateVersion(t, db, database.TemplateVersion{
OrganizationID: org.ID,
CreatedBy: user.ID,
JobID: versionJob.ID,
})
template := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
ActiveVersionID: version.ID,
CreatedBy: user.ID,
})
workspace := dbgen.Workspace(t, db, database.WorkspaceTable{
OrganizationID: org.ID,
OwnerID: user.ID,
TemplateID: template.ID,
})
buildJob := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
OrganizationID: org.ID,
Type: database.ProvisionerJobTypeWorkspaceBuild,
})
parentBuild := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
WorkspaceID: workspace.ID,
TemplateVersionID: version.ID,
InitiatorID: user.ID,
JobID: buildJob.ID,
Transition: database.WorkspaceTransitionStop,
})
// Given: a pending orchestration row.
now := dbtime.Now()
orchestration, err := db.InsertWorkspaceBuildOrchestration(ctx, database.InsertWorkspaceBuildOrchestrationParams{
ID: uuid.New(),
CreatedAt: now,
UpdatedAt: now,
ParentBuildID: parentBuild.ID,
ChildTransition: database.WorkspaceTransitionStart,
ChildRichParameterValues: json.RawMessage("[]"),
})
require.NoError(t, err)
const retryError = "some retryable child build failure"
type retryResult struct {
orchestration database.WorkspaceBuildOrchestration
err error
}
firstUpdated := make(chan retryResult, 1)
releaseFirst := make(chan struct{})
firstErr := make(chan error, 1)
secondStarted := make(chan struct{}, 1)
secondErr := make(chan error, 1)
secondDone := make(chan struct{})
// When: one retry update terminalizes the row while another
// worker races to retry the same pending row.
go func() {
err := db.InTx(func(tx database.Store) error {
got, err := tx.UpdateWorkspaceBuildOrchestrationRetryByID(ctx, database.UpdateWorkspaceBuildOrchestrationRetryByIDParams{
Error: sql.NullString{
String: retryError,
Valid: true,
},
NextRetryAfter: dbtime.Now().Add(time.Minute),
UpdatedAt: dbtime.Now(),
ID: orchestration.ID,
// Use a single allowed retry so the winning update
// immediately terminalizes the row.
MaxAttemptCount: 1,
})
if err != nil {
firstUpdated <- retryResult{err: err}
return err
}
firstUpdated <- retryResult{orchestration: got}
<-releaseFirst
return nil
}, nil)
firstErr <- err
}()
firstResult := <-firstUpdated
require.NoError(t, firstResult.err)
go func() {
secondStarted <- struct{}{}
_, err := db.UpdateWorkspaceBuildOrchestrationRetryByID(ctx, database.UpdateWorkspaceBuildOrchestrationRetryByIDParams{
Error: sql.NullString{
String: retryError,
Valid: true,
},
NextRetryAfter: dbtime.Now().Add(time.Minute),
UpdatedAt: dbtime.Now(),
ID: orchestration.ID,
MaxAttemptCount: 1,
})
secondErr <- err
close(secondDone)
}()
<-secondStarted
// Then: while the first transaction is held open, the second
// update does not complete.
require.Never(t, func() bool {
select {
case <-secondDone:
return true
default:
return false
}
}, time.Second, testutil.IntervalFast)
close(releaseFirst)
require.NoError(t, <-firstErr)
// Then: after the first transaction commits the failed status, the
// second update rechecks the pending gate and affects no rows.
require.ErrorIs(t, <-secondErr, sql.ErrNoRows)
}
func TestGetNextPendingWorkspaceBuildOrchestrationForUpdateRetryDelay(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitShort)
org := dbgen.Organization(t, db, database.Organization{})
user := dbgen.User(t, db, database.User{})
versionJob := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
OrganizationID: org.ID,
Type: database.ProvisionerJobTypeTemplateVersionImport,
})
version := dbgen.TemplateVersion(t, db, database.TemplateVersion{
OrganizationID: org.ID,
CreatedBy: user.ID,
JobID: versionJob.ID,
})
template := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
ActiveVersionID: version.ID,
CreatedBy: user.ID,
})
workspace := dbgen.Workspace(t, db, database.WorkspaceTable{
OrganizationID: org.ID,
OwnerID: user.ID,
TemplateID: template.ID,
})
var buildNumber int32
createOrchestration := func(t *testing.T, createdAt time.Time) database.WorkspaceBuildOrchestration {
t.Helper()
buildNumber++
buildJob := database.ProvisionerJob{
OrganizationID: org.ID,
Type: database.ProvisionerJobTypeWorkspaceBuild,
}
setJobStatus(t, database.ProvisionerJobStatusSucceeded, &buildJob)
buildJob = dbgen.ProvisionerJob(t, db, nil, buildJob)
parentBuild := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
WorkspaceID: workspace.ID,
TemplateVersionID: version.ID,
InitiatorID: user.ID,
JobID: buildJob.ID,
BuildNumber: buildNumber,
Transition: database.WorkspaceTransitionStop,
})
orchestration, err := db.InsertWorkspaceBuildOrchestration(ctx, database.InsertWorkspaceBuildOrchestrationParams{
ID: uuid.New(),
CreatedAt: createdAt,
UpdatedAt: createdAt,
ParentBuildID: parentBuild.ID,
ChildTransition: database.WorkspaceTransitionStart,
ChildRichParameterValues: json.RawMessage("[]"),
})
require.NoError(t, err)
require.Equal(t, workspace.ID, orchestration.WorkspaceID)
return orchestration
}
claimNext := func(t *testing.T) (database.WorkspaceBuildOrchestration, error) {
t.Helper()
var orchestration database.WorkspaceBuildOrchestration
err := db.InTx(func(tx database.Store) error {
var err error
orchestration, err = tx.GetNextPendingWorkspaceBuildOrchestrationForUpdate(ctx)
return err
}, nil)
return orchestration, err
}
baseTime := dbtime.Now().Add(-time.Hour)
// Given: an old pending orchestration row with a future retry delay.
delayed := createOrchestration(t, baseTime)
_, err := db.UpdateWorkspaceBuildOrchestrationRetryByID(ctx, database.UpdateWorkspaceBuildOrchestrationRetryByIDParams{
Error: sql.NullString{
String: "retry later",
Valid: true,
},
NextRetryAfter: dbtime.Now().Add(time.Hour),
UpdatedAt: dbtime.Now(),
ID: delayed.ID,
MaxAttemptCount: 3,
})
require.NoError(t, err)
// When: the orchestrator claims the next eligible row.
_, err = claimNext(t)
// Then: no row is claimed before the retry time.
require.ErrorIs(t, err, sql.ErrNoRows)
// When: a later row is eligible immediately.
eligible := createOrchestration(t, baseTime.Add(time.Minute))
// Then: the old delayed row does not block the later eligible row.
got, err := claimNext(t)
require.NoError(t, err)
require.Equal(t, eligible.ID, got.ID)
// When: the delayed row's retry time has passed.
_, err = db.UpdateWorkspaceBuildOrchestrationRetryByID(ctx, database.UpdateWorkspaceBuildOrchestrationRetryByIDParams{
Error: sql.NullString{
String: "retry now",
Valid: true,
},
NextRetryAfter: dbtime.Now().Add(-time.Minute),
UpdatedAt: dbtime.Now(),
ID: delayed.ID,
MaxAttemptCount: 3,
})
require.NoError(t, err)
// Then: the delayed row is eligible again and is claimed first
// because it is older than the later row.
got, err = claimNext(t)
require.NoError(t, err)
require.Equal(t, delayed.ID, got.ID)
}
func TestUpdateWorkspaceBuildOrchestrationCompletedByIDWorkspaceMismatch(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitShort)
org := dbgen.Organization(t, db, database.Organization{})
user := dbgen.User(t, db, database.User{})
versionJob := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
OrganizationID: org.ID,
Type: database.ProvisionerJobTypeTemplateVersionImport,
})
version := dbgen.TemplateVersion(t, db, database.TemplateVersion{
OrganizationID: org.ID,
CreatedBy: user.ID,
JobID: versionJob.ID,
})
template := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
ActiveVersionID: version.ID,
CreatedBy: user.ID,
})
parentWorkspace := dbgen.Workspace(t, db, database.WorkspaceTable{
OrganizationID: org.ID,
OwnerID: user.ID,
TemplateID: template.ID,
})
otherWorkspace := dbgen.Workspace(t, db, database.WorkspaceTable{
OrganizationID: org.ID,
OwnerID: user.ID,
TemplateID: template.ID,
})
parentJob := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
OrganizationID: org.ID,
Type: database.ProvisionerJobTypeWorkspaceBuild,
})
parentBuild := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
WorkspaceID: parentWorkspace.ID,
TemplateVersionID: version.ID,
InitiatorID: user.ID,
JobID: parentJob.ID,
Transition: database.WorkspaceTransitionStop,
})
// Given: a pending orchestration row.
orchestration, err := db.InsertWorkspaceBuildOrchestration(ctx, database.InsertWorkspaceBuildOrchestrationParams{
ID: uuid.New(),
CreatedAt: dbtime.Now(),
UpdatedAt: dbtime.Now(),
ParentBuildID: parentBuild.ID,
ChildTransition: database.WorkspaceTransitionStart,
ChildRichParameterValues: json.RawMessage("[]"),
})
require.NoError(t, err)
require.Equal(t, parentWorkspace.ID, orchestration.WorkspaceID)
// Given: a child build whose workspace does not match the parent
// build's workspace.
childJob := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
OrganizationID: org.ID,
Type: database.ProvisionerJobTypeWorkspaceBuild,
})
childBuild := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
WorkspaceID: otherWorkspace.ID,
TemplateVersionID: version.ID,
InitiatorID: user.ID,
JobID: childJob.ID,
Transition: database.WorkspaceTransitionStart,
})
// When: the orchestration is completed with the child build.
_, err = db.UpdateWorkspaceBuildOrchestrationCompletedByID(ctx, database.UpdateWorkspaceBuildOrchestrationCompletedByIDParams{
ID: orchestration.ID,
ChildBuildID: uuid.NullUUID{UUID: childBuild.ID, Valid: true},
UpdatedAt: dbtime.Now(),
})
// Then: the composite foreign key rejects the mismatched child build.
require.Error(t, err)
require.True(t, database.IsForeignKeyViolation(err))
}
func TestInsertWorkspaceBuildOrchestrationPresetRequiresVersion(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitShort)
org := dbgen.Organization(t, db, database.Organization{})
user := dbgen.User(t, db, database.User{})
versionJob := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
OrganizationID: org.ID,
Type: database.ProvisionerJobTypeTemplateVersionImport,
})
version := dbgen.TemplateVersion(t, db, database.TemplateVersion{
OrganizationID: org.ID,
CreatedBy: user.ID,
JobID: versionJob.ID,
})
template := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
ActiveVersionID: version.ID,
CreatedBy: user.ID,
})
workspace := dbgen.Workspace(t, db, database.WorkspaceTable{
OrganizationID: org.ID,
OwnerID: user.ID,
TemplateID: template.ID,
})
parentJob := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
OrganizationID: org.ID,
Type: database.ProvisionerJobTypeWorkspaceBuild,
})
// Given: a parent build, a child preset, no child template
// version.
parentBuild := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
WorkspaceID: workspace.ID,
TemplateVersionID: version.ID,
InitiatorID: user.ID,
JobID: parentJob.ID,
Transition: database.WorkspaceTransitionStop,
})
preset := dbgen.Preset(t, db, database.InsertPresetParams{
TemplateVersionID: version.ID,
})
// When: the orchestration row is inserted.
_, err := db.InsertWorkspaceBuildOrchestration(ctx, database.InsertWorkspaceBuildOrchestrationParams{
ID: uuid.New(),
CreatedAt: dbtime.Now(),
UpdatedAt: dbtime.Now(),
ParentBuildID: parentBuild.ID,
ChildTransition: database.WorkspaceTransitionStart,
ChildTemplateVersionPresetID: uuid.NullUUID{
UUID: preset.ID,
Valid: true,
},
ChildRichParameterValues: json.RawMessage("[]"),
})
// Then: the check constraint rejects the missing child template
// version.
require.Error(t, err)
require.True(t, database.IsCheckViolation(err))
}
func TestInsertWorkspaceBuildOrchestrationPresetVersionMismatch(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitShort)
org := dbgen.Organization(t, db, database.Organization{})
user := dbgen.User(t, db, database.User{})
versionOneJob := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
OrganizationID: org.ID,
Type: database.ProvisionerJobTypeTemplateVersionImport,
})
versionOne := dbgen.TemplateVersion(t, db, database.TemplateVersion{
OrganizationID: org.ID,
CreatedBy: user.ID,
JobID: versionOneJob.ID,
})
versionTwoJob := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
OrganizationID: org.ID,
Type: database.ProvisionerJobTypeTemplateVersionImport,
})
versionTwo := dbgen.TemplateVersion(t, db, database.TemplateVersion{
OrganizationID: org.ID,
CreatedBy: user.ID,
JobID: versionTwoJob.ID,
})
template := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
ActiveVersionID: versionOne.ID,
CreatedBy: user.ID,
})
workspace := dbgen.Workspace(t, db, database.WorkspaceTable{
OrganizationID: org.ID,
OwnerID: user.ID,
TemplateID: template.ID,
})
parentJob := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
OrganizationID: org.ID,
Type: database.ProvisionerJobTypeWorkspaceBuild,
})
// Given: a parent build and a child preset.
parentBuild := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
WorkspaceID: workspace.ID,
TemplateVersionID: versionOne.ID,
InitiatorID: user.ID,
JobID: parentJob.ID,
Transition: database.WorkspaceTransitionStop,
})
preset := dbgen.Preset(t, db, database.InsertPresetParams{
TemplateVersionID: versionOne.ID,
})
// When: the orchestration row is inserted with a different child
// template version.
_, err := db.InsertWorkspaceBuildOrchestration(ctx, database.InsertWorkspaceBuildOrchestrationParams{
ID: uuid.New(),
CreatedAt: dbtime.Now(),
UpdatedAt: dbtime.Now(),
ParentBuildID: parentBuild.ID,
ChildTransition: database.WorkspaceTransitionStart,
ChildTemplateVersionID: uuid.NullUUID{
UUID: versionTwo.ID,
Valid: true,
},
ChildTemplateVersionPresetID: uuid.NullUUID{
UUID: preset.ID,
Valid: true,
},
ChildRichParameterValues: json.RawMessage("[]"),
})
// Then: the composite foreign key rejects the preset/version
// mismatch.
require.Error(t, err)
require.True(t, database.IsForeignKeyViolation(err))
}
func TestWorkspaceBuildDeadlineConstraint(t *testing.T) {
t.Parallel()