Skip to content

Commit d30945c

Browse files
authored
feat: bump workspace deadline on user activity (#4119)
Resolves #2995
1 parent 0899548 commit d30945c

File tree

6 files changed

+189
-6
lines changed

6 files changed

+189
-6
lines changed

coderd/activitybump.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package coderd
2+
3+
import (
4+
"context"
5+
"database/sql"
6+
"errors"
7+
"time"
8+
9+
"golang.org/x/xerrors"
10+
11+
"cdr.dev/slog"
12+
"github.com/coder/coder/coderd/database"
13+
)
14+
15+
// activityBumpWorkspace automatically bumps the workspace's auto-off timer
16+
// if it is set to expire soon.
17+
func activityBumpWorkspace(log slog.Logger, db database.Store, workspace database.Workspace) {
18+
// We set a short timeout so if the app is under load, these
19+
// low priority operations fail first.
20+
ctx, cancel := context.WithTimeout(context.Background(), time.Second*15)
21+
defer cancel()
22+
23+
err := db.InTx(func(s database.Store) error {
24+
build, err := s.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID)
25+
if errors.Is(err, sql.ErrNoRows) {
26+
return nil
27+
} else if err != nil {
28+
return xerrors.Errorf("get latest workspace build: %w", err)
29+
}
30+
31+
job, err := s.GetProvisionerJobByID(ctx, build.JobID)
32+
if err != nil {
33+
return xerrors.Errorf("get provisioner job: %w", err)
34+
}
35+
36+
if build.Transition != database.WorkspaceTransitionStart || !job.CompletedAt.Valid {
37+
return nil
38+
}
39+
40+
if build.Deadline.IsZero() {
41+
// Workspace shutdown is manual
42+
return nil
43+
}
44+
45+
// We sent bumpThreshold slightly under bumpAmount to minimize DB writes.
46+
const (
47+
bumpAmount = time.Hour
48+
bumpThreshold = time.Hour - (time.Minute * 10)
49+
)
50+
51+
if !build.Deadline.Before(time.Now().Add(bumpThreshold)) {
52+
return nil
53+
}
54+
55+
newDeadline := database.Now().Add(bumpAmount)
56+
57+
if err := s.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{
58+
ID: build.ID,
59+
UpdatedAt: database.Now(),
60+
ProvisionerState: build.ProvisionerState,
61+
Deadline: newDeadline,
62+
}); err != nil {
63+
return xerrors.Errorf("update workspace build: %w", err)
64+
}
65+
return nil
66+
})
67+
if err != nil {
68+
log.Error(
69+
ctx, "bump failed",
70+
slog.Error(err),
71+
slog.F("workspace_id", workspace.ID),
72+
)
73+
} else {
74+
log.Debug(
75+
ctx, "bumped deadline from activity",
76+
slog.F("workspace_id", workspace.ID),
77+
)
78+
}
79+
}

coderd/activitybump_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package coderd_test
2+
3+
import (
4+
"context"
5+
"testing"
6+
"time"
7+
8+
"github.com/stretchr/testify/require"
9+
10+
"cdr.dev/slog/sloggers/slogtest"
11+
12+
"github.com/coder/coder/coderd/coderdtest"
13+
"github.com/coder/coder/coderd/database"
14+
"github.com/coder/coder/codersdk"
15+
"github.com/coder/coder/testutil"
16+
)
17+
18+
func TestWorkspaceActivityBump(t *testing.T) {
19+
t.Parallel()
20+
21+
ctx := context.Background()
22+
23+
setupActivityTest := func(t *testing.T) (client *codersdk.Client, workspace codersdk.Workspace, assertBumped func(want bool)) {
24+
var ttlMillis int64 = 60 * 1000
25+
26+
client, _, workspace, _ = setupProxyTest(t, func(cwr *codersdk.CreateWorkspaceRequest) {
27+
cwr.TTLMillis = &ttlMillis
28+
})
29+
30+
// Sanity-check that deadline is near.
31+
workspace, err := client.Workspace(ctx, workspace.ID)
32+
require.NoError(t, err)
33+
require.WithinDuration(t,
34+
time.Now().Add(time.Duration(ttlMillis)*time.Millisecond),
35+
workspace.LatestBuild.Deadline.Time, testutil.WaitShort,
36+
)
37+
firstDeadline := workspace.LatestBuild.Deadline.Time
38+
39+
_ = coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
40+
41+
return client, workspace, func(want bool) {
42+
if !want {
43+
// It is difficult to test the absence of a call in a non-racey
44+
// way. In general, it is difficult for the API to generate
45+
// false positive activity since Agent networking event
46+
// is required. The Activity Bump behavior is also coupled with
47+
// Last Used, so it would be obvious to the user if we
48+
// are falsely recognizing activity.
49+
time.Sleep(testutil.IntervalMedium)
50+
workspace, err = client.Workspace(ctx, workspace.ID)
51+
require.NoError(t, err)
52+
require.Equal(t, workspace.LatestBuild.Deadline.Time, firstDeadline)
53+
return
54+
}
55+
56+
// The Deadline bump occurs asynchronously.
57+
require.Eventuallyf(t,
58+
func() bool {
59+
workspace, err = client.Workspace(ctx, workspace.ID)
60+
require.NoError(t, err)
61+
return workspace.LatestBuild.Deadline.Time != firstDeadline
62+
},
63+
testutil.WaitShort, testutil.IntervalFast,
64+
"deadline %v never updated", firstDeadline,
65+
)
66+
67+
require.WithinDuration(t, database.Now().Add(time.Hour), workspace.LatestBuild.Deadline.Time, time.Second)
68+
}
69+
}
70+
71+
t.Run("Dial", func(t *testing.T) {
72+
t.Parallel()
73+
74+
client, workspace, assertBumped := setupActivityTest(t)
75+
76+
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
77+
conn, err := client.DialWorkspaceAgentTailnet(ctx, slogtest.Make(t, nil), resources[0].Agents[0].ID)
78+
require.NoError(t, err)
79+
defer conn.Close()
80+
81+
sshConn, err := conn.SSHClient()
82+
require.NoError(t, err)
83+
_ = sshConn.Close()
84+
85+
assertBumped(true)
86+
})
87+
88+
t.Run("NoBump", func(t *testing.T) {
89+
t.Parallel()
90+
91+
client, workspace, assertBumped := setupActivityTest(t)
92+
93+
// Benign operations like retrieving resources must not
94+
// bump the deadline.
95+
_, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID)
96+
require.NoError(t, err)
97+
98+
assertBumped(false)
99+
})
100+
}

