diff --git a/coderd/autobuild/lifecycle_executor.go b/coderd/autobuild/lifecycle_executor.go index 93da5d8df2..668772f05b 100644 --- a/coderd/autobuild/lifecycle_executor.go +++ b/coderd/autobuild/lifecycle_executor.go @@ -189,7 +189,7 @@ func (e *Executor) runOnce(t time.Time) Stats { // NOTE: If a workspace build is created with a given TTL and then the user either // changes or unsets the TTL, the deadline for the workspace build will not // have changed. This behavior is as expected per #2229. - workspaces, err := e.db.GetWorkspacesEligibleForTransition(e.ctx, currentTick) + workspaces, err := e.db.GetWorkspacesEligibleForLifecycleAction(e.ctx, currentTick) if err != nil { e.log.Error(e.ctx, "get workspaces for autostart or autostop", slog.Error(err)) return stats @@ -207,7 +207,7 @@ func (e *Executor) runOnce(t time.Time) Stats { // set of identical template versions. Then unload the files when the builds // are done. Right now, this relies on luck for the 10 goroutine workers to // overlap and keep the file reference in the cache alive. - slices.SortFunc(workspaces, func(a, b database.GetWorkspacesEligibleForTransitionRow) int { + slices.SortFunc(workspaces, func(a, b database.GetWorkspacesEligibleForLifecycleActionRow) int { return strings.Compare(a.BuildTemplateVersionID.UUID.String(), b.BuildTemplateVersionID.UUID.String()) }) @@ -232,6 +232,9 @@ func (e *Executor) runOnce(t time.Time) Stats { auditLog *auditParams shouldNotifyDormancy bool shouldNotifyTaskPause bool + shouldRemind bool + reminderDeadline time.Time + reminderBuildID uuid.UUID nextBuild *database.WorkspaceBuild activeTemplateVersion database.TemplateVersion ws database.Workspace @@ -309,11 +312,29 @@ func (e *Executor) runOnce(t time.Time) Stats { nextTransition, reason, err := getNextTransition(user, ws, latestBuild, latestJob, templateSchedule, currentTick) if err != nil { - log.Debug(e.ctx, "skipping workspace", slog.Error(err)) - // err is used to indicate that a workspace is not eligible - // so returning nil here is ok although ultimately the distinction - // doesn't matter since the transaction is read-only up to - // this point. + return xerrors.Errorf("get next transition: %w", err) + } + + // No transition is due. The workspace may still need a one-time + // autostop reminder; reuse the lock and transaction we already + // hold to stamp the marker. + if reason == "" { + log.Debug(e.ctx, "skipping workspace, no transition due") + // A deadline change (e.g. activity bump) re-arms the reminder; users near + // the boundary may receive one reminder per bump. Intentional: one-per-build + // would leave stale reminders after a bump. + if shouldRemindAutostop(latestBuild, templateSchedule, currentTick) { + if err := tx.UpdateWorkspaceBuildNotifiedAutostopDeadline(e.ctx, database.UpdateWorkspaceBuildNotifiedAutostopDeadlineParams{ + ID: latestBuild.ID, + NotifiedAutostopDeadline: latestBuild.Deadline, + UpdatedAt: dbtime.Now(), + }); err != nil { + return xerrors.Errorf("stamp autostop reminder marker: %w", err) + } + reminderDeadline = latestBuild.Deadline + reminderBuildID = latestBuild.ID + shouldRemind = true + } return nil } @@ -536,6 +557,24 @@ func (e *Executor) runOnce(t time.Time) Stats { } } } + if shouldRemind { + // At-most-once: the marker is already committed, so a failed + // enqueue only logs (no retry). + if _, err := e.notificationsEnqueuer.Enqueue( + e.ctx, + ws.OwnerID, + notifications.TemplateWorkspaceAutostopReminder, + map[string]string{ + "workspace": ws.Name, + "deadline": reminderDeadline.UTC().Format(time.RFC1123), + }, + "lifecycle_executor", + // Associate this notification with all the related entities. + ws.ID, ws.OwnerID, ws.TemplateID, ws.OrganizationID, + ); err != nil { + log.Warn(e.ctx, "failed to notify of upcoming workspace autostop", slog.F("build_id", reminderBuildID), slog.Error(err)) + } + } return nil }() if err != nil && !xerrors.Is(err, context.Canceled) { @@ -559,12 +598,50 @@ func (e *Executor) runOnce(t time.Time) Stats { return stats } +// shouldRemindAutostop reports whether a reminder notification should be sent +// for the workspace's latest build at currentTick. +// +// time_til_autostop_notify has no upper bound. If it exceeds a +// workspace's remaining lifetime, the notify window already covers "now" at +// build creation. This is still safe: we require deadline > now (so we never +// remind once the stop is due) and the marker (NotifiedAutostopDeadline == +// Deadline, stamped in the transaction before the send attempt) filters every +// subsequent tick. The result is exactly one reminder per deadline, never one +// per tick. +func shouldRemindAutostop(build database.WorkspaceBuild, templateSchedule schedule.TemplateScheduleOptions, currentTick time.Time) bool { + if templateSchedule.TimeTilAutostopNotify <= 0 { + return false + } + + if build.Transition != database.WorkspaceTransitionStart || build.Deadline.IsZero() { + return false + } + + if !build.Deadline.After(currentTick) { + return false + } + + // "now" must be within the lead window before the deadline, i.e. + // deadline <= now + time_til_autostop_notify. + if build.Deadline.After(currentTick.Add(templateSchedule.TimeTilAutostopNotify)) { + return false + } + + // Idempotence: a reminder has not yet been sent for THIS deadline. The + // marker re-arms automatically when the deadline changes (e.g. an activity + // bump), so a new reminder fires once the new deadline re-enters the window. + return !build.NotifiedAutostopDeadline.Equal(build.Deadline) +} + // getNextTransition returns the next eligible transition for the workspace -// as well as the reason for why it is transitioning. It is possible -// for this function to return a nil error as well as an empty transition. -// In such cases it means no provisioning should occur but the workspace -// may be "transitioning" to a new state (such as an inactive, stopped -// workspace transitioning to the dormant state). +// as well as the reason for why it is transitioning. It is possible for this +// function to return a nil error as well as an empty transition with a +// non-empty reason. In such cases it means no provisioning should occur but +// the workspace may be "transitioning" to a new state (such as an inactive, +// stopped workspace transitioning to the dormant state). +// +// When nothing is due, it returns an empty transition, an empty reason, and a +// nil error. Callers gate on reason == "" for the "nothing to do" case. func getNextTransition( user database.User, ws database.Workspace, @@ -604,7 +681,8 @@ func getNextTransition( case isEligibleForDelete(ws, templateSchedule, latestBuild, latestJob, currentTick): return database.WorkspaceTransitionDelete, database.BuildReasonAutodelete, nil default: - return "", "", xerrors.Errorf("last transition not valid for autostart or autostop") + // No autostart, autostop, dormancy, or deletion transition is due. + return "", "", nil } } diff --git a/coderd/autobuild/lifecycle_executor_internal_test.go b/coderd/autobuild/lifecycle_executor_internal_test.go index cde61a18d1..3505ae0705 100644 --- a/coderd/autobuild/lifecycle_executor_internal_test.go +++ b/coderd/autobuild/lifecycle_executor_internal_test.go @@ -112,6 +112,151 @@ func Test_getNextTransition_TaskAutoPause(t *testing.T) { } } +func Test_getNextTransition_NoAction(t *testing.T) { + t.Parallel() + + now := time.Now() + + // A stopped workspace with no autostart schedule, no dormancy, and no + // deletion configured has no transition due. The default case must report + // "nothing to do" via an empty transition AND empty reason, with a nil + // error (not a sentinel error). + user := database.User{Status: database.UserStatusActive} + ws := database.Workspace{ + DormantAt: sql.NullTime{Valid: false}, + } + build := database.WorkspaceBuild{ + Transition: database.WorkspaceTransitionStop, + } + job := database.ProvisionerJob{ + JobStatus: database.ProvisionerJobStatusSucceeded, + } + templateSchedule := schedule.TemplateScheduleOptions{} + + transition, reason, err := getNextTransition(user, ws, build, job, templateSchedule, now) + require.NoError(t, err) + require.Equal(t, database.WorkspaceTransition(""), transition) + require.Equal(t, database.BuildReason(""), reason) +} + +func TestShouldRemindAutostop(t *testing.T) { + t.Parallel() + + currentTick := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + const ttl = time.Hour + + // inWindow places the deadline 30m out, inside the 1h lead window. + inWindow := func() database.WorkspaceBuild { + return database.WorkspaceBuild{ + Transition: database.WorkspaceTransitionStart, + Deadline: currentTick.Add(30 * time.Minute), + } + } + + testCases := []struct { + Name string + Build database.WorkspaceBuild + TemplateSchedule schedule.TemplateScheduleOptions + Expected bool + }{ + { + Name: "InWindow", + Build: inWindow(), + TemplateSchedule: schedule.TemplateScheduleOptions{TimeTilAutostopNotify: ttl}, + Expected: true, + }, + { + Name: "TemplateDisabled", + Build: inWindow(), + TemplateSchedule: schedule.TemplateScheduleOptions{TimeTilAutostopNotify: 0}, + Expected: false, + }, + { + Name: "TransitionStop", + Build: func() database.WorkspaceBuild { + b := inWindow() + b.Transition = database.WorkspaceTransitionStop + return b + }(), + TemplateSchedule: schedule.TemplateScheduleOptions{TimeTilAutostopNotify: ttl}, + Expected: false, + }, + { + Name: "ZeroDeadline", + Build: func() database.WorkspaceBuild { + b := inWindow() + b.Deadline = time.Time{} + return b + }(), + TemplateSchedule: schedule.TemplateScheduleOptions{TimeTilAutostopNotify: ttl}, + Expected: false, + }, + { + Name: "DeadlineInPast", + Build: func() database.WorkspaceBuild { + b := inWindow() + b.Deadline = currentTick.Add(-time.Minute) + return b + }(), + TemplateSchedule: schedule.TemplateScheduleOptions{TimeTilAutostopNotify: ttl}, + Expected: false, + }, + { + Name: "BeforeWindow", + Build: func() database.WorkspaceBuild { + b := inWindow() + // Deadline two hours out, ttl is only one hour. + b.Deadline = currentTick.Add(2 * time.Hour) + return b + }(), + TemplateSchedule: schedule.TemplateScheduleOptions{TimeTilAutostopNotify: ttl}, + Expected: false, + }, + { + Name: "AlreadyNotified", + Build: func() database.WorkspaceBuild { + b := inWindow() + b.NotifiedAutostopDeadline = b.Deadline + return b + }(), + TemplateSchedule: schedule.TemplateScheduleOptions{TimeTilAutostopNotify: ttl}, + Expected: false, + }, + { + // Deadline == currentTick: the stop is already due, so + // !build.Deadline.After(currentTick) rejects it (not a reminder). + Name: "ExactDeadline", + Build: func() database.WorkspaceBuild { + b := inWindow() + b.Deadline = currentTick + return b + }(), + TemplateSchedule: schedule.TemplateScheduleOptions{TimeTilAutostopNotify: ttl}, + Expected: false, + }, + { + // Deadline exactly at the window opening edge (now + ttl) is + // eligible: the lead-window check uses After, so the edge passes. + Name: "WindowEdge", + Build: func() database.WorkspaceBuild { + b := inWindow() + b.Deadline = currentTick.Add(ttl) + return b + }(), + TemplateSchedule: schedule.TemplateScheduleOptions{TimeTilAutostopNotify: ttl}, + Expected: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + + require.Equal(t, tc.Expected, shouldRemindAutostop(tc.Build, tc.TemplateSchedule, currentTick)) + }) + } +} + func Test_isEligibleForAutostart(t *testing.T) { t.Parallel() diff --git a/coderd/autobuild/lifecycle_executor_test.go b/coderd/autobuild/lifecycle_executor_test.go index c9caf339be..bda41dc3f3 100644 --- a/coderd/autobuild/lifecycle_executor_test.go +++ b/coderd/autobuild/lifecycle_executor_test.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "sync" "sync/atomic" "testing" "time" @@ -13,6 +14,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/goleak" + "golang.org/x/xerrors" "cdr.dev/slog/v3" "cdr.dev/slog/v3/sloggers/slogtest" @@ -1890,6 +1892,277 @@ func setupTestDBPrebuiltWorkspace( return workspace } +// setupAutostopReminderWorkspace provisions a running workspace whose template +// has the given time_til_autostop_notify configured, using the caller-supplied +// notifications enqueuer. It returns the harness channels needed to drive ticks +// and observe notifications. +func setupAutostopReminderWorkspace(t *testing.T, timeTilAutostopNotify time.Duration, enq notifications.Enqueuer) ( + client *codersdk.Client, + tickCh chan time.Time, + statsCh chan autobuild.Stats, + workspace codersdk.Workspace, +) { + t.Helper() + + tickCh = make(chan time.Time) + statsCh = make(chan autobuild.Stats) + client = coderdtest.New(t, &coderdtest.Options{ + AutobuildTicker: tickCh, + AutobuildStats: statsCh, + IncludeProvisionerDaemon: true, + NotificationsEnqueuer: enq, + // The AGPL schedule store persists and returns time_til_autostop_notify. + TemplateScheduleStore: schedule.NewAGPLTemplateScheduleStore(), + }) + + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + if timeTilAutostopNotify > 0 { + ctr.TimeTilAutostopNotifyMillis = ptr.Ref(timeTilAutostopNotify.Milliseconds()) + } + }) + ws := coderdtest.CreateWorkspace(t, client, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + workspace = coderdtest.MustWorkspace(t, client, ws.ID) + + // The build must have a non-zero deadline for a reminder to ever fire. + require.Equal(t, codersdk.WorkspaceTransitionStart, workspace.LatestBuild.Transition) + require.NotZero(t, workspace.LatestBuild.Deadline) + return client, tickCh, statsCh, workspace +} + +// failOnceEnqueuer fails its first Enqueue call and delegates every subsequent +// call to the wrapped enqueuer. It is used by the FailedEnqueueNotRetried +// subtest to verify that a failed reminder enqueue is not retried (the +// at-most-once guarantee); notificationstest.FakeEnqueuer.Enqueue always +// succeeds, so this wrapper is the only way to inject a send failure. +type failOnceEnqueuer struct { + notifications.Enqueuer + mu sync.Mutex + failed bool +} + +func (f *failOnceEnqueuer) Enqueue(ctx context.Context, userID, templateID uuid.UUID, labels map[string]string, createdBy string, targets ...uuid.UUID) ([]uuid.UUID, error) { + f.mu.Lock() + defer f.mu.Unlock() + if !f.failed { + f.failed = true + return nil, xerrors.New("injected enqueue failure") + } + return f.Enqueuer.Enqueue(ctx, userID, templateID, labels, createdBy, targets...) +} + +func TestExecutorAutostopReminder(t *testing.T) { + t.Parallel() + + // Sent: a reminder is enqueued when a tick lands inside the lead window + // [deadline - ttl, deadline). + t.Run("Sent", func(t *testing.T) { + t.Parallel() + + timeTilNotify := 30 * time.Minute + notifyEnq := ¬ificationstest.FakeEnqueuer{} + _, tickCh, statsCh, workspace := setupAutostopReminderWorkspace(t, timeTilNotify, notifyEnq) + deadline := workspace.LatestBuild.Deadline.Time + + go func() { + // Halfway into the lead window. + tickCh <- deadline.Add(-timeTilNotify / 2) + close(tickCh) + }() + + stats := testutil.TryReceive(testutil.Context(t, testutil.WaitShort), t, statsCh) + require.Len(t, stats.Errors, 0) + require.Len(t, stats.Transitions, 0) + + sent := notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceAutostopReminder)) + require.Len(t, sent, 1) + require.Equal(t, workspace.OwnerID, sent[0].UserID) + require.Equal(t, workspace.Name, sent[0].Labels["workspace"]) + require.Equal(t, deadline.UTC().Format(time.RFC1123), sent[0].Labels["deadline"]) + require.Contains(t, sent[0].Targets, workspace.ID) + require.Contains(t, sent[0].Targets, workspace.OwnerID) + require.Contains(t, sent[0].Targets, workspace.TemplateID) + require.Contains(t, sent[0].Targets, workspace.OrganizationID) + }) + + // NotBeforeWindow: no reminder when the tick precedes the lead window. + t.Run("NotBeforeWindow", func(t *testing.T) { + t.Parallel() + + timeTilNotify := 30 * time.Minute + notifyEnq := ¬ificationstest.FakeEnqueuer{} + _, tickCh, statsCh, workspace := setupAutostopReminderWorkspace(t, timeTilNotify, notifyEnq) + deadline := workspace.LatestBuild.Deadline.Time + + go func() { + // Well before the window opens. + tickCh <- deadline.Add(-2 * timeTilNotify) + close(tickCh) + }() + + stats := testutil.TryReceive(testutil.Context(t, testutil.WaitShort), t, statsCh) + require.Len(t, stats.Errors, 0) + require.Empty(t, notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceAutostopReminder))) + }) + + // Disabled: time_til_autostop_notify of 0 (the default) never reminds. + t.Run("Disabled", func(t *testing.T) { + t.Parallel() + + notifyEnq := ¬ificationstest.FakeEnqueuer{} + _, tickCh, statsCh, workspace := setupAutostopReminderWorkspace(t, 0, notifyEnq) + deadline := workspace.LatestBuild.Deadline.Time + + go func() { + tickCh <- deadline.Add(-time.Minute) + close(tickCh) + }() + + stats := testutil.TryReceive(testutil.Context(t, testutil.WaitShort), t, statsCh) + require.Len(t, stats.Errors, 0) + require.Empty(t, notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceAutostopReminder))) + }) + + // NoDuplicate: a second tick still inside the window does not re-notify + // because the idempotence marker was stamped. + t.Run("NoDuplicate", func(t *testing.T) { + t.Parallel() + + timeTilNotify := 30 * time.Minute + notifyEnq := ¬ificationstest.FakeEnqueuer{} + _, tickCh, statsCh, workspace := setupAutostopReminderWorkspace(t, timeTilNotify, notifyEnq) + deadline := workspace.LatestBuild.Deadline.Time + + // First tick: reminder fires. Receiving from statsCh acts as the + // per-tick barrier guaranteeing the enqueue already happened. + go func() { + tickCh <- deadline.Add(-timeTilNotify / 2) + }() + testutil.TryReceive(testutil.Context(t, testutil.WaitShort), t, statsCh) + require.Len(t, notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceAutostopReminder)), 1) + + // Second tick still inside the window: no new reminder. Sent() + // accumulates across ticks, so a cumulative count still at 1 proves + // the duplicate was suppressed. + go func() { + tickCh <- deadline.Add(-timeTilNotify / 4) + close(tickCh) + }() + testutil.TryReceive(testutil.Context(t, testutil.WaitShort), t, statsCh) + require.Len(t, notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceAutostopReminder)), 1) + }) + + // DeadlineBumped: extending the deadline re-arms the marker, so a new + // reminder fires once the new deadline re-enters the window. + t.Run("DeadlineBumped", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + timeTilNotify := 30 * time.Minute + notifyEnq := ¬ificationstest.FakeEnqueuer{} + client, tickCh, statsCh, workspace := setupAutostopReminderWorkspace(t, timeTilNotify, notifyEnq) + deadline := workspace.LatestBuild.Deadline.Time + + // First tick: reminder fires for the original deadline. + go func() { + tickCh <- deadline.Add(-timeTilNotify / 2) + }() + testutil.TryReceive(ctx, t, statsCh) + sent := notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceAutostopReminder)) + require.Len(t, sent, 1) + require.Equal(t, deadline.UTC().Format(time.RFC1123), sent[0].Labels["deadline"]) + + // Move the deadline well into the future. The marker now differs from + // the build deadline, re-arming the reminder. + newDeadline := deadline.Add(2 * time.Hour) + require.NoError(t, client.PutExtendWorkspace(ctx, workspace.ID, codersdk.PutExtendWorkspaceRequest{ + Deadline: newDeadline, + })) + + // Second tick inside the new window fires another reminder. Sent() + // accumulates across ticks, so two total proves the second reminder + // fired; sent[1] carries the bumped deadline. + go func() { + tickCh <- newDeadline.Add(-timeTilNotify / 2) + close(tickCh) + }() + testutil.TryReceive(ctx, t, statsCh) + sent = notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceAutostopReminder)) + require.Len(t, sent, 2) + require.Equal(t, newDeadline.UTC().Format(time.RFC1123), sent[1].Labels["deadline"]) + }) + + // ExceedsLifetime: a time_til_autostop_notify larger than the + // workspace's remaining lifetime yields exactly one reminder, not one per + // tick. + t.Run("ExceedsLifetime", func(t *testing.T) { + t.Parallel() + + // Far larger than the workspace's 8h TTL, so the lead window already + // includes "now" at build creation. + timeTilNotify := 100 * time.Hour + notifyEnq := ¬ificationstest.FakeEnqueuer{} + _, tickCh, statsCh, workspace := setupAutostopReminderWorkspace(t, timeTilNotify, notifyEnq) + deadline := workspace.LatestBuild.Deadline.Time + + // First tick: a single reminder fires. + go func() { + tickCh <- deadline.Add(-time.Hour) + }() + testutil.TryReceive(testutil.Context(t, testutil.WaitShort), t, statsCh) + require.Len(t, notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceAutostopReminder)), 1) + + // Second tick still before the deadline: no flood of reminders. Sent() + // accumulates across ticks, so a cumulative count still at 1 proves no + // duplicate fired. + go func() { + tickCh <- deadline.Add(-30 * time.Minute) + close(tickCh) + }() + testutil.TryReceive(testutil.Context(t, testutil.WaitShort), t, statsCh) + require.Len(t, notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceAutostopReminder)), 1) + }) + + // FailedEnqueueNotRetried pins the marker-before-enqueue / at-most-once + // guarantee: the marker is committed inside the transaction before the + // post-commit enqueue, so a failed enqueue on the first tick is NOT + // retried on a later tick even though the workspace is still inside the + // lead window. failOnceEnqueuer injects that single send failure; + // notificationstest.FakeEnqueuer.Enqueue always succeeds. + t.Run("FailedEnqueueNotRetried", func(t *testing.T) { + t.Parallel() + + fake := ¬ificationstest.FakeEnqueuer{} + enq := &failOnceEnqueuer{Enqueuer: fake} + timeTilNotify := 2 * time.Hour + _, tickCh, statsCh, workspace := setupAutostopReminderWorkspace(t, timeTilNotify, enq) + deadline := workspace.LatestBuild.Deadline.Time + + // Tick 1 inside the window: the enqueue fails. Because the marker is + // stamped before the enqueue, the failure only logs and nothing is + // sent. + go func() { + tickCh <- deadline.Add(-time.Hour) + }() + testutil.TryReceive(testutil.Context(t, testutil.WaitShort), t, statsCh) + require.Len(t, fake.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceAutostopReminder)), 0) + + // Tick 2 still inside the window: the committed marker suppresses + // re-selection, so the failed reminder is NOT retried. A cumulative + // count still at 0 proves the at-most-once guarantee described at the + // enqueue block in lifecycle_executor.go. + go func() { + tickCh <- deadline.Add(-time.Hour + time.Minute) + close(tickCh) + }() + testutil.TryReceive(testutil.Context(t, testutil.WaitShort), t, statsCh) + require.Len(t, fake.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceAutostopReminder)), 0) + }) +} + func mustProvisionWorkspace(t *testing.T, client *codersdk.Client, mut ...func(*codersdk.CreateWorkspaceRequest)) codersdk.Workspace { t.Helper() user := coderdtest.CreateFirstUser(t, client) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 3ad04ab92c..51684b42ac 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -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 diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 8e77219654..0754b99596 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -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) { diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 8fc504de35..5f5761a97e 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -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) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 2051eed4ec..87ab8d05f2 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -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() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 27ec63a4e1..fe56f6e4f1 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -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) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 6b44efc737..176998b4c9 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -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 } diff --git a/coderd/database/queries/workspacebuilds.sql b/coderd/database/queries/workspacebuilds.sql index 7767cd0b6f..390ffefab9 100644 --- a/coderd/database/queries/workspacebuilds.sql +++ b/coderd/database/queries/workspacebuilds.sql @@ -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 ( diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index c9ed2ed446..9be84a6b6f 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -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'