| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170 |
- package service
- import (
- "context"
- "sync"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
- "github.com/robfig/cron/v3"
- )
- const scheduledTestDefaultMaxWorkers = 10
- // ScheduledTestRunnerService periodically scans due test plans and executes them.
- type ScheduledTestRunnerService struct {
- planRepo ScheduledTestPlanRepository
- scheduledSvc *ScheduledTestService
- accountTestSvc *AccountTestService
- rateLimitSvc *RateLimitService
- cfg *config.Config
- cron *cron.Cron
- startOnce sync.Once
- stopOnce sync.Once
- }
- // NewScheduledTestRunnerService creates a new runner.
- func NewScheduledTestRunnerService(
- planRepo ScheduledTestPlanRepository,
- scheduledSvc *ScheduledTestService,
- accountTestSvc *AccountTestService,
- rateLimitSvc *RateLimitService,
- cfg *config.Config,
- ) *ScheduledTestRunnerService {
- return &ScheduledTestRunnerService{
- planRepo: planRepo,
- scheduledSvc: scheduledSvc,
- accountTestSvc: accountTestSvc,
- rateLimitSvc: rateLimitSvc,
- cfg: cfg,
- }
- }
- // Start begins the cron ticker (every minute).
- func (s *ScheduledTestRunnerService) Start() {
- if s == nil {
- return
- }
- s.startOnce.Do(func() {
- loc := time.Local
- if s.cfg != nil {
- if parsed, err := time.LoadLocation(s.cfg.Timezone); err == nil && parsed != nil {
- loc = parsed
- }
- }
- c := cron.New(cron.WithParser(scheduledTestCronParser), cron.WithLocation(loc))
- _, err := c.AddFunc("* * * * *", func() { s.runScheduled() })
- if err != nil {
- logger.LegacyPrintf("service.scheduled_test_runner", "[ScheduledTestRunner] not started (invalid schedule): %v", err)
- return
- }
- s.cron = c
- s.cron.Start()
- logger.LegacyPrintf("service.scheduled_test_runner", "[ScheduledTestRunner] started (tick=every minute)")
- })
- }
- // Stop gracefully shuts down the cron scheduler.
- func (s *ScheduledTestRunnerService) Stop() {
- if s == nil {
- return
- }
- s.stopOnce.Do(func() {
- if s.cron != nil {
- ctx := s.cron.Stop()
- select {
- case <-ctx.Done():
- case <-time.After(3 * time.Second):
- logger.LegacyPrintf("service.scheduled_test_runner", "[ScheduledTestRunner] cron stop timed out")
- }
- }
- })
- }
- func (s *ScheduledTestRunnerService) runScheduled() {
- // Delay 10s so execution lands at ~:10 of each minute instead of :00.
- time.Sleep(10 * time.Second)
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
- defer cancel()
- now := time.Now()
- plans, err := s.planRepo.ListDue(ctx, now)
- if err != nil {
- logger.LegacyPrintf("service.scheduled_test_runner", "[ScheduledTestRunner] ListDue error: %v", err)
- return
- }
- if len(plans) == 0 {
- return
- }
- logger.LegacyPrintf("service.scheduled_test_runner", "[ScheduledTestRunner] found %d due plans", len(plans))
- sem := make(chan struct{}, scheduledTestDefaultMaxWorkers)
- var wg sync.WaitGroup
- for _, plan := range plans {
- sem <- struct{}{}
- wg.Add(1)
- go func(p *ScheduledTestPlan) {
- defer wg.Done()
- defer func() { <-sem }()
- s.runOnePlan(ctx, p)
- }(plan)
- }
- wg.Wait()
- }
- func (s *ScheduledTestRunnerService) runOnePlan(ctx context.Context, plan *ScheduledTestPlan) {
- result, err := s.accountTestSvc.RunTestBackground(ctx, plan.AccountID, plan.ModelID)
- if err != nil {
- logger.LegacyPrintf("service.scheduled_test_runner", "[ScheduledTestRunner] plan=%d RunTestBackground error: %v", plan.ID, err)
- return
- }
- if err := s.scheduledSvc.SaveResult(ctx, plan.ID, plan.MaxResults, result); err != nil {
- logger.LegacyPrintf("service.scheduled_test_runner", "[ScheduledTestRunner] plan=%d SaveResult error: %v", plan.ID, err)
- }
- // Auto-recover account if test succeeded and auto_recover is enabled.
- if result.Status == "success" && plan.AutoRecover {
- s.tryRecoverAccount(ctx, plan.AccountID, plan.ID)
- }
- nextRun, err := computeNextRun(plan.CronExpression, time.Now())
- if err != nil {
- logger.LegacyPrintf("service.scheduled_test_runner", "[ScheduledTestRunner] plan=%d computeNextRun error: %v", plan.ID, err)
- return
- }
- if err := s.planRepo.UpdateAfterRun(ctx, plan.ID, time.Now(), nextRun); err != nil {
- logger.LegacyPrintf("service.scheduled_test_runner", "[ScheduledTestRunner] plan=%d UpdateAfterRun error: %v", plan.ID, err)
- }
- }
- // tryRecoverAccount attempts to recover an account from recoverable runtime state.
- func (s *ScheduledTestRunnerService) tryRecoverAccount(ctx context.Context, accountID int64, planID int64) {
- if s.rateLimitSvc == nil {
- return
- }
- recovery, err := s.rateLimitSvc.RecoverAccountAfterSuccessfulTest(ctx, accountID)
- if err != nil {
- logger.LegacyPrintf("service.scheduled_test_runner", "[ScheduledTestRunner] plan=%d auto-recover failed: %v", planID, err)
- return
- }
- if recovery == nil {
- return
- }
- if recovery.ClearedError {
- logger.LegacyPrintf("service.scheduled_test_runner", "[ScheduledTestRunner] plan=%d auto-recover: account=%d recovered from error status", planID, accountID)
- }
- if recovery.ClearedRateLimit {
- logger.LegacyPrintf("service.scheduled_test_runner", "[ScheduledTestRunner] plan=%d auto-recover: account=%d cleared rate-limit/runtime state", planID, accountID)
- }
- }
|