coderd/workspaceagents.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -616,6 +616,8 @@ func (api *API) workspaceAgentReportStats(rw http.ResponseWriter, r *http.Reques
616616
)
617617

618618
if updateDB {
619+
go activityBumpWorkspace(api.Logger.Named("activity_bump"), api.Database, workspace)
620+
619621
lastReport = rep
620622

621623
_, err = api.Database.InsertAgentStat(ctx, database.InsertAgentStatParams{

coderd/workspaceapps_test.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ const (
3636
// setupProxyTest creates a workspace with an agent and some apps. It returns a
3737
// codersdk client, the workspace, and the port number the test listener is
3838
// running on.
39-
func setupProxyTest(t *testing.T) (*codersdk.Client, uuid.UUID, codersdk.Workspace, uint16) {
39+
func setupProxyTest(t *testing.T, workspaceMutators ...func(*codersdk.CreateWorkspaceRequest)) (*codersdk.Client, uuid.UUID, codersdk.Workspace, uint16) {
4040
// #nosec
4141
ln, err := net.Listen("tcp", ":0")
4242
require.NoError(t, err)
@@ -58,7 +58,9 @@ func setupProxyTest(t *testing.T) (*codersdk.Client, uuid.UUID, codersdk.Workspa
5858
require.True(t, ok)
5959

6060
client := coderdtest.New(t, &coderdtest.Options{
61-
IncludeProvisionerDaemon: true,
61+
IncludeProvisionerDaemon: true,
62+
AgentStatsRefreshInterval: time.Millisecond * 100,
63+
MetricsCacheRefreshInterval: time.Millisecond * 100,
6264
})
6365
user := coderdtest.CreateFirstUser(t, client)
6466
authToken := uuid.NewString()
@@ -95,7 +97,7 @@ func setupProxyTest(t *testing.T) (*codersdk.Client, uuid.UUID, codersdk.Workspa
9597
})
9698
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
9799
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
98-
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
100+
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID, workspaceMutators...)
99101
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
100102

101103
agentClient := codersdk.New(client.URL)
@@ -104,6 +106,7 @@ func setupProxyTest(t *testing.T) (*codersdk.Client, uuid.UUID, codersdk.Workspa
104106
FetchMetadata: agentClient.WorkspaceAgentMetadata,
105107
CoordinatorDialer: agentClient.ListenWorkspaceAgentTailnet,
106108
Logger: slogtest.Make(t, nil).Named("agent"),
109+
StatsReporter: agentClient.AgentReportStats,
107110
})
108111
t.Cleanup(func() {
109112
_ = agentCloser.Close()

codersdk/workspaceagents.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -281,12 +281,10 @@ func (c *Client) DialWorkspaceAgentTailnet(ctx context.Context, logger slog.Logg
281281
CompressionMode: websocket.CompressionDisabled,
282282
})
283283
if errors.Is(err, context.Canceled) {
284-
_ = ws.Close(websocket.StatusAbnormalClosure, "")
285284
return
286285
}
287286
if err != nil {
288287
logger.Debug(ctx, "failed to dial", slog.Error(err))
289-
_ = ws.Close(websocket.StatusAbnormalClosure, "")
290288
continue
291289
}
292290
sendNode, errChan := tailnet.ServeCoordinator(websocket.NetConn(ctx, ws, websocket.MessageBinary), func(node []*tailnet.Node) error {

site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ export const Language = {
5555
timezoneLabel: "Timezone",
5656
ttlLabel: "Time until shutdown (hours)",
5757
ttlCausesShutdownHelperText: "Your workspace will shut down",
58-
ttlCausesShutdownAfterStart: "after its next start",
58+
ttlCausesShutdownAfterStart:
59+
"after its next start. We delay shutdown by an hour whenever we detect activity",
5960
ttlCausesNoShutdownHelperText: "Your workspace will not automatically shut down.",
6061
formTitle: "Workspace schedule",
6162
startSection: "Start",

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy