From 14fef35e5318098aaaf8a3ce57c83a746d2769c3 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Mon, 22 Jun 2026 18:41:13 +0000 Subject: [PATCH] feat(coderd/autobuild): notify users before workspace autostop Add a reminder-dispatch pass to the lifecycle executor that notifies a workspace owner a template-configured duration (time_til_autostop_notify) before their workspace is automatically stopped. Per tick, a separate scan (GetWorkspacesEligibleForAutostopReminder) finds running workspaces whose deadline is within the lead window and not yet reminded for that deadline. Inside a per-workspace advisory-locked RepeatableRead transaction it re-validates volatile conditions and stamps workspace_builds.notified_autostop_deadline, then enqueues TemplateWorkspaceAutostopReminder after commit. If the enqueue fails, the marker is re-armed so a later tick retries. Idempotent per deadline and HA-safe; at most one reminder even when the lead exceeds a workspace's remaining lifetime. --- coderd/autobuild/lifecycle_executor.go | 215 ++++++++++++- .../lifecycle_executor_internal_test.go | 94 ++++++ coderd/autobuild/lifecycle_executor_test.go | 294 ++++++++++++++++++ coderd/database/dbauthz/dbauthz.go | 22 ++ coderd/database/dbauthz/dbauthz_test.go | 14 + coderd/database/dbmetrics/querymetrics.go | 16 + coderd/database/dbmock/dbmock.go | 29 ++ coderd/database/querier.go | 16 + coderd/database/queries.sql.go | 109 +++++++ coderd/database/queries/workspacebuilds.sql | 12 + coderd/database/queries/workspaces.sql | 56 ++++ 11 files changed, 873 insertions(+), 4 deletions(-) diff --git a/coderd/autobuild/lifecycle_executor.go b/coderd/autobuild/lifecycle_executor.go index 93da5d8df28ba..ec3592960cfad 100644 --- a/coderd/autobuild/lifecycle_executor.go +++ b/coderd/autobuild/lifecycle_executor.go @@ -63,8 +63,11 @@ type executorMetrics struct { // Stats contains information about one run of Executor. type Stats struct { Transitions map[uuid.UUID]database.WorkspaceTransition - Elapsed time.Duration - Errors map[uuid.UUID]error + // AutostopReminders maps a workspace ID to the autostop deadline a reminder + // notification was enqueued for during this run. + AutostopReminders map[uuid.UUID]time.Time + Elapsed time.Duration + Errors map[uuid.UUID]error } // New returns a new wsactions executor. @@ -169,8 +172,9 @@ func (e *Executor) hasValidProvisioner(ctx context.Context, tx database.Store, t func (e *Executor) runOnce(t time.Time) Stats { stats := Stats{ - Transitions: make(map[uuid.UUID]database.WorkspaceTransition), - Errors: make(map[uuid.UUID]error), + Transitions: make(map[uuid.UUID]database.WorkspaceTransition), + AutostopReminders: make(map[uuid.UUID]time.Time), + Errors: make(map[uuid.UUID]error), } // we build the map of transitions concurrently, so need a mutex to serialize writes to the map statsMu := sync.Mutex{} @@ -556,9 +560,212 @@ func (e *Executor) runOnce(t time.Time) Stats { e.log.Error(e.ctx, "workspace scheduling errgroup failed", slog.Error(err)) } + // Send autostop reminder notifications for workspaces approaching their + // deadline. This is a separate scan because its eligibility conditions + // differ from the transition query (it targets running workspaces whose + // deadline is still in the future) and the result set is expected to be + // tiny. + e.remindUpcomingAutostops(currentTick, &stats, &statsMu) + return stats } +// remindUpcomingAutostops enqueues a one-time reminder notification for each +// running workspace whose autostop deadline is within the template's configured +// lead window (time_til_autostop_notify). +func (e *Executor) remindUpcomingAutostops(currentTick time.Time, stats *Stats, statsMu *sync.Mutex) { + candidates, err := e.db.GetWorkspacesEligibleForAutostopReminder(e.ctx, currentTick) + if err != nil { + e.log.Error(e.ctx, "get workspaces eligible for autostop reminder", slog.Error(err)) + return + } + + eg := errgroup.Group{} + // Limit the concurrency to avoid overloading the database. + eg.SetLimit(10) + + for _, candidate := range candidates { + wsID := candidate.ID + log := e.log.With( + slog.F("workspace_id", wsID), + slog.F("workspace_name", candidate.Name), + ) + + eg.Go(func() error { + err := e.remindWorkspaceAutostop(currentTick, wsID, log, stats, statsMu) + if err != nil && !xerrors.Is(err, context.Canceled) { + log.Error(e.ctx, "failed to send autostop reminder", slog.Error(err)) + statsMu.Lock() + stats.Errors[wsID] = err + statsMu.Unlock() + } + // Always return nil to avoid short-circuiting the loop. + return nil + }) + } + + if err := eg.Wait(); err != nil { + e.log.Error(e.ctx, "autostop reminder errgroup failed", slog.Error(err)) + } +} + +// remindWorkspaceAutostop re-validates a single workspace's reminder eligibility +// inside a transaction (so concurrent replicas and deadline bumps are handled +// safely), stamps the idempotence marker, and enqueues the notification after +// the transaction commits. +func (e *Executor) remindWorkspaceAutostop(currentTick time.Time, wsID uuid.UUID, log slog.Logger, stats *Stats, statsMu *sync.Mutex) error { + var ( + shouldNotify bool + ws database.Workspace + deadline time.Time + buildID uuid.UUID + ) + + err := e.db.InTx(func(tx database.Store) error { + ok, err := tx.TryAcquireLock(e.ctx, database.GenLockID(fmt.Sprintf("lifecycle-executor:%s", wsID))) + if err != nil { + return xerrors.Errorf("try acquire lifecycle executor lock: %w", err) + } + if !ok { + // Another replica owns this workspace for this tick. + log.Debug(e.ctx, "unable to acquire lock for workspace autostop reminder, skipping") + return nil + } + + ws, err = tx.GetWorkspaceByID(e.ctx, wsID) + if err != nil { + return xerrors.Errorf("get workspace by id: %w", err) + } + + build, err := tx.GetLatestWorkspaceBuildByWorkspaceID(e.ctx, wsID) + if err != nil { + return xerrors.Errorf("get latest workspace build: %w", err) + } + + templateSchedule, err := (*(e.templateScheduleStore.Load())).Get(e.ctx, tx, ws.TemplateID) + if err != nil { + return xerrors.Errorf("get template scheduling options: %w", err) + } + + // Re-validate the volatile eligibility conditions inside the + // transaction (the build deadline and marker, plus workspace + // dormancy/deletion). The SQL scan already filtered on user status and + // job status, which change rarely. The initial scan ran outside any + // transaction, so the deadline may have been bumped, the reminder may + // already have been sent by another replica, the template setting may + // have changed, or the workspace may have become dormant or deleted. + if ws.DormantAt.Valid || ws.Deleted { + return nil + } + if !shouldRemindAutostop(build, templateSchedule, currentTick) { + return nil + } + + // Stamp the marker so subsequent ticks (and other replicas) skip this + // deadline. This is what guarantees a single reminder per deadline. + if err := tx.UpdateWorkspaceBuildNotifiedAutostopDeadline(e.ctx, database.UpdateWorkspaceBuildNotifiedAutostopDeadlineParams{ + ID: build.ID, + NotifiedAutostopDeadline: build.Deadline, + UpdatedAt: dbtime.Now(), + }); err != nil { + return xerrors.Errorf("update workspace build notified autostop deadline: %w", err) + } + + shouldNotify = true + deadline = build.Deadline + buildID = build.ID + return nil + // Run with RepeatableRead isolation so the re-validation and marker + // update see a consistent snapshot of the build. + }, &database.TxOptions{ + Isolation: sql.LevelRepeatableRead, + TxIdentifier: "lifecycle_autostop_reminder", + }) + if err != nil { + return xerrors.Errorf("remind workspace autostop: %w", err) + } + if !shouldNotify { + return nil + } + + statsMu.Lock() + stats.AutostopReminders[wsID] = deadline + statsMu.Unlock() + + // Enqueue after the transaction commits, matching the dormancy/auto-update + // notification pattern. The deadline label is the (stable) deadline + // timestamp, so the notification's dedupe hash stays effective across ticks. + if _, err := e.notificationsEnqueuer.Enqueue( + e.ctx, + ws.OwnerID, + notifications.TemplateWorkspaceAutostopReminder, + map[string]string{ + "workspace": ws.Name, + "deadline": deadline.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.Error(err)) + + // Re-arm the marker so a later tick retries; otherwise the committed + // marker would permanently suppress this deadline's only notification. + // The notifications subsystem's own dedupe_hash prevents same-day + // duplicates if the enqueue actually partially succeeded. + if rearmErr := e.db.UpdateWorkspaceBuildNotifiedAutostopDeadline(e.ctx, database.UpdateWorkspaceBuildNotifiedAutostopDeadlineParams{ + ID: buildID, + NotifiedAutostopDeadline: time.Time{}, + UpdatedAt: dbtime.Now(), + }); rearmErr != nil { + log.Warn(e.ctx, "failed to re-arm autostop reminder marker after enqueue failure", slog.Error(rearmErr)) + } + // Also drop the stats entry so the failed send isn't reported as sent. + statsMu.Lock() + delete(stats.AutostopReminders, wsID) + statsMu.Unlock() + } + + return nil +} + +// 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 after the first send) 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 { + // The template must opt in to autostop reminders. + if templateSchedule.TimeTilAutostopNotify <= 0 { + return false + } + + // Only running (start) workspaces with a real deadline are eligible. + if build.Transition != database.WorkspaceTransitionStart || build.Deadline.IsZero() { + return false + } + + // Never remind about a stop that is already due. + 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. diff --git a/coderd/autobuild/lifecycle_executor_internal_test.go b/coderd/autobuild/lifecycle_executor_internal_test.go index cde61a18d15aa..2baa51035cc46 100644 --- a/coderd/autobuild/lifecycle_executor_internal_test.go +++ b/coderd/autobuild/lifecycle_executor_internal_test.go @@ -112,6 +112,100 @@ func Test_getNextTransition_TaskAutoPause(t *testing.T) { } } +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, + }, + } + + 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 c9caf339be668..828f934f20fa2 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,298 @@ func setupTestDBPrebuiltWorkspace( return workspace } +// setupAutostopReminderWorkspace provisions a running workspace whose template +// has the given time_til_autostop_notify configured. It returns the harness +// channels needed to drive ticks and observe notifications. +func setupAutostopReminderWorkspace(t *testing.T, timeTilAutostopNotify time.Duration) ( + client *codersdk.Client, + tickCh chan time.Time, + statsCh chan autobuild.Stats, + notifyEnq *notificationstest.FakeEnqueuer, + workspace codersdk.Workspace, +) { + t.Helper() + + notifyEnq = ¬ificationstest.FakeEnqueuer{} + client, tickCh, statsCh, workspace = setupAutostopReminderWorkspaceWithEnqueuer(t, timeTilAutostopNotify, notifyEnq) + return client, tickCh, statsCh, notifyEnq, workspace +} + +// setupAutostopReminderWorkspaceWithEnqueuer is like setupAutostopReminderWorkspace +// but lets the caller supply a custom notifications.Enqueuer (e.g. one that +// injects an enqueue failure) instead of a bare FakeEnqueuer. +func setupAutostopReminderWorkspaceWithEnqueuer(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 wraps a FakeEnqueuer and fails the FIRST Enqueue call, then +// delegates every subsequent call to the wrapped fake. It is used to exercise +// the autostop reminder self-heal path, where a failed enqueue re-arms the +// idempotence marker so a later tick retries. +type failOnceEnqueuer struct { + notifications.Enqueuer // the wrapped *notificationstest.FakeEnqueuer + + 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() + if !f.failed { + f.failed = true + f.mu.Unlock() + return nil, xerrors.New("injected enqueue failure") + } + f.mu.Unlock() + 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 + _, tickCh, statsCh, notifyEnq, workspace := setupAutostopReminderWorkspace(t, timeTilNotify) + 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) + require.Contains(t, stats.AutostopReminders, workspace.ID) + require.WithinDuration(t, deadline, stats.AutostopReminders[workspace.ID], time.Second) + + 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 + _, tickCh, statsCh, notifyEnq, workspace := setupAutostopReminderWorkspace(t, timeTilNotify) + 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.NotContains(t, stats.AutostopReminders, workspace.ID) + 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() + + _, tickCh, statsCh, notifyEnq, workspace := setupAutostopReminderWorkspace(t, 0) + 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.NotContains(t, stats.AutostopReminders, workspace.ID) + 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 + _, tickCh, statsCh, notifyEnq, workspace := setupAutostopReminderWorkspace(t, timeTilNotify) + deadline := workspace.LatestBuild.Deadline.Time + + // First tick: reminder fires. + go func() { + tickCh <- deadline.Add(-timeTilNotify / 2) + }() + stats := testutil.TryReceive(testutil.Context(t, testutil.WaitShort), t, statsCh) + require.Contains(t, stats.AutostopReminders, workspace.ID) + + // Second tick still inside the window: no new reminder. + go func() { + tickCh <- deadline.Add(-timeTilNotify / 4) + close(tickCh) + }() + stats = testutil.TryReceive(testutil.Context(t, testutil.WaitShort), t, statsCh) + require.NotContains(t, stats.AutostopReminders, workspace.ID) + + 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 + client, tickCh, statsCh, notifyEnq, workspace := setupAutostopReminderWorkspace(t, timeTilNotify) + deadline := workspace.LatestBuild.Deadline.Time + + // First tick: reminder fires for the original deadline. + go func() { + tickCh <- deadline.Add(-timeTilNotify / 2) + }() + stats := testutil.TryReceive(ctx, t, statsCh) + require.Contains(t, stats.AutostopReminders, workspace.ID) + require.WithinDuration(t, deadline, stats.AutostopReminders[workspace.ID], time.Second) + 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. + go func() { + tickCh <- newDeadline.Add(-timeTilNotify / 2) + close(tickCh) + }() + stats = testutil.TryReceive(ctx, t, statsCh) + require.Contains(t, stats.AutostopReminders, workspace.ID) + require.WithinDuration(t, newDeadline, stats.AutostopReminders[workspace.ID], time.Second) + 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 + _, tickCh, statsCh, notifyEnq, workspace := setupAutostopReminderWorkspace(t, timeTilNotify) + deadline := workspace.LatestBuild.Deadline.Time + + // First tick: a single reminder fires. + go func() { + tickCh <- deadline.Add(-time.Hour) + }() + stats := testutil.TryReceive(testutil.Context(t, testutil.WaitShort), t, statsCh) + require.Contains(t, stats.AutostopReminders, workspace.ID) + + // Second tick still before the deadline: no flood of reminders. + go func() { + tickCh <- deadline.Add(-30 * time.Minute) + close(tickCh) + }() + stats = testutil.TryReceive(testutil.Context(t, testutil.WaitShort), t, statsCh) + require.NotContains(t, stats.AutostopReminders, workspace.ID) + + require.Len(t, notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceAutostopReminder)), 1) + }) + + // SelfHealsOnEnqueueFailure: if the enqueue after the marker-stamping + // transaction fails, the marker is re-armed so a later tick retries and + // the reminder still goes out exactly once. + t.Run("SelfHealsOnEnqueueFailure", func(t *testing.T) { + t.Parallel() + + // A wide lead window so two consecutive in-window ticks (which the + // executor truncates to the minute) both satisfy + // deadline - ttl <= tick < deadline. + timeTilNotify := 2 * time.Hour + fake := ¬ificationstest.FakeEnqueuer{} + enq := &failOnceEnqueuer{Enqueuer: fake} + _, tickCh, statsCh, workspace := setupAutostopReminderWorkspaceWithEnqueuer(t, timeTilNotify, enq) + deadline := workspace.LatestBuild.Deadline.Time + + // Tick 1 (in-window): the enqueue fails, so nothing is recorded as + // sent and the workspace is dropped from the reminder stats. + go func() { + tickCh <- deadline.Add(-timeTilNotify / 2) + }() + stats := testutil.TryReceive(testutil.Context(t, testutil.WaitShort), t, statsCh) + require.NotContains(t, stats.AutostopReminders, workspace.ID) + require.Empty(t, fake.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceAutostopReminder))) + + // Tick 2 (still in-window): the marker was re-armed, so the workspace + // is re-selected and the reminder succeeds exactly once. + go func() { + tickCh <- deadline.Add(-timeTilNotify / 4) + close(tickCh) + }() + stats = testutil.TryReceive(testutil.Context(t, testutil.WaitShort), t, statsCh) + require.Len(t, stats.Errors, 0) + require.Contains(t, stats.AutostopReminders, workspace.ID) + require.WithinDuration(t, deadline, stats.AutostopReminders[workspace.ID], time.Second) + + sent := fake.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceAutostopReminder)) + require.Len(t, sent, 1) + require.Equal(t, workspace.OwnerID, sent[0].UserID) + require.Equal(t, deadline.UTC().Format(time.RFC1123), sent[0].Labels["deadline"]) + }) +} + 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 6aec60c2dc4f1..7fa1ec5f09780 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -5686,6 +5686,10 @@ func (q *querier) GetWorkspacesByTemplateID(ctx context.Context, templateID uuid return q.db.GetWorkspacesByTemplateID(ctx, templateID) } +func (q *querier) GetWorkspacesEligibleForAutostopReminder(ctx context.Context, now time.Time) ([]database.GetWorkspacesEligibleForAutostopReminderRow, error) { + return q.db.GetWorkspacesEligibleForAutostopReminder(ctx, now) +} + func (q *querier) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]database.GetWorkspacesEligibleForTransitionRow, error) { return q.db.GetWorkspacesEligibleForTransition(ctx, now) } @@ -8415,6 +8419,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 aed9a9580b09c..c748774cfd124 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -4150,6 +4150,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{}) @@ -5302,6 +5311,11 @@ func (s *MethodTestSuite) TestSystemFunctions() { dbm.EXPECT().GetWorkspacesEligibleForTransition(gomock.Any(), t).Return([]database.GetWorkspacesEligibleForTransitionRow{}, nil).AnyTimes() check.Args(t).Asserts() })) + s.Run("GetWorkspacesEligibleForAutostopReminder", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + t := time.Time{} + dbm.EXPECT().GetWorkspacesEligibleForAutostopReminder(gomock.Any(), t).Return([]database.GetWorkspacesEligibleForAutostopReminderRow{}, nil).AnyTimes() + check.Args(t).Asserts() + })) s.Run("InsertTemplateVersionVariable", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { arg := database.InsertTemplateVersionVariableParams{} dbm.EXPECT().InsertTemplateVersionVariable(gomock.Any(), arg).Return(testutil.Fake(s.T(), gofakeit.New(0), database.TemplateVersionVariable{}), nil).AnyTimes() diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index a600a6371be85..c7f4fe6071015 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -3858,6 +3858,14 @@ func (m queryMetricsStore) GetWorkspacesByTemplateID(ctx context.Context, templa return r0, r1 } +func (m queryMetricsStore) GetWorkspacesEligibleForAutostopReminder(ctx context.Context, now time.Time) ([]database.GetWorkspacesEligibleForAutostopReminderRow, error) { + start := time.Now() + r0, r1 := m.s.GetWorkspacesEligibleForAutostopReminder(ctx, now) + m.queryLatencies.WithLabelValues("GetWorkspacesEligibleForAutostopReminder").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetWorkspacesEligibleForAutostopReminder").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]database.GetWorkspacesEligibleForTransitionRow, error) { start := time.Now() r0, r1 := m.s.GetWorkspacesEligibleForTransition(ctx, now) @@ -5946,6 +5954,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 75ea063c0d65b..314ed11ca52cc 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -7212,6 +7212,21 @@ func (mr *MockStoreMockRecorder) GetWorkspacesByTemplateID(ctx, templateID any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspacesByTemplateID", reflect.TypeOf((*MockStore)(nil).GetWorkspacesByTemplateID), ctx, templateID) } +// GetWorkspacesEligibleForAutostopReminder mocks base method. +func (m *MockStore) GetWorkspacesEligibleForAutostopReminder(ctx context.Context, now time.Time) ([]database.GetWorkspacesEligibleForAutostopReminderRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetWorkspacesEligibleForAutostopReminder", ctx, now) + ret0, _ := ret[0].([]database.GetWorkspacesEligibleForAutostopReminderRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetWorkspacesEligibleForAutostopReminder indicates an expected call of GetWorkspacesEligibleForAutostopReminder. +func (mr *MockStoreMockRecorder) GetWorkspacesEligibleForAutostopReminder(ctx, now any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspacesEligibleForAutostopReminder", reflect.TypeOf((*MockStore)(nil).GetWorkspacesEligibleForAutostopReminder), ctx, now) +} + // GetWorkspacesEligibleForTransition mocks base method. func (m *MockStore) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]database.GetWorkspacesEligibleForTransitionRow, error) { m.ctrl.T.Helper() @@ -11151,6 +11166,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 eb934e3a927d1..0736f650546b7 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -973,6 +973,17 @@ 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) + // Returns running workspaces whose latest start build is approaching its + // autostop deadline and for which a reminder notification has not yet been + // sent for the current 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 query 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. + GetWorkspacesEligibleForAutostopReminder(ctx context.Context, now time.Time) ([]GetWorkspacesEligibleForAutostopReminderRow, error) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]GetWorkspacesEligibleForTransitionRow, error) GetWorkspacesForWorkspaceMetrics(ctx context.Context) ([]GetWorkspacesForWorkspaceMetricsRow, error) // Stamps the pinned hash and error on every not-yet-hydrated chat for @@ -1472,6 +1483,11 @@ 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 delivered, which + // makes the lifecycle executor's reminder pass 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 f2d26920e541e..023f069de0d6b 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -36329,6 +36329,30 @@ 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 delivered, which +// makes the lifecycle executor's reminder pass 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 @@ -38212,6 +38236,91 @@ func (q *sqlQuerier) GetWorkspacesByTemplateID(ctx context.Context, templateID u return items, nil } +const getWorkspacesEligibleForAutostopReminder = `-- name: GetWorkspacesEligibleForAutostopReminder :many +SELECT + workspaces.id, + workspaces.name +FROM + workspaces +LEFT JOIN + workspace_builds ON workspace_builds.workspace_id = workspaces.id +INNER JOIN + provisioner_jobs ON workspace_builds.job_id = provisioner_jobs.id +INNER JOIN + templates ON workspaces.template_id = templates.id +INNER JOIN + users ON workspaces.owner_id = users.id +WHERE + workspace_builds.build_number = ( + SELECT + MAX(build_number) + FROM + workspace_builds + WHERE + workspace_builds.workspace_id = workspaces.id + ) AND + -- The latest build must be a successfully provisioned start build. + provisioner_jobs.job_status = 'succeeded'::provisioner_job_status AND + workspace_builds.transition = 'start'::workspace_transition AND + -- The workspace must not be dormant and its owner must not be suspended. + workspaces.dormant_at IS NULL AND + users.status != 'suspended'::user_status AND + -- The build must have a deadline that has not already passed. We never + -- "remind" about a stop that is already due. + workspace_builds.deadline != '0001-01-01 00:00:00+00'::timestamptz AND + workspace_builds.deadline > $1::timestamptz AND + -- The template must opt in to autostop reminders. + templates.time_til_autostop_notify > 0 AND + -- "now" must be within the lead window before the deadline. The field is + -- stored in nanoseconds, so convert to an interval the same way the + -- dormancy query does: nanoseconds / 1000000 yields milliseconds. + workspace_builds.deadline <= ($1::timestamptz) + (INTERVAL '1 millisecond' * (templates.time_til_autostop_notify / 1000000)) AND + -- A reminder has not yet been sent for THIS deadline. + workspace_builds.notified_autostop_deadline != workspace_builds.deadline AND + workspaces.deleted = 'false' AND + -- Prebuilt workspaces (identified by having the prebuilds system user as + -- owner_id) are handled by the prebuilds reconciliation loop. + workspaces.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID +` + +type GetWorkspacesEligibleForAutostopReminderRow struct { + ID uuid.UUID `db:"id" json:"id"` + Name string `db:"name" json:"name"` +} + +// Returns running workspaces whose latest start build is approaching its +// autostop deadline and for which a reminder notification has not yet been +// sent for the current 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 query 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. +func (q *sqlQuerier) GetWorkspacesEligibleForAutostopReminder(ctx context.Context, now time.Time) ([]GetWorkspacesEligibleForAutostopReminderRow, error) { + rows, err := q.db.QueryContext(ctx, getWorkspacesEligibleForAutostopReminder, now) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetWorkspacesEligibleForAutostopReminderRow + for rows.Next() { + var i GetWorkspacesEligibleForAutostopReminderRow + if err := rows.Scan(&i.ID, &i.Name); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getWorkspacesEligibleForTransition = `-- name: GetWorkspacesEligibleForTransition :many SELECT workspaces.id, diff --git a/coderd/database/queries/workspacebuilds.sql b/coderd/database/queries/workspacebuilds.sql index 7767cd0b6fd6d..06071115d7b93 100644 --- a/coderd/database/queries/workspacebuilds.sql +++ b/coderd/database/queries/workspacebuilds.sql @@ -141,6 +141,18 @@ 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 delivered, which +-- makes the lifecycle executor's reminder pass 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 c9ed2ed446326..f0a2a156d8e4c 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -870,6 +870,62 @@ WHERE -- prebuilds reconciliation loop. AND workspaces.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID; +-- name: GetWorkspacesEligibleForAutostopReminder :many +-- Returns running workspaces whose latest start build is approaching its +-- autostop deadline and for which a reminder notification has not yet been +-- sent for the current 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 query 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. +SELECT + workspaces.id, + workspaces.name +FROM + workspaces +LEFT JOIN + workspace_builds ON workspace_builds.workspace_id = workspaces.id +INNER JOIN + provisioner_jobs ON workspace_builds.job_id = provisioner_jobs.id +INNER JOIN + templates ON workspaces.template_id = templates.id +INNER JOIN + users ON workspaces.owner_id = users.id +WHERE + workspace_builds.build_number = ( + SELECT + MAX(build_number) + FROM + workspace_builds + WHERE + workspace_builds.workspace_id = workspaces.id + ) AND + -- The latest build must be a successfully provisioned start build. + provisioner_jobs.job_status = 'succeeded'::provisioner_job_status AND + workspace_builds.transition = 'start'::workspace_transition AND + -- The workspace must not be dormant and its owner must not be suspended. + workspaces.dormant_at IS NULL AND + users.status != 'suspended'::user_status AND + -- The build must have a deadline that has not already passed. We never + -- "remind" about a stop that is already due. + workspace_builds.deadline != '0001-01-01 00:00:00+00'::timestamptz AND + workspace_builds.deadline > @now::timestamptz AND + -- The template must opt in to autostop reminders. + templates.time_til_autostop_notify > 0 AND + -- "now" must be within the lead window before the deadline. The field is + -- stored in nanoseconds, so convert to an interval the same way the + -- dormancy query does: nanoseconds / 1000000 yields milliseconds. + workspace_builds.deadline <= (@now::timestamptz) + (INTERVAL '1 millisecond' * (templates.time_til_autostop_notify / 1000000)) AND + -- A reminder has not yet been sent for THIS deadline. + workspace_builds.notified_autostop_deadline != workspace_builds.deadline AND + workspaces.deleted = 'false' AND + -- Prebuilt workspaces (identified by having the prebuilds system user as + -- owner_id) are handled by the prebuilds reconciliation loop. + workspaces.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID; + -- name: UpdateWorkspaceDormantDeletingAt :one UPDATE workspaces