mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: notify users before workspace autostop (#26676)
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4142,6 +4142,15 @@ func (s *MethodTestSuite) TestWorkspace() {
|
||||
dbm.EXPECT().UpdateWorkspaceBuildDeadlineByID(gomock.Any(), arg).Return(nil).AnyTimes()
|
||||
check.Args(arg).Asserts(w, policy.ActionUpdate)
|
||||
}))
|
||||
s.Run("UpdateWorkspaceBuildNotifiedAutostopDeadline", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
w := testutil.Fake(s.T(), faker, database.Workspace{})
|
||||
b := testutil.Fake(s.T(), faker, database.WorkspaceBuild{WorkspaceID: w.ID})
|
||||
arg := database.UpdateWorkspaceBuildNotifiedAutostopDeadlineParams{ID: b.ID, NotifiedAutostopDeadline: b.Deadline}
|
||||
dbm.EXPECT().GetWorkspaceBuildByID(gomock.Any(), b.ID).Return(b, nil).AnyTimes()
|
||||
dbm.EXPECT().GetWorkspaceByID(gomock.Any(), w.ID).Return(w, nil).AnyTimes()
|
||||
dbm.EXPECT().UpdateWorkspaceBuildNotifiedAutostopDeadline(gomock.Any(), arg).Return(nil).AnyTimes()
|
||||
check.Args(arg).Asserts(w, policy.ActionUpdate)
|
||||
}))
|
||||
s.Run("UpdateWorkspaceBuildFlagsByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
u := testutil.Fake(s.T(), faker, database.User{})
|
||||
o := testutil.Fake(s.T(), faker, database.Organization{})
|
||||
@@ -5289,9 +5298,9 @@ func (s *MethodTestSuite) TestSystemFunctions() {
|
||||
dbm.EXPECT().GetWorkspacesByTemplateID(gomock.Any(), id).Return([]database.WorkspaceTable{}, nil).AnyTimes()
|
||||
check.Args(id).Asserts(rbac.ResourceSystem, policy.ActionRead)
|
||||
}))
|
||||
s.Run("GetWorkspacesEligibleForTransition", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
s.Run("GetWorkspacesEligibleForLifecycleAction", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
t := time.Time{}
|
||||
dbm.EXPECT().GetWorkspacesEligibleForTransition(gomock.Any(), t).Return([]database.GetWorkspacesEligibleForTransitionRow{}, nil).AnyTimes()
|
||||
dbm.EXPECT().GetWorkspacesEligibleForLifecycleAction(gomock.Any(), t).Return([]database.GetWorkspacesEligibleForLifecycleActionRow{}, nil).AnyTimes()
|
||||
check.Args(t).Asserts()
|
||||
}))
|
||||
s.Run("InsertTemplateVersionVariable", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
|
||||
+12
-4
@@ -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)
|
||||
|
||||
Generated
+21
-7
@@ -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()
|
||||
|
||||
Generated
+11
-1
@@ -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)
|
||||
|
||||
Generated
+63
-6
@@ -36200,6 +36200,31 @@ func (q *sqlQuerier) UpdateWorkspaceBuildFlagsByID(ctx context.Context, arg Upda
|
||||
return err
|
||||
}
|
||||
|
||||
const updateWorkspaceBuildNotifiedAutostopDeadline = `-- name: UpdateWorkspaceBuildNotifiedAutostopDeadline :exec
|
||||
UPDATE
|
||||
workspace_builds
|
||||
SET
|
||||
notified_autostop_deadline = $1::timestamptz,
|
||||
updated_at = $2::timestamptz
|
||||
WHERE id = $3::uuid
|
||||
`
|
||||
|
||||
type UpdateWorkspaceBuildNotifiedAutostopDeadlineParams struct {
|
||||
NotifiedAutostopDeadline time.Time `db:"notified_autostop_deadline" json:"notified_autostop_deadline"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
}
|
||||
|
||||
// Stamps the deadline value that an autostop reminder was last sent for. Once
|
||||
// this equals the build's deadline the reminder is considered handled and the
|
||||
// lifecycle executor will not send another for this deadline, which makes the
|
||||
// reminder idempotent and HA-safe. It re-arms automatically when the deadline
|
||||
// changes (e.g. an activity bump).
|
||||
func (q *sqlQuerier) UpdateWorkspaceBuildNotifiedAutostopDeadline(ctx context.Context, arg UpdateWorkspaceBuildNotifiedAutostopDeadlineParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateWorkspaceBuildNotifiedAutostopDeadline, arg.NotifiedAutostopDeadline, arg.UpdatedAt, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateWorkspaceBuildProvisionerStateByID = `-- name: UpdateWorkspaceBuildProvisionerStateByID :exec
|
||||
UPDATE
|
||||
workspace_builds
|
||||
@@ -38083,7 +38108,7 @@ func (q *sqlQuerier) GetWorkspacesByTemplateID(ctx context.Context, templateID u
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getWorkspacesEligibleForTransition = `-- name: GetWorkspacesEligibleForTransition :many
|
||||
const getWorkspacesEligibleForLifecycleAction = `-- name: GetWorkspacesEligibleForLifecycleAction :many
|
||||
SELECT
|
||||
workspaces.id,
|
||||
workspaces.name,
|
||||
@@ -38206,6 +38231,34 @@ WHERE
|
||||
provisioner_jobs.job_status = 'failed'::provisioner_job_status AND
|
||||
provisioner_jobs.completed_at IS NOT NULL AND
|
||||
($1 :: timestamptz) - provisioner_jobs.completed_at > (INTERVAL '1 millisecond' * (templates.failure_ttl / 1000000))
|
||||
) OR
|
||||
|
||||
-- A workspace may be eligible for an autostop reminder if the following are true:
|
||||
-- * The latest build is a successfully provisioned start build.
|
||||
-- * The workspace is not dormant and its owner is not suspended.
|
||||
-- * The build has a deadline in the future (we never remind about a stop already due).
|
||||
-- * The template opts in (time_til_autostop_notify > 0) and now is within the lead window.
|
||||
-- * A reminder has not yet been sent for THIS deadline.
|
||||
--
|
||||
-- NOTE: time_til_autostop_notify has no upper bound. If it exceeds a
|
||||
-- workspace's remaining lifetime, the notify window already includes "now"
|
||||
-- at build creation. This arm intentionally still only matches builds whose
|
||||
-- deadline is in the future (deadline > now) and whose marker has not yet
|
||||
-- been stamped (notified_autostop_deadline != deadline), so at most ONE
|
||||
-- reminder is ever produced for a given deadline regardless of how large the
|
||||
-- field is. The field is stored in nanoseconds, so convert to an interval
|
||||
-- the same way the dormancy arm does: nanoseconds / 1000000 yields
|
||||
-- milliseconds.
|
||||
(
|
||||
provisioner_jobs.job_status = 'succeeded'::provisioner_job_status AND
|
||||
workspace_builds.transition = 'start'::workspace_transition AND
|
||||
workspaces.dormant_at IS NULL AND
|
||||
users.status != 'suspended'::user_status AND
|
||||
workspace_builds.deadline != '0001-01-01 00:00:00+00'::timestamptz AND
|
||||
workspace_builds.deadline > $1::timestamptz AND
|
||||
templates.time_til_autostop_notify > 0 AND
|
||||
workspace_builds.deadline <= ($1::timestamptz) + (INTERVAL '1 millisecond' * (templates.time_til_autostop_notify / 1000000)) AND
|
||||
workspace_builds.notified_autostop_deadline != workspace_builds.deadline
|
||||
)
|
||||
)
|
||||
AND workspaces.deleted = 'false'
|
||||
@@ -38215,21 +38268,25 @@ WHERE
|
||||
AND workspaces.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID
|
||||
`
|
||||
|
||||
type GetWorkspacesEligibleForTransitionRow struct {
|
||||
type GetWorkspacesEligibleForLifecycleActionRow struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
BuildTemplateVersionID uuid.NullUUID `db:"build_template_version_id" json:"build_template_version_id"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]GetWorkspacesEligibleForTransitionRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getWorkspacesEligibleForTransition, now)
|
||||
// Returns workspaces the lifecycle executor must act on this tick. An
|
||||
// "action" is a state transition (autostart/autostop/dormancy/delete), a
|
||||
// dormancy mark (which has no build transition), or a one-time autostop
|
||||
// reminder notification (which only stamps a marker, no transition).
|
||||
func (q *sqlQuerier) GetWorkspacesEligibleForLifecycleAction(ctx context.Context, now time.Time) ([]GetWorkspacesEligibleForLifecycleActionRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getWorkspacesEligibleForLifecycleAction, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetWorkspacesEligibleForTransitionRow
|
||||
var items []GetWorkspacesEligibleForLifecycleActionRow
|
||||
for rows.Next() {
|
||||
var i GetWorkspacesEligibleForTransitionRow
|
||||
var i GetWorkspacesEligibleForLifecycleActionRow
|
||||
if err := rows.Scan(&i.ID, &i.Name, &i.BuildTemplateVersionID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -141,6 +141,19 @@ SET
|
||||
updated_at = @updated_at::timestamptz
|
||||
WHERE id = @id::uuid;
|
||||
|
||||
-- name: UpdateWorkspaceBuildNotifiedAutostopDeadline :exec
|
||||
-- Stamps the deadline value that an autostop reminder was last sent for. Once
|
||||
-- this equals the build's deadline the reminder is considered handled and the
|
||||
-- lifecycle executor will not send another for this deadline, which makes the
|
||||
-- reminder idempotent and HA-safe. It re-arms automatically when the deadline
|
||||
-- changes (e.g. an activity bump).
|
||||
UPDATE
|
||||
workspace_builds
|
||||
SET
|
||||
notified_autostop_deadline = @notified_autostop_deadline::timestamptz,
|
||||
updated_at = @updated_at::timestamptz
|
||||
WHERE id = @id::uuid;
|
||||
|
||||
-- name: GetActiveWorkspaceBuildsByTemplateID :many
|
||||
SELECT wb.*
|
||||
FROM (
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user