feat: notify users before workspace autostop (#26676)

This commit is contained in:
Jon Ayers
2026-06-26 01:25:23 -05:00
committed by GitHub
parent a163d43981
commit 637a801a41
11 changed files with 693 additions and 36 deletions
+20 -2
View File
@@ -5686,8 +5686,8 @@ func (q *querier) GetWorkspacesByTemplateID(ctx context.Context, templateID uuid
return q.db.GetWorkspacesByTemplateID(ctx, templateID)
}
func (q *querier) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]database.GetWorkspacesEligibleForTransitionRow, error) {
return q.db.GetWorkspacesEligibleForTransition(ctx, now)
func (q *querier) GetWorkspacesEligibleForLifecycleAction(ctx context.Context, now time.Time) ([]database.GetWorkspacesEligibleForLifecycleActionRow, error) {
return q.db.GetWorkspacesEligibleForLifecycleAction(ctx, now)
}
func (q *querier) GetWorkspacesForWorkspaceMetrics(ctx context.Context) ([]database.GetWorkspacesForWorkspaceMetricsRow, error) {
@@ -8414,6 +8414,24 @@ func (q *querier) UpdateWorkspaceBuildFlagsByID(ctx context.Context, arg databas
return q.db.UpdateWorkspaceBuildFlagsByID(ctx, arg)
}
func (q *querier) UpdateWorkspaceBuildNotifiedAutostopDeadline(ctx context.Context, arg database.UpdateWorkspaceBuildNotifiedAutostopDeadlineParams) error {
build, err := q.db.GetWorkspaceBuildByID(ctx, arg.ID)
if err != nil {
return err
}
workspace, err := q.db.GetWorkspaceByID(ctx, build.WorkspaceID)
if err != nil {
return err
}
err = q.authorizeContext(ctx, policy.ActionUpdate, workspace.RBACObject())
if err != nil {
return err
}
return q.db.UpdateWorkspaceBuildNotifiedAutostopDeadline(ctx, arg)
}
func (q *querier) UpdateWorkspaceBuildProvisionerStateByID(ctx context.Context, arg database.UpdateWorkspaceBuildProvisionerStateByIDParams) error {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil {
return err
+11 -2
View File
@@ -4142,6 +4142,15 @@ func (s *MethodTestSuite) TestWorkspace() {
dbm.EXPECT().UpdateWorkspaceBuildDeadlineByID(gomock.Any(), arg).Return(nil).AnyTimes()
check.Args(arg).Asserts(w, policy.ActionUpdate)
}))
s.Run("UpdateWorkspaceBuildNotifiedAutostopDeadline", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
w := testutil.Fake(s.T(), faker, database.Workspace{})
b := testutil.Fake(s.T(), faker, database.WorkspaceBuild{WorkspaceID: w.ID})
arg := database.UpdateWorkspaceBuildNotifiedAutostopDeadlineParams{ID: b.ID, NotifiedAutostopDeadline: b.Deadline}
dbm.EXPECT().GetWorkspaceBuildByID(gomock.Any(), b.ID).Return(b, nil).AnyTimes()
dbm.EXPECT().GetWorkspaceByID(gomock.Any(), w.ID).Return(w, nil).AnyTimes()
dbm.EXPECT().UpdateWorkspaceBuildNotifiedAutostopDeadline(gomock.Any(), arg).Return(nil).AnyTimes()
check.Args(arg).Asserts(w, policy.ActionUpdate)
}))
s.Run("UpdateWorkspaceBuildFlagsByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
u := testutil.Fake(s.T(), faker, database.User{})
o := testutil.Fake(s.T(), faker, database.Organization{})
@@ -5289,9 +5298,9 @@ func (s *MethodTestSuite) TestSystemFunctions() {
dbm.EXPECT().GetWorkspacesByTemplateID(gomock.Any(), id).Return([]database.WorkspaceTable{}, nil).AnyTimes()
check.Args(id).Asserts(rbac.ResourceSystem, policy.ActionRead)
}))
s.Run("GetWorkspacesEligibleForTransition", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
s.Run("GetWorkspacesEligibleForLifecycleAction", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
t := time.Time{}
dbm.EXPECT().GetWorkspacesEligibleForTransition(gomock.Any(), t).Return([]database.GetWorkspacesEligibleForTransitionRow{}, nil).AnyTimes()
dbm.EXPECT().GetWorkspacesEligibleForLifecycleAction(gomock.Any(), t).Return([]database.GetWorkspacesEligibleForLifecycleActionRow{}, nil).AnyTimes()
check.Args(t).Asserts()
}))
s.Run("InsertTemplateVersionVariable", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
+12 -4
View File
@@ -3858,11 +3858,11 @@ func (m queryMetricsStore) GetWorkspacesByTemplateID(ctx context.Context, templa
return r0, r1
}
func (m queryMetricsStore) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]database.GetWorkspacesEligibleForTransitionRow, error) {
func (m queryMetricsStore) GetWorkspacesEligibleForLifecycleAction(ctx context.Context, now time.Time) ([]database.GetWorkspacesEligibleForLifecycleActionRow, error) {
start := time.Now()
r0, r1 := m.s.GetWorkspacesEligibleForTransition(ctx, now)
m.queryLatencies.WithLabelValues("GetWorkspacesEligibleForTransition").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetWorkspacesEligibleForTransition").Inc()
r0, r1 := m.s.GetWorkspacesEligibleForLifecycleAction(ctx, now)
m.queryLatencies.WithLabelValues("GetWorkspacesEligibleForLifecycleAction").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetWorkspacesEligibleForLifecycleAction").Inc()
return r0, r1
}
@@ -5946,6 +5946,14 @@ func (m queryMetricsStore) UpdateWorkspaceBuildFlagsByID(ctx context.Context, ar
return r0
}
func (m queryMetricsStore) UpdateWorkspaceBuildNotifiedAutostopDeadline(ctx context.Context, arg database.UpdateWorkspaceBuildNotifiedAutostopDeadlineParams) error {
start := time.Now()
r0 := m.s.UpdateWorkspaceBuildNotifiedAutostopDeadline(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateWorkspaceBuildNotifiedAutostopDeadline").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateWorkspaceBuildNotifiedAutostopDeadline").Inc()
return r0
}
func (m queryMetricsStore) UpdateWorkspaceBuildProvisionerStateByID(ctx context.Context, arg database.UpdateWorkspaceBuildProvisionerStateByIDParams) error {
start := time.Now()
r0 := m.s.UpdateWorkspaceBuildProvisionerStateByID(ctx, arg)
+21 -7
View File
@@ -7212,19 +7212,19 @@ func (mr *MockStoreMockRecorder) GetWorkspacesByTemplateID(ctx, templateID any)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspacesByTemplateID", reflect.TypeOf((*MockStore)(nil).GetWorkspacesByTemplateID), ctx, templateID)
}
// GetWorkspacesEligibleForTransition mocks base method.
func (m *MockStore) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]database.GetWorkspacesEligibleForTransitionRow, error) {
// GetWorkspacesEligibleForLifecycleAction mocks base method.
func (m *MockStore) GetWorkspacesEligibleForLifecycleAction(ctx context.Context, now time.Time) ([]database.GetWorkspacesEligibleForLifecycleActionRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetWorkspacesEligibleForTransition", ctx, now)
ret0, _ := ret[0].([]database.GetWorkspacesEligibleForTransitionRow)
ret := m.ctrl.Call(m, "GetWorkspacesEligibleForLifecycleAction", ctx, now)
ret0, _ := ret[0].([]database.GetWorkspacesEligibleForLifecycleActionRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetWorkspacesEligibleForTransition indicates an expected call of GetWorkspacesEligibleForTransition.
func (mr *MockStoreMockRecorder) GetWorkspacesEligibleForTransition(ctx, now any) *gomock.Call {
// GetWorkspacesEligibleForLifecycleAction indicates an expected call of GetWorkspacesEligibleForLifecycleAction.
func (mr *MockStoreMockRecorder) GetWorkspacesEligibleForLifecycleAction(ctx, now any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspacesEligibleForTransition", reflect.TypeOf((*MockStore)(nil).GetWorkspacesEligibleForTransition), ctx, now)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspacesEligibleForLifecycleAction", reflect.TypeOf((*MockStore)(nil).GetWorkspacesEligibleForLifecycleAction), ctx, now)
}
// GetWorkspacesForWorkspaceMetrics mocks base method.
@@ -11151,6 +11151,20 @@ func (mr *MockStoreMockRecorder) UpdateWorkspaceBuildFlagsByID(ctx, arg any) *go
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceBuildFlagsByID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceBuildFlagsByID), ctx, arg)
}
// UpdateWorkspaceBuildNotifiedAutostopDeadline mocks base method.
func (m *MockStore) UpdateWorkspaceBuildNotifiedAutostopDeadline(ctx context.Context, arg database.UpdateWorkspaceBuildNotifiedAutostopDeadlineParams) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateWorkspaceBuildNotifiedAutostopDeadline", ctx, arg)
ret0, _ := ret[0].(error)
return ret0
}
// UpdateWorkspaceBuildNotifiedAutostopDeadline indicates an expected call of UpdateWorkspaceBuildNotifiedAutostopDeadline.
func (mr *MockStoreMockRecorder) UpdateWorkspaceBuildNotifiedAutostopDeadline(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceBuildNotifiedAutostopDeadline", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceBuildNotifiedAutostopDeadline), ctx, arg)
}
// UpdateWorkspaceBuildProvisionerStateByID mocks base method.
func (m *MockStore) UpdateWorkspaceBuildProvisionerStateByID(ctx context.Context, arg database.UpdateWorkspaceBuildProvisionerStateByIDParams) error {
m.ctrl.T.Helper()
+11 -1
View File
@@ -973,7 +973,11 @@ type sqlcQuerier interface {
GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) ([]GetWorkspacesRow, error)
GetWorkspacesAndAgentsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]GetWorkspacesAndAgentsByOwnerIDRow, error)
GetWorkspacesByTemplateID(ctx context.Context, templateID uuid.UUID) ([]WorkspaceTable, error)
GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]GetWorkspacesEligibleForTransitionRow, error)
// Returns workspaces the lifecycle executor must act on this tick. An
// "action" is a state transition (autostart/autostop/dormancy/delete), a
// dormancy mark (which has no build transition), or a one-time autostop
// reminder notification (which only stamps a marker, no transition).
GetWorkspacesEligibleForLifecycleAction(ctx context.Context, now time.Time) ([]GetWorkspacesEligibleForLifecycleActionRow, error)
GetWorkspacesForWorkspaceMetrics(ctx context.Context) ([]GetWorkspacesForWorkspaceMetricsRow, error)
// Reports whether the given file is referenced as cached module files by any
// template version in the given organization. Used to authorize provisioner
@@ -1472,6 +1476,12 @@ type sqlcQuerier interface {
UpdateWorkspaceBuildCostByID(ctx context.Context, arg UpdateWorkspaceBuildCostByIDParams) error
UpdateWorkspaceBuildDeadlineByID(ctx context.Context, arg UpdateWorkspaceBuildDeadlineByIDParams) error
UpdateWorkspaceBuildFlagsByID(ctx context.Context, arg UpdateWorkspaceBuildFlagsByIDParams) error
// Stamps the deadline value that an autostop reminder was last sent for. Once
// this equals the build's deadline the reminder is considered handled and the
// lifecycle executor will not send another for this deadline, which makes the
// reminder idempotent and HA-safe. It re-arms automatically when the deadline
// changes (e.g. an activity bump).
UpdateWorkspaceBuildNotifiedAutostopDeadline(ctx context.Context, arg UpdateWorkspaceBuildNotifiedAutostopDeadlineParams) error
UpdateWorkspaceBuildProvisionerStateByID(ctx context.Context, arg UpdateWorkspaceBuildProvisionerStateByIDParams) error
UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateWorkspaceDeletedByIDParams) error
UpdateWorkspaceDormantDeletingAt(ctx context.Context, arg UpdateWorkspaceDormantDeletingAtParams) (WorkspaceTable, error)
+63 -6
View File
@@ -36200,6 +36200,31 @@ func (q *sqlQuerier) UpdateWorkspaceBuildFlagsByID(ctx context.Context, arg Upda
return err
}
const updateWorkspaceBuildNotifiedAutostopDeadline = `-- name: UpdateWorkspaceBuildNotifiedAutostopDeadline :exec
UPDATE
workspace_builds
SET
notified_autostop_deadline = $1::timestamptz,
updated_at = $2::timestamptz
WHERE id = $3::uuid
`
type UpdateWorkspaceBuildNotifiedAutostopDeadlineParams struct {
NotifiedAutostopDeadline time.Time `db:"notified_autostop_deadline" json:"notified_autostop_deadline"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
ID uuid.UUID `db:"id" json:"id"`
}
// Stamps the deadline value that an autostop reminder was last sent for. Once
// this equals the build's deadline the reminder is considered handled and the
// lifecycle executor will not send another for this deadline, which makes the
// reminder idempotent and HA-safe. It re-arms automatically when the deadline
// changes (e.g. an activity bump).
func (q *sqlQuerier) UpdateWorkspaceBuildNotifiedAutostopDeadline(ctx context.Context, arg UpdateWorkspaceBuildNotifiedAutostopDeadlineParams) error {
_, err := q.db.ExecContext(ctx, updateWorkspaceBuildNotifiedAutostopDeadline, arg.NotifiedAutostopDeadline, arg.UpdatedAt, arg.ID)
return err
}
const updateWorkspaceBuildProvisionerStateByID = `-- name: UpdateWorkspaceBuildProvisionerStateByID :exec
UPDATE
workspace_builds
@@ -38083,7 +38108,7 @@ func (q *sqlQuerier) GetWorkspacesByTemplateID(ctx context.Context, templateID u
return items, nil
}
const getWorkspacesEligibleForTransition = `-- name: GetWorkspacesEligibleForTransition :many
const getWorkspacesEligibleForLifecycleAction = `-- name: GetWorkspacesEligibleForLifecycleAction :many
SELECT
workspaces.id,
workspaces.name,
@@ -38206,6 +38231,34 @@ WHERE
provisioner_jobs.job_status = 'failed'::provisioner_job_status AND
provisioner_jobs.completed_at IS NOT NULL AND
($1 :: timestamptz) - provisioner_jobs.completed_at > (INTERVAL '1 millisecond' * (templates.failure_ttl / 1000000))
) OR
-- A workspace may be eligible for an autostop reminder if the following are true:
-- * The latest build is a successfully provisioned start build.
-- * The workspace is not dormant and its owner is not suspended.
-- * The build has a deadline in the future (we never remind about a stop already due).
-- * The template opts in (time_til_autostop_notify > 0) and now is within the lead window.
-- * A reminder has not yet been sent for THIS deadline.
--
-- NOTE: time_til_autostop_notify has no upper bound. If it exceeds a
-- workspace's remaining lifetime, the notify window already includes "now"
-- at build creation. This arm intentionally still only matches builds whose
-- deadline is in the future (deadline > now) and whose marker has not yet
-- been stamped (notified_autostop_deadline != deadline), so at most ONE
-- reminder is ever produced for a given deadline regardless of how large the
-- field is. The field is stored in nanoseconds, so convert to an interval
-- the same way the dormancy arm does: nanoseconds / 1000000 yields
-- milliseconds.
(
provisioner_jobs.job_status = 'succeeded'::provisioner_job_status AND
workspace_builds.transition = 'start'::workspace_transition AND
workspaces.dormant_at IS NULL AND
users.status != 'suspended'::user_status AND
workspace_builds.deadline != '0001-01-01 00:00:00+00'::timestamptz AND
workspace_builds.deadline > $1::timestamptz AND
templates.time_til_autostop_notify > 0 AND
workspace_builds.deadline <= ($1::timestamptz) + (INTERVAL '1 millisecond' * (templates.time_til_autostop_notify / 1000000)) AND
workspace_builds.notified_autostop_deadline != workspace_builds.deadline
)
)
AND workspaces.deleted = 'false'
@@ -38215,21 +38268,25 @@ WHERE
AND workspaces.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID
`
type GetWorkspacesEligibleForTransitionRow struct {
type GetWorkspacesEligibleForLifecycleActionRow struct {
ID uuid.UUID `db:"id" json:"id"`
Name string `db:"name" json:"name"`
BuildTemplateVersionID uuid.NullUUID `db:"build_template_version_id" json:"build_template_version_id"`
}
func (q *sqlQuerier) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]GetWorkspacesEligibleForTransitionRow, error) {
rows, err := q.db.QueryContext(ctx, getWorkspacesEligibleForTransition, now)
// Returns workspaces the lifecycle executor must act on this tick. An
// "action" is a state transition (autostart/autostop/dormancy/delete), a
// dormancy mark (which has no build transition), or a one-time autostop
// reminder notification (which only stamps a marker, no transition).
func (q *sqlQuerier) GetWorkspacesEligibleForLifecycleAction(ctx context.Context, now time.Time) ([]GetWorkspacesEligibleForLifecycleActionRow, error) {
rows, err := q.db.QueryContext(ctx, getWorkspacesEligibleForLifecycleAction, now)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetWorkspacesEligibleForTransitionRow
var items []GetWorkspacesEligibleForLifecycleActionRow
for rows.Next() {
var i GetWorkspacesEligibleForTransitionRow
var i GetWorkspacesEligibleForLifecycleActionRow
if err := rows.Scan(&i.ID, &i.Name, &i.BuildTemplateVersionID); err != nil {
return nil, err
}
@@ -141,6 +141,19 @@ SET
updated_at = @updated_at::timestamptz
WHERE id = @id::uuid;
-- name: UpdateWorkspaceBuildNotifiedAutostopDeadline :exec
-- Stamps the deadline value that an autostop reminder was last sent for. Once
-- this equals the build's deadline the reminder is considered handled and the
-- lifecycle executor will not send another for this deadline, which makes the
-- reminder idempotent and HA-safe. It re-arms automatically when the deadline
-- changes (e.g. an activity bump).
UPDATE
workspace_builds
SET
notified_autostop_deadline = @notified_autostop_deadline::timestamptz,
updated_at = @updated_at::timestamptz
WHERE id = @id::uuid;
-- name: GetActiveWorkspaceBuildsByTemplateID :many
SELECT wb.*
FROM (
+33 -1
View File
@@ -739,7 +739,11 @@ SELECT
stopped_workspaces.count AS stopped_workspaces
FROM pending_workspaces, building_workspaces, running_workspaces, failed_workspaces, stopped_workspaces;
-- name: GetWorkspacesEligibleForTransition :many
-- name: GetWorkspacesEligibleForLifecycleAction :many
-- Returns workspaces the lifecycle executor must act on this tick. An
-- "action" is a state transition (autostart/autostop/dormancy/delete), a
-- dormancy mark (which has no build transition), or a one-time autostop
-- reminder notification (which only stamps a marker, no transition).
SELECT
workspaces.id,
workspaces.name,
@@ -862,6 +866,34 @@ WHERE
provisioner_jobs.job_status = 'failed'::provisioner_job_status AND
provisioner_jobs.completed_at IS NOT NULL AND
(@now :: timestamptz) - provisioner_jobs.completed_at > (INTERVAL '1 millisecond' * (templates.failure_ttl / 1000000))
) OR
-- A workspace may be eligible for an autostop reminder if the following are true:
-- * The latest build is a successfully provisioned start build.
-- * The workspace is not dormant and its owner is not suspended.
-- * The build has a deadline in the future (we never remind about a stop already due).
-- * The template opts in (time_til_autostop_notify > 0) and now is within the lead window.
-- * A reminder has not yet been sent for THIS deadline.
--
-- NOTE: time_til_autostop_notify has no upper bound. If it exceeds a
-- workspace's remaining lifetime, the notify window already includes "now"
-- at build creation. This arm intentionally still only matches builds whose
-- deadline is in the future (deadline > now) and whose marker has not yet
-- been stamped (notified_autostop_deadline != deadline), so at most ONE
-- reminder is ever produced for a given deadline regardless of how large the
-- field is. The field is stored in nanoseconds, so convert to an interval
-- the same way the dormancy arm does: nanoseconds / 1000000 yields
-- milliseconds.
(
provisioner_jobs.job_status = 'succeeded'::provisioner_job_status AND
workspace_builds.transition = 'start'::workspace_transition AND
workspaces.dormant_at IS NULL AND
users.status != 'suspended'::user_status AND
workspace_builds.deadline != '0001-01-01 00:00:00+00'::timestamptz AND
workspace_builds.deadline > @now::timestamptz AND
templates.time_til_autostop_notify > 0 AND
workspace_builds.deadline <= (@now::timestamptz) + (INTERVAL '1 millisecond' * (templates.time_til_autostop_notify / 1000000)) AND
workspace_builds.notified_autostop_deadline != workspace_builds.deadline
)
)
AND workspaces.deleted = 'false'