Skip to content

Commit 8902c11

Browse files
authored
Merge pull request #868 from chaitin/feat/vm-recycle-cli-support
修复 VM 空闲回收活动语义并统一回收流程
2 parents 5355cc4 + af969a3 commit 8902c11

27 files changed

Lines changed: 2618 additions & 500 deletions

backend/biz/host/handler/v1/internal.go

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ type InternalHostHandler struct {
3434
logger *slog.Logger
3535
repo domain.HostRepo
3636
taskRepo taskLogStoreRepo
37+
taskActivity vmActivityTaskRepo
3738
teamRepo domain.TeamHostRepo
3839
redis *redis.Client
3940
getAgentToken agentTokenGetter
@@ -53,15 +54,27 @@ type taskLogStoreRepo interface {
5354
GetLogStore(ctx context.Context, id uuid.UUID) (consts.LogStore, error)
5455
}
5556

57+
type vmActivityTaskRepo interface {
58+
RefreshLastActiveAtByVMID(ctx context.Context, vmID string, at time.Time, minInterval time.Duration) error
59+
}
60+
61+
const vmActivityTaskRefreshInterval = 5 * time.Minute
62+
5663
func NewInternalHostHandler(i *do.Injector) (*InternalHostHandler, error) {
5764
w := do.MustInvoke[*web.Web](i)
5865
tf := do.MustInvoke[taskflow.Clienter](i)
5966
rdb := do.MustInvoke[*redis.Client](i)
67+
taskRepo := do.MustInvoke[domain.TaskRepo](i)
68+
taskActivity, ok := taskRepo.(vmActivityTaskRepo)
69+
if !ok {
70+
return nil, errors.New("task repo does not support VM activity")
71+
}
6072

6173
h := &InternalHostHandler{
6274
logger: do.MustInvoke[*slog.Logger](i).With("module", "InternalHostHandler"),
6375
repo: do.MustInvoke[domain.HostRepo](i),
64-
taskRepo: do.MustInvoke[domain.TaskRepo](i),
76+
taskRepo: taskRepo,
77+
taskActivity: taskActivity,
6578
teamRepo: do.MustInvoke[domain.TeamHostRepo](i),
6679
redis: rdb,
6780
getAgentToken: defaultAgentTokenGetter(rdb),
@@ -122,8 +135,26 @@ func (h *InternalHostHandler) VMActivity(c *web.Context, req VMActivityReq) erro
122135
if strings.TrimSpace(req.VMID) == "" {
123136
return errors.New("vm_id is required")
124137
}
125-
if err := h.idleRefresher.Refresh(c.Request().Context(), req.VMID); err != nil {
126-
h.logger.WarnContext(c.Request().Context(), "failed to refresh vm idle timers on activity", "vm_id", req.VMID, "error", err)
138+
if req.LastActiveAt <= 0 {
139+
return errors.New("last_active_at is required")
140+
}
141+
142+
ctx := c.Request().Context()
143+
activeAt := time.Unix(req.LastActiveAt, 0)
144+
if now := time.Now(); activeAt.After(now) {
145+
h.logger.WarnContext(ctx, "vm activity timestamp is in the future", "vm_id", req.VMID, "last_active_at", req.LastActiveAt)
146+
activeAt = now
147+
}
148+
var errs []error
149+
if err := h.taskActivity.RefreshLastActiveAtByVMID(ctx, req.VMID, activeAt, vmActivityTaskRefreshInterval); err != nil {
150+
h.logger.WarnContext(ctx, "failed to persist vm activity", "vm_id", req.VMID, "error", err)
151+
errs = append(errs, err)
152+
}
153+
if err := h.idleRefresher.RecordActivity(ctx, req.VMID); err != nil {
154+
h.logger.WarnContext(ctx, "failed to refresh vm idle timers on activity", "vm_id", req.VMID, "error", err)
155+
errs = append(errs, err)
156+
}
157+
if err := errors.Join(errs...); err != nil {
127158
return err
128159
}
129160
return c.Success(nil)

backend/biz/host/handler/v1/internal_vm_activity_test.go

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,25 @@ package v1
33
import (
44
"context"
55
"encoding/json"
6+
"errors"
67
"io"
78
"log/slog"
89
"net/http"
910
"net/http/httptest"
1011
"strings"
1112
"testing"
13+
"time"
1214

1315
"github.com/GoYoko/web"
1416
)
1517

16-
func TestInternalHostHandler_VMActivityRefreshesIdleTimer(t *testing.T) {
18+
func TestInternalHostHandler_VMActivityPersistsAndRefreshesIdleTimer(t *testing.T) {
1719
refresher := &internalVMIdleRefresherStub{ch: make(chan string, 1)}
20+
activity := &internalVMActivityTaskRepoStub{ch: make(chan internalVMActivityCall, 1)}
1821
h := &InternalHostHandler{
1922
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
2023
idleRefresher: refresher,
24+
taskActivity: activity,
2125
}
2226

2327
body := `{"vm_id":"vm-activity-1","last_active_at":1710000000}`
@@ -40,12 +44,57 @@ func TestInternalHostHandler_VMActivityRefreshesIdleTimer(t *testing.T) {
4044
default:
4145
t.Fatal("expected idle refresher to be called")
4246
}
47+
select {
48+
case got := <-activity.ch:
49+
if got.vmID != "vm-activity-1" || !got.at.Equal(time.Unix(1710000000, 0)) || got.minInterval != vmActivityTaskRefreshInterval {
50+
t.Fatalf("activity call = %+v", got)
51+
}
52+
default:
53+
t.Fatal("expected task activity to be persisted")
54+
}
55+
}
56+
57+
func TestInternalHostHandler_VMActivityRefreshesIdleTimerWhenPersistFails(t *testing.T) {
58+
refresher := &internalVMIdleRefresherStub{ch: make(chan string, 1)}
59+
h := &InternalHostHandler{
60+
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
61+
idleRefresher: refresher,
62+
taskActivity: &internalVMActivityTaskRepoStub{
63+
ch: make(chan internalVMActivityCall, 1),
64+
err: errors.New("persist failed"),
65+
},
66+
}
67+
68+
w := web.New()
69+
w.POST("/internal/vm/activity", web.BindHandler(h.VMActivity))
70+
rec := httptest.NewRecorder()
71+
req := httptest.NewRequest(http.MethodPost, "/internal/vm/activity", strings.NewReader(`{"vm_id":"vm-activity-1","last_active_at":1710000000}`))
72+
req.Header.Set("Content-Type", "application/json")
73+
w.Echo().ServeHTTP(rec, req)
74+
75+
select {
76+
case got := <-refresher.ch:
77+
if got != "vm-activity-1" {
78+
t.Fatalf("refreshed vm id = %q", got)
79+
}
80+
default:
81+
t.Fatal("expected idle refresher to run after persist failure")
82+
}
83+
84+
var resp web.Resp
85+
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
86+
t.Fatalf("unmarshal resp: %v, body = %s", err, rec.Body.String())
87+
}
88+
if resp.Code == 0 {
89+
t.Fatalf("response = %+v, want error", resp)
90+
}
4391
}
4492

4593
func TestInternalHostHandler_VMActivityRejectsEmptyVMID(t *testing.T) {
4694
h := &InternalHostHandler{
4795
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
4896
idleRefresher: &internalVMIdleRefresherStub{ch: make(chan string, 1)},
97+
taskActivity: &internalVMActivityTaskRepoStub{ch: make(chan internalVMActivityCall, 1)},
4998
}
5099

51100
w := web.New()
@@ -65,14 +114,59 @@ func TestInternalHostHandler_VMActivityRejectsEmptyVMID(t *testing.T) {
65114
}
66115
}
67116

117+
func TestInternalHostHandler_VMActivityRejectsMissingTimestamp(t *testing.T) {
118+
h := &InternalHostHandler{
119+
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
120+
idleRefresher: &internalVMIdleRefresherStub{ch: make(chan string, 1)},
121+
taskActivity: &internalVMActivityTaskRepoStub{ch: make(chan internalVMActivityCall, 1)},
122+
}
123+
124+
w := web.New()
125+
w.POST("/internal/vm/activity", web.BindHandler(h.VMActivity))
126+
127+
rec := httptest.NewRecorder()
128+
req := httptest.NewRequest(http.MethodPost, "/internal/vm/activity", strings.NewReader(`{"vm_id":"vm-activity-1"}`))
129+
req.Header.Set("Content-Type", "application/json")
130+
w.Echo().ServeHTTP(rec, req)
131+
132+
var resp web.Resp
133+
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
134+
t.Fatalf("unmarshal resp: %v, body = %s", err, rec.Body.String())
135+
}
136+
if resp.Code == 0 {
137+
t.Fatalf("response = %+v, want error", resp)
138+
}
139+
}
140+
68141
type internalVMIdleRefresherStub struct {
69142
ch chan string
70143
}
71144

72-
func (s *internalVMIdleRefresherStub) Refresh(_ context.Context, vmID string) error {
145+
func (s *internalVMIdleRefresherStub) KeepAwake(context.Context, string) error { return nil }
146+
147+
func (s *internalVMIdleRefresherStub) RecordActivity(_ context.Context, vmID string) error {
73148
select {
74149
case s.ch <- vmID:
75150
default:
76151
}
77152
return nil
78153
}
154+
155+
type internalVMActivityCall struct {
156+
vmID string
157+
at time.Time
158+
minInterval time.Duration
159+
}
160+
161+
type internalVMActivityTaskRepoStub struct {
162+
ch chan internalVMActivityCall
163+
err error
164+
}
165+
166+
func (s *internalVMActivityTaskRepoStub) RefreshLastActiveAtByVMID(_ context.Context, vmID string, at time.Time, minInterval time.Duration) error {
167+
select {
168+
case s.ch <- internalVMActivityCall{vmID: vmID, at: at, minInterval: minInterval}:
169+
default:
170+
}
171+
return s.err
172+
}

backend/biz/task/handler/v1/task_control.go

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,7 @@ import (
1010
"golang.org/x/sync/errgroup"
1111

1212
"github.com/GoYoko/web"
13-
"github.com/google/uuid"
1413

15-
"github.com/chaitin/MonkeyCode/backend/biz/task/service"
1614
"github.com/chaitin/MonkeyCode/backend/consts"
1715
"github.com/chaitin/MonkeyCode/backend/domain"
1816
"github.com/chaitin/MonkeyCode/backend/middleware"
@@ -142,13 +140,9 @@ func (h *TaskHandler) Control(c *web.Context, req domain.TaskControlReq) error {
142140

143141
logger := h.logger.With("task_id", task.ID, "fn", "task.control")
144142
taskID := task.ID.String()
145-
if err := h.taskActivity.Refresh(c.Request().Context(), task.ID); err != nil {
146-
logger.WarnContext(c.Request().Context(), "failed to refresh task last active on control connect", "error", err)
147-
}
148-
149143
// 连接建立:刷新空闲计时器
150144
if vm := task.VirtualMachine; vm != nil {
151-
if err := h.idleRefresher.Refresh(c.Request().Context(), vm.ID); err != nil {
145+
if err := h.idleRefresher.KeepAwake(c.Request().Context(), vm.ID); err != nil {
152146
logger.WarnContext(c.Request().Context(), "failed to refresh idle timers on connect", "error", err)
153147
}
154148

@@ -174,7 +168,7 @@ func (h *TaskHandler) Control(c *web.Context, req domain.TaskControlReq) error {
174168
if vm := task.VirtualMachine; vm != nil && !h.controlConns.Has(taskID) {
175169
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
176170
defer cancel()
177-
if err := h.idleRefresher.Refresh(ctx, vm.ID); err != nil {
171+
if err := h.idleRefresher.KeepAwake(ctx, vm.ID); err != nil {
178172
logger.WarnContext(ctx, "failed to refresh idle timers on disconnect", "error", err)
179173
}
180174
}
@@ -197,7 +191,7 @@ func (h *TaskHandler) Control(c *web.Context, req domain.TaskControlReq) error {
197191
// 定期刷新空闲计时器,保持 VM 活跃
198192
if vm := task.VirtualMachine; vm != nil {
199193
g.Go(func() error {
200-
return h.controlKeepAlive(ctx, task.ID, vm.ID)
194+
return h.controlKeepAlive(ctx, vm.ID)
201195
})
202196
}
203197

@@ -226,30 +220,21 @@ func (h *TaskHandler) controlPing(ctx context.Context, wsConn *ws.WebsocketManag
226220
}
227221

228222
// controlKeepAlive 定期刷新空闲计时器,防止 VM 被误判空闲
229-
func (h *TaskHandler) controlKeepAlive(ctx context.Context, taskID uuid.UUID, vmID string) error {
230-
if err := h.idleRefresher.Refresh(ctx, vmID); err != nil {
223+
func (h *TaskHandler) controlKeepAlive(ctx context.Context, vmID string) error {
224+
if err := h.idleRefresher.KeepAwake(ctx, vmID); err != nil {
231225
h.logger.WarnContext(ctx, "keepalive refresh failed", "vmID", vmID, "error", err)
232226
}
233-
if err := h.taskActivity.Refresh(ctx, taskID); err != nil {
234-
h.logger.WarnContext(ctx, "task activity refresh failed", "taskID", taskID, "error", err)
235-
}
236227

237228
idleTicker := time.NewTicker(1 * time.Minute)
238-
activityTicker := time.NewTicker(service.TaskActivityRefreshInterval)
239229
defer idleTicker.Stop()
240-
defer activityTicker.Stop()
241230
for {
242231
select {
243232
case <-ctx.Done():
244233
return ctx.Err()
245234
case <-idleTicker.C:
246-
if err := h.idleRefresher.Refresh(ctx, vmID); err != nil {
235+
if err := h.idleRefresher.KeepAwake(ctx, vmID); err != nil {
247236
h.logger.WarnContext(ctx, "keepalive refresh failed", "vmID", vmID, "error", err)
248237
}
249-
case <-activityTicker.C:
250-
if err := h.taskActivity.Refresh(ctx, taskID); err != nil {
251-
h.logger.WarnContext(ctx, "task activity refresh failed", "taskID", taskID, "error", err)
252-
}
253238
}
254239
}
255240
}

backend/biz/task/handler/v1/task_control_test.go

Lines changed: 3 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -6,30 +6,23 @@ import (
66
"log/slog"
77
"testing"
88
"time"
9-
10-
"github.com/google/uuid"
11-
12-
"github.com/chaitin/MonkeyCode/backend/biz/task/service"
139
)
1410

1511
func TestControlKeepAliveRefreshesImmediately(t *testing.T) {
16-
taskID := uuid.MustParse("11111111-1111-1111-1111-111111111111")
1712
vmID := "vm-1"
1813

1914
idleRefresher := &testVMIdleRefresher{ch: make(chan string, 1)}
20-
taskActivity := &testTaskActivityRefresher{ch: make(chan uuid.UUID, 1)}
2115
handler := &TaskHandler{
2216
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
2317
idleRefresher: idleRefresher,
24-
taskActivity: taskActivity,
2518
}
2619

2720
ctx, cancel := context.WithCancel(context.Background())
2821
defer cancel()
2922

3023
done := make(chan error, 1)
3124
go func() {
32-
done <- handler.controlKeepAlive(ctx, taskID, vmID)
25+
done <- handler.controlKeepAlive(ctx, vmID)
3326
}()
3427

3528
select {
@@ -41,15 +34,6 @@ func TestControlKeepAliveRefreshesImmediately(t *testing.T) {
4134
t.Fatal("expected idle refresher to run immediately")
4235
}
4336

44-
select {
45-
case got := <-taskActivity.ch:
46-
if got != taskID {
47-
t.Fatalf("task activity task id = %s, want %s", got, taskID)
48-
}
49-
case <-time.After(100 * time.Millisecond):
50-
t.Fatal("expected task activity refresher to run immediately")
51-
}
52-
5337
cancel()
5438

5539
select {
@@ -66,28 +50,12 @@ type testVMIdleRefresher struct {
6650
ch chan string
6751
}
6852

69-
func (r *testVMIdleRefresher) Refresh(_ context.Context, vmID string) error {
53+
func (r *testVMIdleRefresher) KeepAwake(_ context.Context, vmID string) error {
7054
select {
7155
case r.ch <- vmID:
7256
default:
7357
}
7458
return nil
7559
}
7660

77-
type testTaskActivityRefresher struct {
78-
ch chan uuid.UUID
79-
}
80-
81-
func (r *testTaskActivityRefresher) Refresh(_ context.Context, taskID uuid.UUID) error {
82-
select {
83-
case r.ch <- taskID:
84-
default:
85-
}
86-
return nil
87-
}
88-
89-
func (r *testTaskActivityRefresher) ForceRefresh(context.Context, uuid.UUID) error {
90-
return nil
91-
}
92-
93-
var _ service.TaskActivityRefresher = (*testTaskActivityRefresher)(nil)
61+
func (r *testVMIdleRefresher) RecordActivity(context.Context, string) error { return nil }

backend/biz/task/repo/task.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,19 @@ func (t *TaskRepo) RefreshLastActiveAt(ctx context.Context, id uuid.UUID, at tim
286286
return err
287287
}
288288

289+
func (t *TaskRepo) RefreshLastActiveAtByVMID(ctx context.Context, vmID string, at time.Time, minInterval time.Duration) error {
290+
up := t.db.Task.Update().Where(
291+
task.HasVmsWith(virtualmachine.ID(vmID)),
292+
task.StatusNotIn(consts.TaskStatusFinished, consts.TaskStatusError),
293+
task.LastActiveAtLT(at),
294+
)
295+
if minInterval > 0 {
296+
up = up.Where(task.LastActiveAtLT(at.Add(-minInterval)))
297+
}
298+
_, err := up.SetLastActiveAt(at).Save(ctx)
299+
return err
300+
}
301+
289302
// Delete implements domain.TaskRepo.
290303
func (t *TaskRepo) Delete(ctx context.Context, user *domain.User, id uuid.UUID) error {
291304
_, err := t.db.Task.Delete().

0 commit comments

Comments
 (0)