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
+16
View File
@@ -39,6 +39,11 @@ const (
// long enough to cover the maximum interval of a heartbeat event (currently
// 1 hour) plus some buffer.
maxTelemetryHeartbeatAge = 24 * time.Hour
// Operational handoff state; terminal rows are kept for debugging, then
// purged.
workspaceBuildOrchestrationTerminalRetention = 24 * time.Hour
// Batch size for workspace build orchestration deletion.
workspaceBuildOrchestrationsBatchSize = 10000
// Chat and chat file batch sizes stay smaller than audit/connection
// log batches because chat_files rows carry bytea blobs.
chatsBatchSize = 1000
@@ -275,6 +280,15 @@ func (i *instance) purgeTick(ctx context.Context, db database.Store, start time.
}
}
deleteOldWorkspaceBuildOrchestrationsBefore := start.Add(-workspaceBuildOrchestrationTerminalRetention)
purgedWorkspaceBuildOrchestrations, err := tx.DeleteOldWorkspaceBuildOrchestrations(ctx, database.DeleteOldWorkspaceBuildOrchestrationsParams{
BeforeTime: deleteOldWorkspaceBuildOrchestrationsBefore,
LimitCount: workspaceBuildOrchestrationsBatchSize,
})
if err != nil {
return xerrors.Errorf("failed to delete old workspace build orchestrations: %w", err)
}
var purgedChats, purgedChatFiles, purgedChatDebugRuns int64
if purgeChats {
purgedChats, purgedChatFiles, err = i.purgeChatsInTx(ctx, tx, start, chatRetentionDays)
@@ -304,6 +318,7 @@ func (i *instance) purgeTick(ctx context.Context, db database.Store, start time.
slog.F("audit_logs", purgedAuditLogs),
slog.F("boundary_logs", purgedBoundaryLogs),
slog.F("boundary_sessions", purgedBoundarySessions),
slog.F("workspace_build_orchestrations", purgedWorkspaceBuildOrchestrations),
slog.F("chats", purgedChats),
slog.F("chat_files", purgedChatFiles),
slog.F("chat_debug_runs", purgedChatDebugRuns),
@@ -318,6 +333,7 @@ func (i *instance) purgeTick(ctx context.Context, db database.Store, start time.
i.recordsPurged.WithLabelValues("audit_logs").Add(float64(purgedAuditLogs))
i.recordsPurged.WithLabelValues("boundary_logs").Add(float64(purgedBoundaryLogs))
i.recordsPurged.WithLabelValues("boundary_sessions").Add(float64(purgedBoundarySessions))
i.recordsPurged.WithLabelValues("workspace_build_orchestrations").Add(float64(purgedWorkspaceBuildOrchestrations))
i.recordsPurged.WithLabelValues("chats").Add(float64(purgedChats))
i.recordsPurged.WithLabelValues("chat_debug_runs").Add(float64(purgedChatDebugRuns))
i.recordsPurged.WithLabelValues("chat_files").Add(float64(purgedChatFiles))
+147
View File
@@ -129,6 +129,11 @@ func TestMetrics(t *testing.T) {
})
require.GreaterOrEqual(t, auditLogs, 0)
workspaceBuildOrchestrations := promhelp.CounterValue(t, reg, "coderd_dbpurge_records_purged_total", prometheus.Labels{
"record_type": "workspace_build_orchestrations",
})
require.GreaterOrEqual(t, workspaceBuildOrchestrations, 0)
chats := promhelp.CounterValue(t, reg, "coderd_dbpurge_records_purged_total", prometheus.Labels{
"record_type": "chats",
})
@@ -247,6 +252,7 @@ func TestMetrics(t *testing.T) {
mDB.EXPECT().DeleteOldNotificationMessages(gomock.Any()).Return(nil).AnyTimes()
mDB.EXPECT().ExpirePrebuildsAPIKeys(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
mDB.EXPECT().DeleteOldTelemetryLocks(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
mDB.EXPECT().DeleteOldWorkspaceBuildOrchestrations(gomock.Any(), gomock.Any()).Return(int64(0), nil).AnyTimes()
mDB.EXPECT().DeleteOldAuditLogConnectionEvents(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
mDB.EXPECT().DeleteOldChatDebugRuns(gomock.Any(), gomock.AssignableToTypeOf(database.DeleteOldChatDebugRunsParams{})).Return(int64(0), nil).MinTimes(1)
mDB.EXPECT().InTx(gomock.Any(), database.DefaultTXOptions().WithID("db_purge")).
@@ -297,6 +303,7 @@ func TestMetrics(t *testing.T) {
mDB.EXPECT().DeleteOldNotificationMessages(gomock.Any()).Return(nil).AnyTimes()
mDB.EXPECT().ExpirePrebuildsAPIKeys(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
mDB.EXPECT().DeleteOldTelemetryLocks(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
mDB.EXPECT().DeleteOldWorkspaceBuildOrchestrations(gomock.Any(), gomock.Any()).Return(int64(0), nil).AnyTimes()
mDB.EXPECT().DeleteOldAuditLogConnectionEvents(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
mDB.EXPECT().DeleteOldChats(gomock.Any(), gomock.AssignableToTypeOf(database.DeleteOldChatsParams{})).Return(int64(0), nil).MinTimes(1)
mDB.EXPECT().DeleteOldChatFiles(gomock.Any(), gomock.AssignableToTypeOf(database.DeleteOldChatFilesParams{})).Return(int64(0), nil).MinTimes(1)
@@ -1117,6 +1124,146 @@ func TestDeleteOldTelemetryHeartbeats(t *testing.T) {
}, testutil.WaitShort, testutil.IntervalFast, "it should delete old telemetry heartbeats")
}
func TestDeleteOldWorkspaceBuildOrchestrations(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
db, _, rawDB := dbtestutil.NewDBWithSQLDB(t)
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,
JobID: versionJob.ID,
CreatedBy: user.ID,
})
template := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
ActiveVersionID: version.ID,
CreatedBy: user.ID,
})
workspace := dbgen.Workspace(t, db, database.WorkspaceTable{
OwnerID: user.ID,
OrganizationID: org.ID,
TemplateID: template.ID,
})
now := dbtime.Now()
cutoff := now.Add(-24 * time.Hour)
buildTime := cutoff.Add(-time.Hour)
oldCompletedTime := cutoff.Add(-3 * time.Minute)
oldFailedTime := cutoff.Add(-2 * time.Minute)
oldCanceledTime := cutoff.Add(-time.Minute)
oldPendingTime := cutoff.Add(-time.Minute)
recentTime := cutoff.Add(time.Minute)
createBuild := func(buildNumber int32, createdAt time.Time) database.WorkspaceBuild {
return mustCreateWorkspaceBuild(t, db, org, version, workspace.ID, createdAt, buildNumber)
}
insertOrchestration := func(parentBuild database.WorkspaceBuild, updatedAt time.Time) database.WorkspaceBuildOrchestration {
orchestration, err := db.InsertWorkspaceBuildOrchestration(ctx, database.InsertWorkspaceBuildOrchestrationParams{
ID: uuid.New(),
CreatedAt: updatedAt,
UpdatedAt: updatedAt,
ParentBuildID: parentBuild.ID,
ChildTransition: database.WorkspaceTransitionStart,
ChildRichParameterValues: json.RawMessage("[]"),
})
require.NoError(t, err)
return orchestration
}
// Given: old terminal orchestration rows (completed, failed,
// canceled), an old pending row, and a recent terminal row.
oldCompletedParent := createBuild(1, buildTime)
oldCompletedChild := createBuild(2, buildTime)
oldCompleted := insertOrchestration(oldCompletedParent, oldCompletedTime)
_, err := db.UpdateWorkspaceBuildOrchestrationCompletedByID(ctx, database.UpdateWorkspaceBuildOrchestrationCompletedByIDParams{
ID: oldCompleted.ID,
ChildBuildID: uuid.NullUUID{UUID: oldCompletedChild.ID, Valid: true},
UpdatedAt: oldCompletedTime,
})
require.NoError(t, err)
oldFailedParent := createBuild(3, buildTime)
oldFailed := insertOrchestration(oldFailedParent, oldFailedTime)
_, err = db.UpdateWorkspaceBuildOrchestrationFailedByID(ctx, database.UpdateWorkspaceBuildOrchestrationFailedByIDParams{
ID: oldFailed.ID,
Error: sql.NullString{String: "failed", Valid: true},
UpdatedAt: oldFailedTime,
})
require.NoError(t, err)
oldCanceledParent := createBuild(4, buildTime)
oldCanceled := insertOrchestration(oldCanceledParent, oldCanceledTime)
_, err = db.UpdateWorkspaceBuildOrchestrationCanceledByID(ctx, database.UpdateWorkspaceBuildOrchestrationCanceledByIDParams{
ID: oldCanceled.ID,
UpdatedAt: oldCanceledTime,
})
require.NoError(t, err)
oldPendingParent := createBuild(5, buildTime)
oldPending := insertOrchestration(oldPendingParent, oldPendingTime)
recentCompletedParent := createBuild(6, buildTime)
recentCompletedChild := createBuild(7, buildTime)
recentCompleted := insertOrchestration(recentCompletedParent, recentTime)
_, err = db.UpdateWorkspaceBuildOrchestrationCompletedByID(ctx, database.UpdateWorkspaceBuildOrchestrationCompletedByIDParams{
ID: recentCompleted.ID,
ChildBuildID: uuid.NullUUID{UUID: recentCompletedChild.ID, Valid: true},
UpdatedAt: recentTime,
})
require.NoError(t, err)
// When: old workspace build orchestrations are deleted with LimitCount 1
deleted, err := db.DeleteOldWorkspaceBuildOrchestrations(ctx, database.DeleteOldWorkspaceBuildOrchestrationsParams{
BeforeTime: cutoff,
LimitCount: 1,
})
require.NoError(t, err)
require.EqualValues(t, 1, deleted)
// Then: only the oldest terminal row is deleted.
assertOrchestrationDeleted(ctx, t, rawDB, oldCompletedParent.ID)
assertOrchestrationExists(ctx, t, rawDB, oldFailedParent.ID, oldFailed.ID)
assertOrchestrationExists(ctx, t, rawDB, oldCanceledParent.ID, oldCanceled.ID)
assertOrchestrationExists(ctx, t, rawDB, oldPendingParent.ID, oldPending.ID)
assertOrchestrationExists(ctx, t, rawDB, recentCompletedParent.ID, recentCompleted.ID)
// When: old workspace build orchestrations are deleted again.
deleted, err = db.DeleteOldWorkspaceBuildOrchestrations(ctx, database.DeleteOldWorkspaceBuildOrchestrationsParams{
BeforeTime: cutoff,
LimitCount: 10,
})
require.NoError(t, err)
require.EqualValues(t, 2, deleted)
// Then: the remaining old terminal rows are deleted.
assertOrchestrationDeleted(ctx, t, rawDB, oldFailedParent.ID)
assertOrchestrationDeleted(ctx, t, rawDB, oldCanceledParent.ID)
assertOrchestrationExists(ctx, t, rawDB, oldPendingParent.ID, oldPending.ID)
assertOrchestrationExists(ctx, t, rawDB, recentCompletedParent.ID, recentCompleted.ID)
}
func assertOrchestrationDeleted(ctx context.Context, t *testing.T, rawDB *sql.DB, parentBuildID uuid.UUID) {
t.Helper()
_, err := dbtestutil.GetWorkspaceBuildOrchestrationByParentBuildID(ctx, rawDB, parentBuildID)
require.ErrorIs(t, err, sql.ErrNoRows)
}
func assertOrchestrationExists(ctx context.Context, t *testing.T, rawDB *sql.DB, parentBuildID uuid.UUID, orchestrationID uuid.UUID) {
t.Helper()
orchestration, err := dbtestutil.GetWorkspaceBuildOrchestrationByParentBuildID(ctx, rawDB, parentBuildID)
require.NoError(t, err)
require.Equal(t, orchestrationID, orchestration.ID)
}
func TestDeleteOldConnectionLogs(t *testing.T) {
t.Parallel()