diff --git a/coderd/activitybump.go b/coderd/activitybump.go index 87e9ede552d2e..89474bad7f2aa 100644 --- a/coderd/activitybump.go +++ b/coderd/activitybump.go @@ -12,13 +12,39 @@ import ( ) // activityBumpWorkspace automatically bumps the workspace's auto-off timer -// if it is set to expire soon. -func activityBumpWorkspace(ctx context.Context, log slog.Logger, db database.Store, workspaceID uuid.UUID) { +// if it is set to expire soon. The deadline will be bumped by 1 hour*. +// If the bump crosses over an autostart time, the workspace will be +// bumped by the workspace ttl instead. +// +// If nextAutostart is the zero value or in the past, the workspace +// will be bumped by 1 hour. +// It handles the edge case in the example: +// 1. Autostart is set to 9am. +// 2. User works all day, and leaves a terminal open to the workspace overnight. +// 3. The open terminal continually bumps the workspace deadline. +// 4. 9am the next day, the activity bump pushes to 10am. +// 5. If the user goes inactive for 1 hour during the day, the workspace will +// now stop, because it has been extended by 1 hour durations. Despite the TTL +// being set to 8hrs from the autostart time. +// +// So the issue is that when the workspace is bumped across an autostart +// deadline, we should treat the workspace as being "started" again and +// extend the deadline by the autostart time + workspace ttl instead. +// +// The issue still remains with build_max_deadline. We need to respect the original +// maximum deadline, so that will need to be handled separately. +// A way to avoid this is to configure the max deadline to something that will not +// span more than 1 day. This will force the workspace to restart and reset the deadline +// each morning when it autostarts. +func activityBumpWorkspace(ctx context.Context, log slog.Logger, db database.Store, workspaceID uuid.UUID, nextAutostart time.Time) { // We set a short timeout so if the app is under load, these // low priority operations fail first. ctx, cancel := context.WithTimeout(ctx, time.Second*15) defer cancel() - if err := db.ActivityBumpWorkspace(ctx, workspaceID); err != nil { + if err := db.ActivityBumpWorkspace(ctx, database.ActivityBumpWorkspaceParams{ + NextAutostart: nextAutostart.UTC(), + WorkspaceID: workspaceID, + }); err != nil { if !xerrors.Is(err, context.Canceled) && !database.IsQueryCanceledError(err) { // Bump will fail if the context is canceled, but this is ok. log.Error(ctx, "bump failed", slog.Error(err), diff --git a/coderd/activitybump_internal_test.go b/coderd/activitybump_internal_test.go index 3e5f7c1848db3..2ee9fc699ce6f 100644 --- a/coderd/activitybump_internal_test.go +++ b/coderd/activitybump_internal_test.go @@ -42,6 +42,7 @@ func Test_ActivityBumpWorkspace(t *testing.T) { templateTTL time.Duration templateDisallowsUserAutostop bool expectedBump time.Duration + nextAutostart time.Time }{ { name: "NotFinishedYet", @@ -66,22 +67,41 @@ func Test_ActivityBumpWorkspace(t *testing.T) { workspaceTTL: 8 * time.Hour, expectedBump: 0, }, + { + // Expected bump is 0 because the original deadline is more than 1 hour + // out, so a bump would decrease the deadline. + name: "BumpLessThanDeadline", + transition: database.WorkspaceTransitionStart, + jobCompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now().Add(-30 * time.Minute)}, + buildDeadlineOffset: ptr.Ref(8*time.Hour - 30*time.Minute), + workspaceTTL: 8 * time.Hour, + expectedBump: 0, + }, { name: "TimeToBump", transition: database.WorkspaceTransitionStart, - jobCompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now().Add(-24 * time.Minute)}, - buildDeadlineOffset: ptr.Ref(8*time.Hour - 24*time.Minute), + jobCompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now().Add(-30 * time.Minute)}, + buildDeadlineOffset: ptr.Ref(-30 * time.Minute), + workspaceTTL: 8 * time.Hour, + expectedBump: time.Hour, + }, + { + name: "TimeToBumpNextAutostart", + transition: database.WorkspaceTransitionStart, + jobCompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now().Add(-30 * time.Minute)}, + buildDeadlineOffset: ptr.Ref(-30 * time.Minute), workspaceTTL: 8 * time.Hour, - expectedBump: 8 * time.Hour, + expectedBump: 8*time.Hour + 30*time.Minute, + nextAutostart: time.Now().Add(time.Minute * 30), }, { name: "MaxDeadline", transition: database.WorkspaceTransitionStart, jobCompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now().Add(-24 * time.Minute)}, buildDeadlineOffset: ptr.Ref(time.Minute), // last chance to bump! - maxDeadlineOffset: ptr.Ref(time.Hour), + maxDeadlineOffset: ptr.Ref(time.Minute * 30), workspaceTTL: 8 * time.Hour, - expectedBump: 1 * time.Hour, + expectedBump: time.Minute * 30, }, { // A workspace that is still running, has passed its deadline, but has not @@ -91,7 +111,7 @@ func Test_ActivityBumpWorkspace(t *testing.T) { jobCompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now().Add(-24 * time.Minute)}, buildDeadlineOffset: ptr.Ref(-time.Minute), workspaceTTL: 8 * time.Hour, - expectedBump: 8 * time.Hour, + expectedBump: time.Hour, }, { // A stopped workspace should never bump. @@ -106,12 +126,13 @@ func Test_ActivityBumpWorkspace(t *testing.T) { // by the template TTL instead. name: "TemplateDisallowsUserAutostop", transition: database.WorkspaceTransitionStart, - jobCompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now().Add(-24 * time.Minute)}, - buildDeadlineOffset: ptr.Ref(8*time.Hour - 24*time.Minute), - workspaceTTL: 6 * time.Hour, - templateTTL: 8 * time.Hour, + jobCompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now().Add(-7 * time.Hour)}, + buildDeadlineOffset: ptr.Ref(-30 * time.Minute), + workspaceTTL: 2 * time.Hour, + templateTTL: 10 * time.Hour, templateDisallowsUserAutostop: true, - expectedBump: 8 * time.Hour, + expectedBump: 10*time.Hour + (time.Minute * 30), + nextAutostart: time.Now().Add(time.Minute * 30), }, } { tt := tt @@ -215,7 +236,7 @@ func Test_ActivityBumpWorkspace(t *testing.T) { // Bump duration is measured from the time of the bump, so we measure from here. start := dbtime.Now() - activityBumpWorkspace(ctx, log, db, bld.WorkspaceID) + activityBumpWorkspace(ctx, log, db, bld.WorkspaceID, tt.nextAutostart) end := dbtime.Now() // Validate our state after bump @@ -233,9 +254,9 @@ func Test_ActivityBumpWorkspace(t *testing.T) { return } - // Assert that the bump occurred between start and end. - expectedDeadlineStart := start.Add(tt.expectedBump) - expectedDeadlineEnd := end.Add(tt.expectedBump) + // Assert that the bump occurred between start and end. 1min buffer on either side. + expectedDeadlineStart := start.Add(tt.expectedBump).Add(time.Minute * -1) + expectedDeadlineEnd := end.Add(tt.expectedBump).Add(time.Minute) require.GreaterOrEqual(t, updatedBuild.Deadline, expectedDeadlineStart, "new deadline should be greater than or equal to start") require.LessOrEqual(t, updatedBuild.Deadline, expectedDeadlineEnd, "new deadline should be lesser than or equal to end") }) diff --git a/coderd/activitybump_test.go b/coderd/activitybump_test.go index 30c338b37a7c7..0b55b40508966 100644 --- a/coderd/activitybump_test.go +++ b/coderd/activitybump_test.go @@ -30,7 +30,7 @@ func TestWorkspaceActivityBump(t *testing.T) { // max_deadline on the build directly in the database. setupActivityTest := func(t *testing.T, deadline ...time.Duration) (client *codersdk.Client, workspace codersdk.Workspace, assertBumped func(want bool)) { t.Helper() - const ttl = time.Minute + const ttl = time.Hour maxTTL := time.Duration(0) if len(deadline) > 0 { maxTTL = deadline[0] @@ -71,28 +71,29 @@ func TestWorkspaceActivityBump(t *testing.T) { }) coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + var maxDeadline time.Time // Update the max deadline. if maxTTL != 0 { - dbBuild, err := db.GetWorkspaceBuildByID(ctx, workspace.LatestBuild.ID) - require.NoError(t, err) - - err = db.UpdateWorkspaceBuildDeadlineByID(ctx, database.UpdateWorkspaceBuildDeadlineByIDParams{ - ID: workspace.LatestBuild.ID, - UpdatedAt: dbtime.Now(), - Deadline: dbBuild.Deadline, - MaxDeadline: dbtime.Now().Add(maxTTL), - }) - require.NoError(t, err) + maxDeadline = dbtime.Now().Add(maxTTL) } + err := db.UpdateWorkspaceBuildDeadlineByID(ctx, database.UpdateWorkspaceBuildDeadlineByIDParams{ + ID: workspace.LatestBuild.ID, + UpdatedAt: dbtime.Now(), + // Make the deadline really close so it needs to be bumped immediately. + Deadline: dbtime.Now().Add(time.Minute), + MaxDeadline: maxDeadline, + }) + require.NoError(t, err) + _ = agenttest.New(t, client.URL, agentToken) coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) - // Sanity-check that deadline is near. - workspace, err := client.Workspace(ctx, workspace.ID) + // Sanity-check that deadline is nearing requiring a bump. + workspace, err = client.Workspace(ctx, workspace.ID) require.NoError(t, err) require.WithinDuration(t, - time.Now().Add(time.Duration(ttlMillis)*time.Millisecond), + time.Now().Add(time.Minute), workspace.LatestBuild.Deadline.Time, testutil.WaitMedium, ) @@ -133,7 +134,7 @@ func TestWorkspaceActivityBump(t *testing.T) { workspace, err = client.Workspace(ctx, workspace.ID) require.NoError(t, err) updatedAfter = dbtime.Now() - if workspace.LatestBuild.Deadline.Time == firstDeadline { + if workspace.LatestBuild.Deadline.Time.Equal(firstDeadline) { updatedAfter = time.Now() return false } @@ -151,6 +152,13 @@ func TestWorkspaceActivityBump(t *testing.T) { require.LessOrEqual(t, workspace.LatestBuild.Deadline.Time, workspace.LatestBuild.MaxDeadline.Time) return } + now := dbtime.Now() + zone, offset := time.Now().Zone() + t.Logf("[Zone=%s %d] originDeadline: %s, deadline: %s, now %s, (now-deadline)=%s", + zone, offset, + firstDeadline, workspace.LatestBuild.Deadline.Time, now, + now.Sub(workspace.LatestBuild.Deadline.Time), + ) require.WithinDuration(t, dbtime.Now().Add(ttl), workspace.LatestBuild.Deadline.Time, testutil.WaitShort) } } @@ -192,9 +200,9 @@ func TestWorkspaceActivityBump(t *testing.T) { t.Run("NotExceedMaxDeadline", func(t *testing.T) { t.Parallel() - // Set the max deadline to be in 61 seconds. We bump by 1 minute, so we + // Set the max deadline to be in 30min. We bump by 1 hour, so we // should expect the deadline to match the max deadline exactly. - client, workspace, assertBumped := setupActivityTest(t, 61*time.Second) + client, workspace, assertBumped := setupActivityTest(t, time.Minute*30) // Bump by dialing the workspace and sending traffic. resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) diff --git a/coderd/autobuild/lifecycle_executor.go b/coderd/autobuild/lifecycle_executor.go index 8d3bfb5478dc7..9fa1aaedb0ee4 100644 --- a/coderd/autobuild/lifecycle_executor.go +++ b/coderd/autobuild/lifecycle_executor.go @@ -357,13 +357,27 @@ func isEligibleForAutostart(ws database.Workspace, build database.WorkspaceBuild return false } - sched, err := cron.Weekly(ws.AutostartSchedule.String) - if err != nil { + nextTransition, allowed := NextAutostartSchedule(build.CreatedAt, ws.AutostartSchedule.String, templateSchedule) + if !allowed { return false } + + // Must use '.Before' vs '.After' so equal times are considered "valid for autostart". + return !currentTick.Before(nextTransition) +} + +// NextAutostartSchedule takes the workspace and template schedule and returns the next autostart schedule +// after "at". The boolean returned is if the autostart should be allowed to start based on the template +// schedule. +func NextAutostartSchedule(at time.Time, wsSchedule string, templateSchedule schedule.TemplateScheduleOptions) (time.Time, bool) { + sched, err := cron.Weekly(wsSchedule) + if err != nil { + return time.Time{}, false + } + // Round down to the nearest minute, as this is the finest granularity cron supports. // Truncate is probably not necessary here, but doing it anyway to be sure. - nextTransition := sched.Next(build.CreatedAt).Truncate(time.Minute) + nextTransition := sched.Next(at).Truncate(time.Minute) // The nextTransition is when the auto start should kick off. If it lands on a // forbidden day, do not allow the auto start. We use the time location of the @@ -371,12 +385,8 @@ func isEligibleForAutostart(ws database.Workspace, build database.WorkspaceBuild // definition of "Saturday" depends on the location of the schedule. zonedTransition := nextTransition.In(sched.Location()) allowed := templateSchedule.AutostartRequirement.DaysMap()[zonedTransition.Weekday()] - if !allowed { - return false - } - // Must used '.Before' vs '.After' so equal times are considered "valid for autostart". - return !currentTick.Before(nextTransition) + return zonedTransition, allowed } // isEligibleForAutostart returns true if the workspace should be autostopped. diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index d26302c54d091..684f6fc75eb07 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -660,9 +660,9 @@ func (q *querier) AcquireProvisionerJob(ctx context.Context, arg database.Acquir return q.db.AcquireProvisionerJob(ctx, arg) } -func (q *querier) ActivityBumpWorkspace(ctx context.Context, arg uuid.UUID) error { - fetch := func(ctx context.Context, arg uuid.UUID) (database.Workspace, error) { - return q.db.GetWorkspaceByID(ctx, arg) +func (q *querier) ActivityBumpWorkspace(ctx context.Context, arg database.ActivityBumpWorkspaceParams) error { + fetch := func(ctx context.Context, arg database.ActivityBumpWorkspaceParams) (database.Workspace, error) { + return q.db.GetWorkspaceByID(ctx, arg.WorkspaceID) } return update(q.log, q.auth, fetch, q.db.ActivityBumpWorkspace)(ctx, arg) } diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 985b2d4b422ee..5bb7a1eb00a88 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -678,6 +678,13 @@ func (q *FakeQuerier) GetActiveDBCryptKeys(_ context.Context) ([]database.DBCryp return ks, nil } +func maxTime(t, u time.Time) time.Time { + if t.After(u) { + return t + } + return u +} + func minTime(t, u time.Time) time.Time { if t.Before(u) { return t @@ -775,8 +782,8 @@ func (q *FakeQuerier) AcquireProvisionerJob(_ context.Context, arg database.Acqu return database.ProvisionerJob{}, sql.ErrNoRows } -func (q *FakeQuerier) ActivityBumpWorkspace(ctx context.Context, workspaceID uuid.UUID) error { - err := validateDatabaseType(workspaceID) +func (q *FakeQuerier) ActivityBumpWorkspace(ctx context.Context, arg database.ActivityBumpWorkspaceParams) error { + err := validateDatabaseType(arg) if err != nil { return err } @@ -784,11 +791,11 @@ func (q *FakeQuerier) ActivityBumpWorkspace(ctx context.Context, workspaceID uui q.mutex.Lock() defer q.mutex.Unlock() - workspace, err := q.getWorkspaceByIDNoLock(ctx, workspaceID) + workspace, err := q.getWorkspaceByIDNoLock(ctx, arg.WorkspaceID) if err != nil { return err } - latestBuild, err := q.getLatestWorkspaceBuildByWorkspaceIDNoLock(ctx, workspaceID) + latestBuild, err := q.getLatestWorkspaceBuildByWorkspaceIDNoLock(ctx, arg.WorkspaceID) if err != nil { return err } @@ -822,15 +829,17 @@ func (q *FakeQuerier) ActivityBumpWorkspace(ctx context.Context, workspaceID uui } var ttlDur time.Duration - if workspace.Ttl.Valid { - ttlDur = time.Duration(workspace.Ttl.Int64) - } - if !template.AllowUserAutostop { - ttlDur = time.Duration(template.DefaultTTL) - } - if ttlDur <= 0 { - // There's no TTL set anymore, so we don't know the bump duration. - return nil + if now.Add(time.Hour).After(arg.NextAutostart) && arg.NextAutostart.After(now) { + // Extend to TTL + add := arg.NextAutostart.Sub(now) + if workspace.Ttl.Valid && template.AllowUserAutostop { + add += time.Duration(workspace.Ttl.Int64) + } else { + add += time.Duration(template.DefaultTTL) + } + ttlDur = add + } else { + ttlDur = time.Hour } // Only bump if 5% of the deadline has passed. @@ -842,6 +851,8 @@ func (q *FakeQuerier) ActivityBumpWorkspace(ctx context.Context, workspaceID uui // Bump. newDeadline := now.Add(ttlDur) + // Never decrease deadlines from a bump + newDeadline = maxTime(newDeadline, q.workspaceBuilds[i].Deadline) q.workspaceBuilds[i].UpdatedAt = now if !q.workspaceBuilds[i].MaxDeadline.IsZero() { q.workspaceBuilds[i].Deadline = minTime(newDeadline, q.workspaceBuilds[i].MaxDeadline) diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 3d04591938b0d..64a2ba7783af3 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -93,7 +93,7 @@ func (m metricsStore) AcquireProvisionerJob(ctx context.Context, arg database.Ac return provisionerJob, err } -func (m metricsStore) ActivityBumpWorkspace(ctx context.Context, arg uuid.UUID) error { +func (m metricsStore) ActivityBumpWorkspace(ctx context.Context, arg database.ActivityBumpWorkspaceParams) error { start := time.Now() r0 := m.s.ActivityBumpWorkspace(ctx, arg) m.queryLatencies.WithLabelValues("ActivityBumpWorkspace").Observe(time.Since(start).Seconds()) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index bfa9cebc01a13..76229b3742d46 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -69,7 +69,7 @@ func (mr *MockStoreMockRecorder) AcquireProvisionerJob(arg0, arg1 interface{}) * } // ActivityBumpWorkspace mocks base method. -func (m *MockStore) ActivityBumpWorkspace(arg0 context.Context, arg1 uuid.UUID) error { +func (m *MockStore) ActivityBumpWorkspace(arg0 context.Context, arg1 database.ActivityBumpWorkspaceParams) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ActivityBumpWorkspace", arg0, arg1) ret0, _ := ret[0].(error) diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 1332644ac4a06..98316983aef84 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -24,14 +24,16 @@ type sqlcQuerier interface { // multiple provisioners from acquiring the same jobs. See: // https://www.postgresql.org/docs/9.5/sql-select.html#SQL-FOR-UPDATE-SHARE AcquireProvisionerJob(ctx context.Context, arg AcquireProvisionerJobParams) (ProvisionerJob, error) - // We bump by the original TTL to prevent counter-intuitive behavior - // as the TTL wraps. For example, if I set the TTL to 12 hours, sign off - // work at midnight, come back at 10am, I would want another full day - // of uptime. + // Bumps the workspace deadline by 1 hour. If the workspace bump will + // cross an autostart threshold, then the bump is autostart + TTL. This + // is the deadline behavior if the workspace was to autostart from a stopped + // state. + // Max deadline is respected, and will never be bumped. + // The deadline will never decrease. // We only bump if the raw interval is positive and non-zero. // We only bump if workspace shutdown is manual. // We only bump when 5% of the deadline has elapsed. - ActivityBumpWorkspace(ctx context.Context, workspaceID uuid.UUID) error + ActivityBumpWorkspace(ctx context.Context, arg ActivityBumpWorkspaceParams) error // AllUserIDs returns all UserIDs regardless of user status or deletion. AllUserIDs(ctx context.Context) ([]uuid.UUID, error) // Archiving templates is a soft delete action, so is reversible. diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 03aff0ea801b2..7f4004c89d5cc 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -25,9 +25,29 @@ WITH latest AS ( provisioner_jobs.completed_at::timestamp with time zone AS job_completed_at, ( CASE - WHEN templates.allow_user_autostop - THEN (workspaces.ttl / 1000 / 1000 / 1000 || ' seconds')::interval - ELSE (templates.default_ttl / 1000 / 1000 / 1000 || ' seconds')::interval + -- If the extension would push us over the next_autostart + -- interval, then extend the deadline by the full ttl from + -- the autostart time. This will essentially be as if the + -- workspace auto started at the given time and the original + -- TTL was applied. + WHEN NOW() + ('60 minutes')::interval > $1 :: timestamptz + -- If the autostart is behind now(), then the + -- autostart schedule is either the 0 time and not provided, + -- or it was the autostart in the past, which is no longer + -- relevant. If autostart is > 0 and in the past, then + -- that is a mistake by the caller. + AND $1 > NOW() + THEN + -- Extend to the autostart, then add the TTL + (($1 :: timestamptz) - NOW()) + CASE + WHEN templates.allow_user_autostop + THEN (workspaces.ttl / 1000 / 1000 / 1000 || ' seconds')::interval + ELSE (templates.default_ttl / 1000 / 1000 / 1000 || ' seconds')::interval + END + + -- Default to 60 minutes. + ELSE + ('60 minutes')::interval END ) AS ttl_interval FROM workspace_builds @@ -37,7 +57,7 @@ WITH latest AS ( ON workspaces.id = workspace_builds.workspace_id JOIN templates ON templates.id = workspaces.template_id - WHERE workspace_builds.workspace_id = $1::uuid + WHERE workspace_builds.workspace_id = $2::uuid ORDER BY workspace_builds.build_number DESC LIMIT 1 ) @@ -47,8 +67,9 @@ SET updated_at = NOW(), deadline = CASE WHEN l.build_max_deadline = '0001-01-01 00:00:00+00' - THEN NOW() + l.ttl_interval - ELSE LEAST(NOW() + l.ttl_interval, l.build_max_deadline) + -- Never reduce the deadline from activity. + THEN GREATEST(wb.deadline, NOW() + l.ttl_interval) + ELSE LEAST(GREATEST(wb.deadline, NOW() + l.ttl_interval), l.build_max_deadline) END FROM latest l WHERE wb.id = l.build_id @@ -59,15 +80,22 @@ AND l.build_deadline != '0001-01-01 00:00:00+00' AND l.build_deadline - (l.ttl_interval * 0.95) < NOW() ` -// We bump by the original TTL to prevent counter-intuitive behavior -// as the TTL wraps. For example, if I set the TTL to 12 hours, sign off -// work at midnight, come back at 10am, I would want another full day -// of uptime. +type ActivityBumpWorkspaceParams struct { + NextAutostart time.Time `db:"next_autostart" json:"next_autostart"` + WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` +} + +// Bumps the workspace deadline by 1 hour. If the workspace bump will +// cross an autostart threshold, then the bump is autostart + TTL. This +// is the deadline behavior if the workspace was to autostart from a stopped +// state. +// Max deadline is respected, and will never be bumped. +// The deadline will never decrease. // We only bump if the raw interval is positive and non-zero. // We only bump if workspace shutdown is manual. // We only bump when 5% of the deadline has elapsed. -func (q *sqlQuerier) ActivityBumpWorkspace(ctx context.Context, workspaceID uuid.UUID) error { - _, err := q.db.ExecContext(ctx, activityBumpWorkspace, workspaceID) +func (q *sqlQuerier) ActivityBumpWorkspace(ctx context.Context, arg ActivityBumpWorkspaceParams) error { + _, err := q.db.ExecContext(ctx, activityBumpWorkspace, arg.NextAutostart, arg.WorkspaceID) return err } diff --git a/coderd/database/queries/activitybump.sql b/coderd/database/queries/activitybump.sql index fb9ae456501e1..f35628c686369 100644 --- a/coderd/database/queries/activitybump.sql +++ b/coderd/database/queries/activitybump.sql @@ -1,7 +1,9 @@ --- We bump by the original TTL to prevent counter-intuitive behavior --- as the TTL wraps. For example, if I set the TTL to 12 hours, sign off --- work at midnight, come back at 10am, I would want another full day --- of uptime. +-- Bumps the workspace deadline by 1 hour. If the workspace bump will +-- cross an autostart threshold, then the bump is autostart + TTL. This +-- is the deadline behavior if the workspace was to autostart from a stopped +-- state. +-- Max deadline is respected, and will never be bumped. +-- The deadline will never decrease. -- name: ActivityBumpWorkspace :exec WITH latest AS ( SELECT @@ -12,9 +14,29 @@ WITH latest AS ( provisioner_jobs.completed_at::timestamp with time zone AS job_completed_at, ( CASE - WHEN templates.allow_user_autostop - THEN (workspaces.ttl / 1000 / 1000 / 1000 || ' seconds')::interval - ELSE (templates.default_ttl / 1000 / 1000 / 1000 || ' seconds')::interval + -- If the extension would push us over the next_autostart + -- interval, then extend the deadline by the full ttl from + -- the autostart time. This will essentially be as if the + -- workspace auto started at the given time and the original + -- TTL was applied. + WHEN NOW() + ('60 minutes')::interval > @next_autostart :: timestamptz + -- If the autostart is behind now(), then the + -- autostart schedule is either the 0 time and not provided, + -- or it was the autostart in the past, which is no longer + -- relevant. If autostart is > 0 and in the past, then + -- that is a mistake by the caller. + AND @next_autostart > NOW() + THEN + -- Extend to the autostart, then add the TTL + ((@next_autostart :: timestamptz) - NOW()) + CASE + WHEN templates.allow_user_autostop + THEN (workspaces.ttl / 1000 / 1000 / 1000 || ' seconds')::interval + ELSE (templates.default_ttl / 1000 / 1000 / 1000 || ' seconds')::interval + END + + -- Default to 60 minutes. + ELSE + ('60 minutes')::interval END ) AS ttl_interval FROM workspace_builds @@ -34,8 +56,9 @@ SET updated_at = NOW(), deadline = CASE WHEN l.build_max_deadline = '0001-01-01 00:00:00+00' - THEN NOW() + l.ttl_interval - ELSE LEAST(NOW() + l.ttl_interval, l.build_max_deadline) + -- Never reduce the deadline from activity. + THEN GREATEST(wb.deadline, NOW() + l.ttl_interval) + ELSE LEAST(GREATEST(wb.deadline, NOW() + l.ttl_interval), l.build_max_deadline) END FROM latest l WHERE wb.id = l.build_id diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index ff82923036ac3..82a40f699d077 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -30,6 +30,7 @@ import ( "tailscale.com/tailcfg" "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/autobuild" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbtime" @@ -1671,7 +1672,24 @@ func (api *API) workspaceAgentReportStats(rw http.ResponseWriter, r *http.Reques ) if req.ConnectionCount > 0 { - activityBumpWorkspace(ctx, api.Logger.Named("activity_bump"), api.Database, workspace.ID) + var nextAutostart time.Time + if workspace.AutostartSchedule.String != "" { + templateSchedule, err := (*(api.TemplateScheduleStore.Load())).Get(ctx, api.Database, workspace.TemplateID) + // If the template schedule fails to load, just default to bumping without the next trasition and log it. + if err != nil { + api.Logger.Warn(ctx, "failed to load template schedule bumping activity, defaulting to bumping by 60min", + slog.F("workspace_id", workspace.ID), + slog.F("template_id", workspace.TemplateID), + slog.Error(err), + ) + } else { + next, allowed := autobuild.NextAutostartSchedule(time.Now(), workspace.AutostartSchedule.String, templateSchedule) + if allowed { + nextAutostart = next + } + } + } + activityBumpWorkspace(ctx, api.Logger.Named("activity_bump"), api.Database, workspace.ID, nextAutostart) } now := dbtime.Now()