feat: notify users before workspace autostop (#26676)

This commit is contained in:
Jon Ayers
2026-06-26 01:25:23 -05:00
committed by GitHub
parent a163d43981
commit 637a801a41
11 changed files with 693 additions and 36 deletions
+91 -13
View File
@@ -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
}
}
@@ -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()
+273
View File
@@ -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 := &notificationstest.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 := &notificationstest.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 := &notificationstest.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 := &notificationstest.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 := &notificationstest.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 := &notificationstest.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 := &notificationstest.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)