Skip to content

Commit 51a093d

Browse files
authored
Merge pull request #827 from chaitin/fix/vm-idle-clickhouse-guard
修复虚拟机空闲回收误判
2 parents bb21766 + a008732 commit 51a093d

7 files changed

Lines changed: 433 additions & 1 deletion

File tree

backend/biz/host/repo/host.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"github.com/chaitin/MonkeyCode/backend/db/model"
2222
"github.com/chaitin/MonkeyCode/backend/db/predicate"
2323
"github.com/chaitin/MonkeyCode/backend/db/projecttask"
24+
"github.com/chaitin/MonkeyCode/backend/db/task"
2425
"github.com/chaitin/MonkeyCode/backend/db/taskvirtualmachine"
2526
"github.com/chaitin/MonkeyCode/backend/db/teamgroup"
2627
"github.com/chaitin/MonkeyCode/backend/db/user"
@@ -610,6 +611,7 @@ func (h *HostRepo) CompleteCreateVirtualMachine(ctx context.Context, id string,
610611
func (h *HostRepo) DeleteVirtualMachine(ctx context.Context, uid uuid.UUID, hostID, id string, fn func(*db.VirtualMachine) error) error {
611612
return entx.WithTx2(ctx, h.db, func(tx *db.Tx) error {
612613
vm, err := tx.VirtualMachine.Query().
614+
WithTasks().
613615
Where(virtualmachine.ID(id)).
614616
Where(virtualmachine.UserID(uid)).
615617
First(ctx)
@@ -627,6 +629,27 @@ func (h *HostRepo) DeleteVirtualMachine(ctx context.Context, uid uuid.UUID, host
627629
return err
628630
}
629631

632+
taskIDs := make([]uuid.UUID, 0, len(vm.Edges.Tasks))
633+
for _, tk := range vm.Edges.Tasks {
634+
if tk == nil {
635+
continue
636+
}
637+
taskIDs = append(taskIDs, tk.ID)
638+
}
639+
if len(taskIDs) > 0 {
640+
_, err := tx.Task.Update().
641+
Where(
642+
task.IDIn(taskIDs...),
643+
task.StatusNotIn(consts.TaskStatusFinished, consts.TaskStatusError),
644+
).
645+
SetStatus(consts.TaskStatusFinished).
646+
SetCompletedAt(time.Now()).
647+
Save(ctx)
648+
if err != nil {
649+
return err
650+
}
651+
}
652+
630653
_, err = tx.VirtualMachine.Delete().Where(virtualmachine.ID(id)).Exec(ctx)
631654
if err != nil {
632655
return err

backend/biz/host/usecase/host.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -533,7 +533,7 @@ func (h *HostUsecase) CreateVM(ctx context.Context, user *domain.User, req *doma
533533

534534
// DeleteVM 删除虚拟机
535535
func (h *HostUsecase) DeleteVM(ctx context.Context, uid uuid.UUID, hostID, vmID string) error {
536-
h.logger.InfoContext(ctx, "delete vm", "vmID", vmID)
536+
h.logger.InfoContext(ctx, "delete vm", "vmID", vmID, "user_id", uid, "host_id", hostID)
537537
return h.repo.DeleteVirtualMachine(ctx, uid, hostID, vmID, func(vm *db.VirtualMachine) error {
538538
if err := h.taskflow.VirtualMachiner().Delete(ctx, &taskflow.DeleteVirtualMachineReq{
539539
UserID: uid.String(),

backend/biz/host/usecase/host_test.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"github.com/chaitin/MonkeyCode/backend/db"
2424
"github.com/chaitin/MonkeyCode/backend/db/enttest"
2525
"github.com/chaitin/MonkeyCode/backend/domain"
26+
"github.com/chaitin/MonkeyCode/backend/pkg/delayqueue"
2627
"github.com/chaitin/MonkeyCode/backend/pkg/taskflow"
2728
)
2829

@@ -337,6 +338,97 @@ func TestHostUsecase_markRecycledTasksFinished(t *testing.T) {
337338
}
338339
}
339340

341+
func TestHostUsecase_DeleteVMFinishesBoundTasks(t *testing.T) {
342+
t.Parallel()
343+
344+
ctx := context.Background()
345+
client := enttest.Open(t, "sqlite3", "file:host-usecase-delete-vm-finish-task-test?mode=memory&cache=shared&_fk=1")
346+
defer client.Close()
347+
348+
userID := uuid.New()
349+
if _, err := client.User.Create().
350+
SetID(userID).
351+
SetName("tester").
352+
SetRole(consts.UserRoleIndividual).
353+
SetStatus(consts.UserStatusActive).
354+
Save(ctx); err != nil {
355+
t.Fatalf("create user: %v", err)
356+
}
357+
358+
hostID := "host-1"
359+
if _, err := client.Host.Create().
360+
SetID(hostID).
361+
SetUserID(userID).
362+
SetHostname("host").
363+
Save(ctx); err != nil {
364+
t.Fatalf("create host: %v", err)
365+
}
366+
367+
vmID := "vm-1"
368+
if _, err := client.VirtualMachine.Create().
369+
SetID(vmID).
370+
SetHostID(hostID).
371+
SetUserID(userID).
372+
SetName("vm").
373+
SetEnvironmentID("env-1").
374+
Save(ctx); err != nil {
375+
t.Fatalf("create vm: %v", err)
376+
}
377+
378+
taskID := uuid.New()
379+
if _, err := client.Task.Create().
380+
SetID(taskID).
381+
SetUserID(userID).
382+
SetKind(consts.TaskTypeDevelop).
383+
SetContent("content").
384+
SetStatus(consts.TaskStatusProcessing).
385+
Save(ctx); err != nil {
386+
t.Fatalf("create task: %v", err)
387+
}
388+
if _, err := client.TaskVirtualMachine.Create().
389+
SetID(uuid.New()).
390+
SetTaskID(taskID).
391+
SetVirtualmachineID(vmID).
392+
Save(ctx); err != nil {
393+
t.Fatalf("bind task vm: %v", err)
394+
}
395+
396+
i := do.New()
397+
do.ProvideValue(i, client)
398+
redisServer := miniredis.RunT(t)
399+
redisClient := redis.NewClient(&redis.Options{Addr: redisServer.Addr()})
400+
t.Cleanup(func() { _ = redisClient.Close() })
401+
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
402+
do.ProvideValue(i, &config.Config{})
403+
do.ProvideValue(i, logger)
404+
do.ProvideValue(i, redisClient)
405+
hostRepo, err := repo.NewHostRepo(i)
406+
if err != nil {
407+
t.Fatalf("new host repo: %v", err)
408+
}
409+
u := &HostUsecase{
410+
repo: hostRepo,
411+
taskflow: &preinsertTaskflowStub{vm: &preinsertVMCreateStub{db: client}},
412+
logger: logger,
413+
vmexpireQueue: delayqueue.NewVMExpireQueue(redisClient, logger),
414+
}
415+
416+
if err := u.DeleteVM(ctx, userID, hostID, vmID); err != nil {
417+
t.Fatalf("DeleteVM() error = %v", err)
418+
}
419+
420+
gotTask, err := client.Task.Get(ctx, taskID)
421+
if err != nil {
422+
t.Fatalf("query task: %v", err)
423+
}
424+
if gotTask.Status != consts.TaskStatusFinished {
425+
t.Fatalf("task status = %s, want %s", gotTask.Status, consts.TaskStatusFinished)
426+
}
427+
if gotTask.CompletedAt.IsZero() {
428+
t.Fatal("expected task completed_at to be set")
429+
}
430+
}
431+
340432
func TestHostUsecase_CreateVMPreinsertsVirtualMachineBeforeTaskflowCreate(t *testing.T) {
341433
t.Parallel()
342434

backend/biz/vmidle/usecase/policy_test.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@ import (
1414
"github.com/redis/go-redis/v9"
1515

1616
"github.com/chaitin/MonkeyCode/backend/config"
17+
"github.com/chaitin/MonkeyCode/backend/consts"
1718
"github.com/chaitin/MonkeyCode/backend/db"
1819
"github.com/chaitin/MonkeyCode/backend/db/enttest"
1920
"github.com/chaitin/MonkeyCode/backend/db/virtualmachine"
2021
"github.com/chaitin/MonkeyCode/backend/domain"
22+
"github.com/chaitin/MonkeyCode/backend/pkg/delayqueue"
2123
"github.com/chaitin/MonkeyCode/backend/pkg/taskflow"
2224
)
2325

@@ -107,6 +109,104 @@ func TestRefreshCachesNotFoundVM(t *testing.T) {
107109
}
108110
}
109111

112+
func TestShouldSkipRecycleForRecentTaskLastActiveAtSkipsClickHouse(t *testing.T) {
113+
ctx := context.Background()
114+
now := time.Date(2026, 7, 7, 10, 34, 38, 0, time.UTC)
115+
taskID := uuid.New()
116+
vm := &db.VirtualMachine{ID: "vm-recent-pg-activity", UserID: uuid.New()}
117+
vm.Edges.Tasks = []*db.Task{{
118+
ID: taskID,
119+
Status: consts.TaskStatusProcessing,
120+
LastActiveAt: now.Add(-time.Minute),
121+
}}
122+
redisClient := newTestRedis(t)
123+
logger := slog.Default()
124+
repo := &refreshHostRepoStub{vm: vm}
125+
activity := &taskLogActivityStub{err: errors.New("unexpected clickhouse query")}
126+
r := &vmIdleRefresher{
127+
cfg: &config.Config{VMIdle: config.VMIdle{SleepSeconds: 600, RecycleSeconds: 3600}},
128+
redis: redisClient,
129+
logger: logger,
130+
hostRepo: repo,
131+
taskLogActivity: activity,
132+
sleepQueue: delayqueue.NewVMSleepQueue(redisClient, logger),
133+
notifyQueue: delayqueue.NewVMNotifyQueue(redisClient, logger),
134+
recycleQueue: delayqueue.NewVMRecycleQueue(redisClient, logger),
135+
}
136+
137+
skip, err := r.shouldSkipRecycleForRecentActivity(ctx, vm, now)
138+
if err != nil {
139+
t.Fatal(err)
140+
}
141+
if !skip {
142+
t.Fatal("expected recent task last_active_at to skip recycle")
143+
}
144+
if activity.calls != 0 {
145+
t.Fatalf("clickhouse calls = %d, want 0", activity.calls)
146+
}
147+
if repo.getVirtualMachineCalls != 1 {
148+
t.Fatalf("GetVirtualMachine calls = %d, want 1", repo.getVirtualMachineCalls)
149+
}
150+
}
151+
152+
func TestShouldSkipRecycleForRecentTaskLogRefreshesVM(t *testing.T) {
153+
ctx := context.Background()
154+
now := time.Date(2026, 7, 7, 10, 34, 38, 0, time.UTC)
155+
taskID := uuid.New()
156+
vm := &db.VirtualMachine{ID: "vm-recent-log", UserID: uuid.New()}
157+
vm.Edges.Tasks = []*db.Task{{ID: taskID, Status: consts.TaskStatusProcessing}}
158+
redisClient := newTestRedis(t)
159+
logger := slog.Default()
160+
repo := &refreshHostRepoStub{vm: vm}
161+
r := &vmIdleRefresher{
162+
cfg: &config.Config{VMIdle: config.VMIdle{SleepSeconds: 600, RecycleSeconds: 3600}},
163+
redis: redisClient,
164+
logger: logger,
165+
hostRepo: repo,
166+
taskLogActivity: &taskLogActivityStub{latest: map[uuid.UUID]time.Time{taskID: now.Add(-time.Minute)}},
167+
sleepQueue: delayqueue.NewVMSleepQueue(redisClient, logger),
168+
notifyQueue: delayqueue.NewVMNotifyQueue(redisClient, logger),
169+
recycleQueue: delayqueue.NewVMRecycleQueue(redisClient, logger),
170+
}
171+
172+
skip, err := r.shouldSkipRecycleForRecentActivity(ctx, vm, now)
173+
if err != nil {
174+
t.Fatal(err)
175+
}
176+
if !skip {
177+
t.Fatal("expected recent task log to skip recycle")
178+
}
179+
if repo.getVirtualMachineCalls != 1 {
180+
t.Fatalf("GetVirtualMachine calls = %d, want 1", repo.getVirtualMachineCalls)
181+
}
182+
}
183+
184+
func TestShouldSkipRecycleForRecentTaskLogAllowsStaleVM(t *testing.T) {
185+
ctx := context.Background()
186+
now := time.Date(2026, 7, 7, 10, 34, 38, 0, time.UTC)
187+
taskID := uuid.New()
188+
vm := &db.VirtualMachine{ID: "vm-stale-log", UserID: uuid.New()}
189+
vm.Edges.Tasks = []*db.Task{{ID: taskID, Status: consts.TaskStatusProcessing}}
190+
repo := &refreshHostRepoStub{vm: vm}
191+
r := &vmIdleRefresher{
192+
cfg: &config.Config{VMIdle: config.VMIdle{SleepSeconds: 600, RecycleSeconds: 3600}},
193+
logger: slog.Default(),
194+
hostRepo: repo,
195+
taskLogActivity: &taskLogActivityStub{latest: map[uuid.UUID]time.Time{taskID: now.Add(-2 * time.Hour)}},
196+
}
197+
198+
skip, err := r.shouldSkipRecycleForRecentActivity(ctx, vm, now)
199+
if err != nil {
200+
t.Fatal(err)
201+
}
202+
if skip {
203+
t.Fatal("expected stale task log to allow recycle")
204+
}
205+
if repo.getVirtualMachineCalls != 0 {
206+
t.Fatalf("GetVirtualMachine calls = %d, want 0", repo.getVirtualMachineCalls)
207+
}
208+
}
209+
110210
func newTestRedis(t *testing.T) *redis.Client {
111211
t.Helper()
112212
srv := miniredis.RunT(t)
@@ -139,6 +239,21 @@ type refreshHostRepoStub struct {
139239
getVirtualMachineCalls int
140240
}
141241

242+
type taskLogActivityStub struct {
243+
latest map[uuid.UUID]time.Time
244+
err error
245+
calls int
246+
}
247+
248+
func (s *taskLogActivityStub) LatestTaskLogTime(_ context.Context, taskID uuid.UUID) (time.Time, bool, error) {
249+
s.calls++
250+
if s.err != nil {
251+
return time.Time{}, false, s.err
252+
}
253+
latest, ok := s.latest[taskID]
254+
return latest, ok, nil
255+
}
256+
142257
func (s *refreshHostRepoStub) List(context.Context, uuid.UUID) ([]*db.Host, error) {
143258
return nil, errors.New("not implemented")
144259
}

0 commit comments

Comments
 (0)