scheduled_test_runner_service.go 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. package service
  2. import (
  3. "context"
  4. "sync"
  5. "time"
  6. "github.com/Wei-Shaw/sub2api/internal/config"
  7. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  8. "github.com/robfig/cron/v3"
  9. )
  10. const scheduledTestDefaultMaxWorkers = 10
  11. // ScheduledTestRunnerService periodically scans due test plans and executes them.
  12. type ScheduledTestRunnerService struct {
  13. planRepo ScheduledTestPlanRepository
  14. scheduledSvc *ScheduledTestService
  15. accountTestSvc *AccountTestService
  16. rateLimitSvc *RateLimitService
  17. cfg *config.Config
  18. cron *cron.Cron
  19. startOnce sync.Once
  20. stopOnce sync.Once
  21. }
  22. // NewScheduledTestRunnerService creates a new runner.
  23. func NewScheduledTestRunnerService(
  24. planRepo ScheduledTestPlanRepository,
  25. scheduledSvc *ScheduledTestService,
  26. accountTestSvc *AccountTestService,
  27. rateLimitSvc *RateLimitService,
  28. cfg *config.Config,
  29. ) *ScheduledTestRunnerService {
  30. return &ScheduledTestRunnerService{
  31. planRepo: planRepo,
  32. scheduledSvc: scheduledSvc,
  33. accountTestSvc: accountTestSvc,
  34. rateLimitSvc: rateLimitSvc,
  35. cfg: cfg,
  36. }
  37. }
  38. // Start begins the cron ticker (every minute).
  39. func (s *ScheduledTestRunnerService) Start() {
  40. if s == nil {
  41. return
  42. }
  43. s.startOnce.Do(func() {
  44. loc := time.Local
  45. if s.cfg != nil {
  46. if parsed, err := time.LoadLocation(s.cfg.Timezone); err == nil && parsed != nil {
  47. loc = parsed
  48. }
  49. }
  50. c := cron.New(cron.WithParser(scheduledTestCronParser), cron.WithLocation(loc))
  51. _, err := c.AddFunc("* * * * *", func() { s.runScheduled() })
  52. if err != nil {
  53. logger.LegacyPrintf("service.scheduled_test_runner", "[ScheduledTestRunner] not started (invalid schedule): %v", err)
  54. return
  55. }
  56. s.cron = c
  57. s.cron.Start()
  58. logger.LegacyPrintf("service.scheduled_test_runner", "[ScheduledTestRunner] started (tick=every minute)")
  59. })
  60. }
  61. // Stop gracefully shuts down the cron scheduler.
  62. func (s *ScheduledTestRunnerService) Stop() {
  63. if s == nil {
  64. return
  65. }
  66. s.stopOnce.Do(func() {
  67. if s.cron != nil {
  68. ctx := s.cron.Stop()
  69. select {
  70. case <-ctx.Done():
  71. case <-time.After(3 * time.Second):
  72. logger.LegacyPrintf("service.scheduled_test_runner", "[ScheduledTestRunner] cron stop timed out")
  73. }
  74. }
  75. })
  76. }
  77. func (s *ScheduledTestRunnerService) runScheduled() {
  78. // Delay 10s so execution lands at ~:10 of each minute instead of :00.
  79. time.Sleep(10 * time.Second)
  80. ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
  81. defer cancel()
  82. now := time.Now()
  83. plans, err := s.planRepo.ListDue(ctx, now)
  84. if err != nil {
  85. logger.LegacyPrintf("service.scheduled_test_runner", "[ScheduledTestRunner] ListDue error: %v", err)
  86. return
  87. }
  88. if len(plans) == 0 {
  89. return
  90. }
  91. logger.LegacyPrintf("service.scheduled_test_runner", "[ScheduledTestRunner] found %d due plans", len(plans))
  92. sem := make(chan struct{}, scheduledTestDefaultMaxWorkers)
  93. var wg sync.WaitGroup
  94. for _, plan := range plans {
  95. sem <- struct{}{}
  96. wg.Add(1)
  97. go func(p *ScheduledTestPlan) {
  98. defer wg.Done()
  99. defer func() { <-sem }()
  100. s.runOnePlan(ctx, p)
  101. }(plan)
  102. }
  103. wg.Wait()
  104. }
  105. func (s *ScheduledTestRunnerService) runOnePlan(ctx context.Context, plan *ScheduledTestPlan) {
  106. result, err := s.accountTestSvc.RunTestBackground(ctx, plan.AccountID, plan.ModelID)
  107. if err != nil {
  108. logger.LegacyPrintf("service.scheduled_test_runner", "[ScheduledTestRunner] plan=%d RunTestBackground error: %v", plan.ID, err)
  109. return
  110. }
  111. if err := s.scheduledSvc.SaveResult(ctx, plan.ID, plan.MaxResults, result); err != nil {
  112. logger.LegacyPrintf("service.scheduled_test_runner", "[ScheduledTestRunner] plan=%d SaveResult error: %v", plan.ID, err)
  113. }
  114. // Auto-recover account if test succeeded and auto_recover is enabled.
  115. if result.Status == "success" && plan.AutoRecover {
  116. s.tryRecoverAccount(ctx, plan.AccountID, plan.ID)
  117. }
  118. nextRun, err := computeNextRun(plan.CronExpression, time.Now())
  119. if err != nil {
  120. logger.LegacyPrintf("service.scheduled_test_runner", "[ScheduledTestRunner] plan=%d computeNextRun error: %v", plan.ID, err)
  121. return
  122. }
  123. if err := s.planRepo.UpdateAfterRun(ctx, plan.ID, time.Now(), nextRun); err != nil {
  124. logger.LegacyPrintf("service.scheduled_test_runner", "[ScheduledTestRunner] plan=%d UpdateAfterRun error: %v", plan.ID, err)
  125. }
  126. }
  127. // tryRecoverAccount attempts to recover an account from recoverable runtime state.
  128. func (s *ScheduledTestRunnerService) tryRecoverAccount(ctx context.Context, accountID int64, planID int64) {
  129. if s.rateLimitSvc == nil {
  130. return
  131. }
  132. recovery, err := s.rateLimitSvc.RecoverAccountAfterSuccessfulTest(ctx, accountID)
  133. if err != nil {
  134. logger.LegacyPrintf("service.scheduled_test_runner", "[ScheduledTestRunner] plan=%d auto-recover failed: %v", planID, err)
  135. return
  136. }
  137. if recovery == nil {
  138. return
  139. }
  140. if recovery.ClearedError {
  141. logger.LegacyPrintf("service.scheduled_test_runner", "[ScheduledTestRunner] plan=%d auto-recover: account=%d recovered from error status", planID, accountID)
  142. }
  143. if recovery.ClearedRateLimit {
  144. logger.LegacyPrintf("service.scheduled_test_runner", "[ScheduledTestRunner] plan=%d auto-recover: account=%d cleared rate-limit/runtime state", planID, accountID)
  145. }
  146. }