diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index c855d5a1984df..218d6681dc771 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2801,7 +2801,7 @@ func (q *querier) GetWorkspacesAndAgentsByOwnerID(ctx context.Context, ownerID u return q.db.GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx, ownerID, prep) } -func (q *querier) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]database.WorkspaceTable, error) { +func (q *querier) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]database.GetWorkspacesEligibleForTransitionRow, error) { return q.db.GetWorkspacesEligibleForTransition(ctx, now) } diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 9a306db09785e..b465adb96e96d 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -6868,60 +6868,99 @@ func (q *FakeQuerier) GetWorkspacesAndAgentsByOwnerID(ctx context.Context, owner return q.GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx, ownerID, nil) } -func (q *FakeQuerier) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]database.WorkspaceTable, error) { +func (q *FakeQuerier) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]database.GetWorkspacesEligibleForTransitionRow, error) { q.mutex.RLock() defer q.mutex.RUnlock() - workspaces := []database.WorkspaceTable{} + workspaces := []database.GetWorkspacesEligibleForTransitionRow{} for _, workspace := range q.workspaces { build, err := q.getLatestWorkspaceBuildByWorkspaceIDNoLock(ctx, workspace.ID) if err != nil { - return nil, err - } - - if build.Transition == database.WorkspaceTransitionStart && - !build.Deadline.IsZero() && - build.Deadline.Before(now) && - !workspace.DormantAt.Valid { - workspaces = append(workspaces, workspace) - continue + return nil, xerrors.Errorf("get workspace build by ID: %w", err) } - if build.Transition == database.WorkspaceTransitionStop && - workspace.AutostartSchedule.Valid && - !workspace.DormantAt.Valid { - workspaces = append(workspaces, workspace) - continue + user, err := q.getUserByIDNoLock(workspace.OwnerID) + if err != nil { + return nil, xerrors.Errorf("get user by ID: %w", err) } job, err := q.getProvisionerJobByIDNoLock(ctx, build.JobID) if err != nil { return nil, xerrors.Errorf("get provisioner job by ID: %w", err) } - if codersdk.ProvisionerJobStatus(job.JobStatus) == codersdk.ProvisionerJobFailed { - workspaces = append(workspaces, workspace) - continue - } template, err := q.getTemplateByIDNoLock(ctx, workspace.TemplateID) if err != nil { return nil, xerrors.Errorf("get template by ID: %w", err) } - if !workspace.DormantAt.Valid && template.TimeTilDormant > 0 { - workspaces = append(workspaces, workspace) + + if workspace.Deleted { continue } - if workspace.DormantAt.Valid && template.TimeTilDormantAutoDelete > 0 { - workspaces = append(workspaces, workspace) + + if job.JobStatus != database.ProvisionerJobStatusFailed && + !workspace.DormantAt.Valid && + build.Transition == database.WorkspaceTransitionStart && + (user.Status == database.UserStatusSuspended || (!build.Deadline.IsZero() && build.Deadline.Before(now))) { + workspaces = append(workspaces, database.GetWorkspacesEligibleForTransitionRow{ + ID: workspace.ID, + Name: workspace.Name, + }) continue } - user, err := q.getUserByIDNoLock(workspace.OwnerID) - if err != nil { - return nil, xerrors.Errorf("get user by ID: %w", err) + if user.Status == database.UserStatusActive && + job.JobStatus != database.ProvisionerJobStatusFailed && + build.Transition == database.WorkspaceTransitionStop && + workspace.AutostartSchedule.Valid { + workspaces = append(workspaces, database.GetWorkspacesEligibleForTransitionRow{ + ID: workspace.ID, + Name: workspace.Name, + }) + continue } - if user.Status == database.UserStatusSuspended && build.Transition == database.WorkspaceTransitionStart { - workspaces = append(workspaces, workspace) + + if !workspace.DormantAt.Valid && + template.TimeTilDormant > 0 && + now.Sub(workspace.LastUsedAt) > time.Duration(template.TimeTilDormant) { + workspaces = append(workspaces, database.GetWorkspacesEligibleForTransitionRow{ + ID: workspace.ID, + Name: workspace.Name, + }) + continue + } + + if workspace.DormantAt.Valid && + workspace.DeletingAt.Valid && + workspace.DeletingAt.Time.Before(now) && + template.TimeTilDormantAutoDelete > 0 { + if build.Transition == database.WorkspaceTransitionDelete && + job.JobStatus == database.ProvisionerJobStatusFailed { + if job.CanceledAt.Valid && now.Sub(job.CanceledAt.Time) <= 24*time.Hour { + continue + } + + if job.CompletedAt.Valid && now.Sub(job.CompletedAt.Time) <= 24*time.Hour { + continue + } + } + + workspaces = append(workspaces, database.GetWorkspacesEligibleForTransitionRow{ + ID: workspace.ID, + Name: workspace.Name, + }) + continue + } + + if template.FailureTTL > 0 && + build.Transition == database.WorkspaceTransitionStart && + job.JobStatus == database.ProvisionerJobStatusFailed && + job.CompletedAt.Valid && + now.Sub(job.CompletedAt.Time) > time.Duration(template.FailureTTL) { + workspaces = append(workspaces, database.GetWorkspacesEligibleForTransitionRow{ + ID: workspace.ID, + Name: workspace.Name, + }) continue } } diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index cee25e482bbaa..b2edda7a8fff4 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1659,7 +1659,7 @@ func (m queryMetricsStore) GetWorkspacesAndAgentsByOwnerID(ctx context.Context, return r0, r1 } -func (m queryMetricsStore) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]database.WorkspaceTable, error) { +func (m queryMetricsStore) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]database.GetWorkspacesEligibleForTransitionRow, error) { start := time.Now() workspaces, err := m.s.GetWorkspacesEligibleForTransition(ctx, now) m.queryLatencies.WithLabelValues("GetWorkspacesEligibleForAutoStartStop").Observe(time.Since(start).Seconds()) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index d8721f56d3f4e..cb5ebd69f89e7 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -3503,10 +3503,10 @@ func (mr *MockStoreMockRecorder) GetWorkspacesAndAgentsByOwnerID(arg0, arg1 any) } // GetWorkspacesEligibleForTransition mocks base method. -func (m *MockStore) GetWorkspacesEligibleForTransition(arg0 context.Context, arg1 time.Time) ([]database.WorkspaceTable, error) { +func (m *MockStore) GetWorkspacesEligibleForTransition(arg0 context.Context, arg1 time.Time) ([]database.GetWorkspacesEligibleForTransitionRow, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetWorkspacesEligibleForTransition", arg0, arg1) - ret0, _ := ret[0].([]database.WorkspaceTable) + ret0, _ := ret[0].([]database.GetWorkspacesEligibleForTransitionRow) ret1, _ := ret[1].(error) return ret0, ret1 } diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 46d1b1ae5b322..297e549f1e300 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -346,7 +346,7 @@ type sqlcQuerier interface { // be used in a WHERE clause. GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) ([]GetWorkspacesRow, error) GetWorkspacesAndAgentsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]GetWorkspacesAndAgentsByOwnerIDRow, error) - GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]WorkspaceTable, error) + GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]GetWorkspacesEligibleForTransitionRow, error) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error) // We use the organization_id as the id // for simplicity since all users is diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 87d3c17f5400f..14afd75403c89 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -15363,7 +15363,8 @@ func (q *sqlQuerier) GetWorkspacesAndAgentsByOwnerID(ctx context.Context, ownerI const getWorkspacesEligibleForTransition = `-- name: GetWorkspacesEligibleForTransition :many SELECT - workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite + workspaces.id, + workspaces.name FROM workspaces LEFT JOIN @@ -15385,82 +15386,104 @@ WHERE ) AND ( - -- If the workspace build was a start transition, the workspace is - -- potentially eligible for autostop if it's past the deadline. The - -- deadline is computed at build time upon success and is bumped based - -- on activity (up the max deadline if set). We don't need to check - -- license here since that's done when the values are written to the build. + -- A workspace may be eligible for autostop if the following are true: + -- * The provisioner job has not failed. + -- * The workspace is not dormant. + -- * The workspace build was a start transition. + -- * The workspace's owner is suspended OR the workspace build deadline has passed. ( - workspace_builds.transition = 'start'::workspace_transition AND - workspace_builds.deadline IS NOT NULL AND - workspace_builds.deadline < $1 :: timestamptz + provisioner_jobs.job_status != 'failed'::provisioner_job_status AND + workspaces.dormant_at IS NULL AND + workspace_builds.transition = 'start'::workspace_transition AND ( + users.status = 'suspended'::user_status OR ( + workspace_builds.deadline != '0001-01-01 00:00:00+00'::timestamptz AND + workspace_builds.deadline < $1 :: timestamptz + ) + ) ) OR - -- If the workspace build was a stop transition, the workspace is - -- potentially eligible for autostart if it has a schedule set. The - -- caller must check if the template allows autostart in a license-aware - -- fashion as we cannot check it here. + -- A workspace may be eligible for autostart if the following are true: + -- * The workspace's owner is active. + -- * The provisioner job did not fail. + -- * The workspace build was a stop transition. + -- * The workspace has an autostart schedule. ( + users.status = 'active'::user_status AND + provisioner_jobs.job_status != 'failed'::provisioner_job_status AND workspace_builds.transition = 'stop'::workspace_transition AND workspaces.autostart_schedule IS NOT NULL ) OR - -- If the workspace's most recent job resulted in an error - -- it may be eligible for failed stop. - ( - provisioner_jobs.error IS NOT NULL AND - provisioner_jobs.error != '' AND - workspace_builds.transition = 'start'::workspace_transition - ) OR - - -- If the workspace's template has an inactivity_ttl set - -- it may be eligible for dormancy. + -- A workspace may be eligible for dormant stop if the following are true: + -- * The workspace is not dormant. + -- * The template has set a time 'til dormant. + -- * The workspace has been unused for longer than the time 'til dormancy. ( + workspaces.dormant_at IS NULL AND templates.time_til_dormant > 0 AND - workspaces.dormant_at IS NULL + ($1 :: timestamptz) - workspaces.last_used_at > (INTERVAL '1 millisecond' * (templates.time_til_dormant / 1000000)) ) OR - -- If the workspace's template has a time_til_dormant_autodelete set - -- and the workspace is already dormant. + -- A workspace may be eligible for deletion if the following are true: + -- * The workspace is dormant. + -- * The workspace is scheduled to be deleted. + -- * If there was a prior attempt to delete the workspace that failed: + -- * This attempt was at least 24 hours ago. ( + workspaces.dormant_at IS NOT NULL AND + workspaces.deleting_at IS NOT NULL AND + workspaces.deleting_at < $1 :: timestamptz AND templates.time_til_dormant_autodelete > 0 AND - workspaces.dormant_at IS NOT NULL + CASE + WHEN ( + workspace_builds.transition = 'delete'::workspace_transition AND + provisioner_jobs.job_status = 'failed'::provisioner_job_status + ) THEN ( + ( + provisioner_jobs.canceled_at IS NOT NULL OR + provisioner_jobs.completed_at IS NOT NULL + ) AND ( + ($1 :: timestamptz) - (CASE + WHEN provisioner_jobs.canceled_at IS NOT NULL THEN provisioner_jobs.canceled_at + ELSE provisioner_jobs.completed_at + END) > INTERVAL '24 hours' + ) + ) + ELSE true + END ) OR - -- If the user account is suspended, and the workspace is running. + -- A workspace may be eligible for failed stop if the following are true: + -- * The template has a failure ttl set. + -- * The workspace build was a start transition. + -- * The provisioner job failed. + -- * The provisioner job had completed. + -- * The provisioner job has been completed for longer than the failure ttl. ( - users.status = 'suspended'::user_status AND - workspace_builds.transition = 'start'::workspace_transition + templates.failure_ttl > 0 AND + workspace_builds.transition = 'start'::workspace_transition AND + 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)) ) ) AND workspaces.deleted = 'false' ` -func (q *sqlQuerier) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]WorkspaceTable, error) { +type GetWorkspacesEligibleForTransitionRow struct { + ID uuid.UUID `db:"id" json:"id"` + Name string `db:"name" json:"name"` +} + +func (q *sqlQuerier) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]GetWorkspacesEligibleForTransitionRow, error) { rows, err := q.db.QueryContext(ctx, getWorkspacesEligibleForTransition, now) if err != nil { return nil, err } defer rows.Close() - var items []WorkspaceTable + var items []GetWorkspacesEligibleForTransitionRow for rows.Next() { - var i WorkspaceTable - if err := rows.Scan( - &i.ID, - &i.CreatedAt, - &i.UpdatedAt, - &i.OwnerID, - &i.OrganizationID, - &i.TemplateID, - &i.Deleted, - &i.Name, - &i.AutostartSchedule, - &i.Ttl, - &i.LastUsedAt, - &i.DormantAt, - &i.DeletingAt, - &i.AutomaticUpdates, - &i.Favorite, - ); err != nil { + var i GetWorkspacesEligibleForTransitionRow + if err := rows.Scan(&i.ID, &i.Name); err != nil { return nil, err } items = append(items, i) diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index a1f41eb84d603..4d200a33f1620 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -557,7 +557,8 @@ FROM pending_workspaces, building_workspaces, running_workspaces, failed_workspa -- name: GetWorkspacesEligibleForTransition :many SELECT - workspaces.* + workspaces.id, + workspaces.name FROM workspaces LEFT JOIN @@ -579,52 +580,85 @@ WHERE ) AND ( - -- If the workspace build was a start transition, the workspace is - -- potentially eligible for autostop if it's past the deadline. The - -- deadline is computed at build time upon success and is bumped based - -- on activity (up the max deadline if set). We don't need to check - -- license here since that's done when the values are written to the build. + -- A workspace may be eligible for autostop if the following are true: + -- * The provisioner job has not failed. + -- * The workspace is not dormant. + -- * The workspace build was a start transition. + -- * The workspace's owner is suspended OR the workspace build deadline has passed. ( - workspace_builds.transition = 'start'::workspace_transition AND - workspace_builds.deadline IS NOT NULL AND - workspace_builds.deadline < @now :: timestamptz + provisioner_jobs.job_status != 'failed'::provisioner_job_status AND + workspaces.dormant_at IS NULL AND + workspace_builds.transition = 'start'::workspace_transition AND ( + users.status = 'suspended'::user_status OR ( + workspace_builds.deadline != '0001-01-01 00:00:00+00'::timestamptz AND + workspace_builds.deadline < @now :: timestamptz + ) + ) ) OR - -- If the workspace build was a stop transition, the workspace is - -- potentially eligible for autostart if it has a schedule set. The - -- caller must check if the template allows autostart in a license-aware - -- fashion as we cannot check it here. + -- A workspace may be eligible for autostart if the following are true: + -- * The workspace's owner is active. + -- * The provisioner job did not fail. + -- * The workspace build was a stop transition. + -- * The workspace has an autostart schedule. ( + users.status = 'active'::user_status AND + provisioner_jobs.job_status != 'failed'::provisioner_job_status AND workspace_builds.transition = 'stop'::workspace_transition AND workspaces.autostart_schedule IS NOT NULL ) OR - -- If the workspace's most recent job resulted in an error - -- it may be eligible for failed stop. - ( - provisioner_jobs.error IS NOT NULL AND - provisioner_jobs.error != '' AND - workspace_builds.transition = 'start'::workspace_transition - ) OR - - -- If the workspace's template has an inactivity_ttl set - -- it may be eligible for dormancy. + -- A workspace may be eligible for dormant stop if the following are true: + -- * The workspace is not dormant. + -- * The template has set a time 'til dormant. + -- * The workspace has been unused for longer than the time 'til dormancy. ( + workspaces.dormant_at IS NULL AND templates.time_til_dormant > 0 AND - workspaces.dormant_at IS NULL + (@now :: timestamptz) - workspaces.last_used_at > (INTERVAL '1 millisecond' * (templates.time_til_dormant / 1000000)) ) OR - -- If the workspace's template has a time_til_dormant_autodelete set - -- and the workspace is already dormant. + -- A workspace may be eligible for deletion if the following are true: + -- * The workspace is dormant. + -- * The workspace is scheduled to be deleted. + -- * If there was a prior attempt to delete the workspace that failed: + -- * This attempt was at least 24 hours ago. ( + workspaces.dormant_at IS NOT NULL AND + workspaces.deleting_at IS NOT NULL AND + workspaces.deleting_at < @now :: timestamptz AND templates.time_til_dormant_autodelete > 0 AND - workspaces.dormant_at IS NOT NULL + CASE + WHEN ( + workspace_builds.transition = 'delete'::workspace_transition AND + provisioner_jobs.job_status = 'failed'::provisioner_job_status + ) THEN ( + ( + provisioner_jobs.canceled_at IS NOT NULL OR + provisioner_jobs.completed_at IS NOT NULL + ) AND ( + (@now :: timestamptz) - (CASE + WHEN provisioner_jobs.canceled_at IS NOT NULL THEN provisioner_jobs.canceled_at + ELSE provisioner_jobs.completed_at + END) > INTERVAL '24 hours' + ) + ) + ELSE true + END ) OR - -- If the user account is suspended, and the workspace is running. + -- A workspace may be eligible for failed stop if the following are true: + -- * The template has a failure ttl set. + -- * The workspace build was a start transition. + -- * The provisioner job failed. + -- * The provisioner job had completed. + -- * The provisioner job has been completed for longer than the failure ttl. ( - users.status = 'suspended'::user_status AND - workspace_builds.transition = 'start'::workspace_transition + templates.failure_ttl > 0 AND + workspace_builds.transition = 'start'::workspace_transition AND + 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)) ) ) AND workspaces.deleted = 'false'; @@ -727,5 +761,3 @@ WHERE -- Authorize Filter clause will be injected below in GetAuthorizedWorkspacesAndAgentsByOwnerID -- @authorize_filter GROUP BY workspaces.id, workspaces.name, latest_build.job_status, latest_build.job_id, latest_build.transition; - -