Thanks to visit codestin.com
Credit goes to github.com

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)