package scheduler import ( "context" "sync" "testing" "testing/synctest" "time" "hyapp/services/cron-service/internal/config" mysqlstorage "hyapp/services/cron-service/internal/storage/mysql" ) type synctestStore struct { mu sync.Mutex leases int started []mysqlstorage.TaskRun finished []mysqlstorage.TaskRun } func (s *synctestStore) TryAcquireTaskLease(_ context.Context, _ string, _ string, _ string, _ int64, _ time.Duration) (bool, error) { s.mu.Lock() defer s.mu.Unlock() s.leases++ return true, nil } func (s *synctestStore) StartTaskRun(_ context.Context, run mysqlstorage.TaskRun) error { s.mu.Lock() defer s.mu.Unlock() s.started = append(s.started, run) return nil } func (s *synctestStore) FinishTaskRun(_ context.Context, run mysqlstorage.TaskRun) error { s.mu.Lock() defer s.mu.Unlock() s.finished = append(s.finished, run) return nil } // TestSchedulerRunUsesSynctestClock 锁定 cron loop 的首轮立即执行和 interval 退避。 // synctest 的虚拟时钟让 worker/timer 测试不依赖真实 sleep,避免调度抖动污染 CI。 func TestSchedulerRunUsesSynctestClock(t *testing.T) { synctest.Test(t, func(t *testing.T) { store := &synctestStore{} ctx, cancel := context.WithCancel(t.Context()) defer cancel() var mu sync.Mutex runTimes := make([]time.Time, 0, 2) s := New("node-a", map[string]config.TaskConfig{ "room_outbox": { Enabled: true, AppCodes: []string{"lalu"}, Interval: "1s", Timeout: "100ms", LockTTL: "5s", BatchSize: 8, }, }, store, map[string]Handler{ "room_outbox": func(_ context.Context, request BatchRequest) (BatchResult, error) { mu.Lock() defer mu.Unlock() if request.BatchSize != 8 || request.AppCode != "lalu" || request.WorkerID != "node-a" { t.Fatalf("unexpected request: %+v", request) } runTimes = append(runTimes, time.Now()) return BatchResult{ProcessedCount: 1}, nil }, }) go s.Run(ctx) synctest.Wait() mu.Lock() if len(runTimes) != 1 { t.Fatalf("first run count = %d, want 1", len(runTimes)) } firstRunAt := runTimes[0] mu.Unlock() time.Sleep(time.Second) synctest.Wait() mu.Lock() if len(runTimes) != 2 { t.Fatalf("second run count = %d, want 2", len(runTimes)) } if got := runTimes[1].Sub(firstRunAt); got != time.Second { t.Fatalf("interval = %s, want 1s", got) } mu.Unlock() cancel() synctest.Wait() store.mu.Lock() defer store.mu.Unlock() if store.leases != 2 || len(store.started) != 2 || len(store.finished) != 2 { t.Fatalf("store calls: leases=%d started=%d finished=%d", store.leases, len(store.started), len(store.finished)) } }) }