// Package scheduler runs configured cron tasks and delegates business work to handlers. package scheduler import ( "context" "log/slog" "sort" "strings" "sync" "time" "hyapp/pkg/idgen" "hyapp/pkg/logx" "hyapp/services/cron-service/internal/config" mysqlstorage "hyapp/services/cron-service/internal/storage/mysql" ) const ( runStatusRunning = "running" runStatusSucceeded = "succeeded" runStatusFailed = "failed" ) // Store is the cron metadata boundary used by scheduler loops. type Store interface { TryAcquireTaskLease(ctx context.Context, taskName string, appCode string, ownerNodeID string, nowMs int64, leaseTTL time.Duration) (bool, error) StartTaskRun(ctx context.Context, run mysqlstorage.TaskRun) error FinishTaskRun(ctx context.Context, run mysqlstorage.TaskRun) error } // BatchRequest is the stable input passed to task handlers. type BatchRequest struct { TaskName string AppCode string RunID string WorkerID string BatchSize int LockTTL time.Duration PendingPublishMaxAge time.Duration PublishingSessionMaxAge time.Duration } // BatchResult summarizes one owner-service batch response. type BatchResult struct { ClaimedCount int ProcessedCount int SuccessCount int FailureCount int HasMore bool } // Handler executes one batch through an owner service client. type Handler func(ctx context.Context, request BatchRequest) (BatchResult, error) // Scheduler owns task loops. It never implements domain logic directly. type Scheduler struct { nodeID string tasks map[string]config.TaskConfig store Store handlers map[string]Handler } // New creates a scheduler with explicit handlers for tasks that are already migrated. func New(nodeID string, tasks map[string]config.TaskConfig, store Store, handlers map[string]Handler) *Scheduler { return &Scheduler{ nodeID: strings.TrimSpace(nodeID), tasks: tasks, store: store, handlers: handlers, } } // Run starts one loop per enabled task and app_code until ctx is cancelled. func (s *Scheduler) Run(ctx context.Context) { if s == nil { return } taskNames := make([]string, 0, len(s.tasks)) for name := range s.tasks { taskNames = append(taskNames, name) } sort.Strings(taskNames) var wg sync.WaitGroup for _, taskName := range taskNames { task := s.tasks[taskName] if !task.Enabled { continue } handler := s.handlers[taskName] if handler == nil { // 配置可以先落地,但没有 owner-service RPC handler 时不能启动空跑循环。 logx.Warn(ctx, "cron_task_handler_missing", slog.String("task_name", taskName)) continue } for _, appCode := range task.AppCodes { taskName := taskName appCode := appCode task := task handler := handler wg.Go(func() { s.runTaskLoop(ctx, taskName, appCode, task, handler) }) } } wg.Wait() } func (s *Scheduler) runTaskLoop(ctx context.Context, taskName string, appCode string, task config.TaskConfig, handler Handler) { interval := parseDuration(task.Interval, 5*time.Second) timer := time.NewTimer(0) defer timer.Stop() for { select { case <-ctx.Done(): return case <-timer.C: hasMore := s.runOnce(ctx, taskName, appCode, task, handler) if hasMore { timer.Reset(0) continue } timer.Reset(interval) } } } func (s *Scheduler) runOnce(ctx context.Context, taskName string, appCode string, task config.TaskConfig, handler Handler) bool { if s.store == nil { logx.Error(ctx, "cron_store_missing", nil, slog.String("task_name", taskName), slog.String("app_code", appCode)) return false } now := time.Now().UTC() lockTTL := parseDuration(task.LockTTL, 30*time.Second) acquired, err := s.store.TryAcquireTaskLease(ctx, taskName, appCode, s.nodeID, now.UnixMilli(), lockTTL) if err != nil { logx.Error(ctx, "cron_task_lease_failed", err, slog.String("task_name", taskName), slog.String("app_code", appCode), slog.String("worker_id", s.nodeID)) return false } if !acquired { return false } runID := idgen.New("cronrun") run := mysqlstorage.TaskRun{ RunID: runID, TaskName: taskName, AppCode: appCode, OwnerNodeID: s.nodeID, Status: runStatusRunning, StartedAtMs: now.UnixMilli(), } if err := s.store.StartTaskRun(ctx, run); err != nil { logx.Error(ctx, "cron_task_run_start_failed", err, slog.String("task_name", taskName), slog.String("app_code", appCode), slog.String("run_id", runID)) return false } timeout := parseDuration(task.Timeout, 5*time.Second) taskCtx, cancel := context.WithTimeout(ctx, timeout) result, handlerErr := handler(taskCtx, BatchRequest{ TaskName: taskName, AppCode: appCode, RunID: runID, WorkerID: s.nodeID, BatchSize: task.BatchSize, LockTTL: lockTTL, PendingPublishMaxAge: parseDuration(task.PendingPublishMaxAge, 0), PublishingSessionMaxAge: parseDuration(task.PublishingSessionMaxAge, 0), }) cancel() finished := time.Now().UTC() run.FinishedAtMs = finished.UnixMilli() run.DurationMs = finished.Sub(now).Milliseconds() run.ClaimedCount = result.ClaimedCount run.ProcessedCount = result.ProcessedCount run.SuccessCount = result.SuccessCount run.FailureCount = result.FailureCount run.Status = runStatusSucceeded if handlerErr != nil { run.Status = runStatusFailed run.ErrorMessage = handlerErr.Error() } if err := s.store.FinishTaskRun(context.Background(), run); err != nil { logx.Error(ctx, "cron_task_run_finish_failed", err, slog.String("task_name", taskName), slog.String("app_code", appCode), slog.String("run_id", runID)) return false } if handlerErr != nil { logx.Error(ctx, "cron_task_run_failed", handlerErr, slog.String("task_name", taskName), slog.String("app_code", appCode), slog.String("run_id", runID), slog.Int("processed_count", run.ProcessedCount), slog.Int64("duration_ms", run.DurationMs)) return false } // logx.Info(ctx, "cron_task_run_succeeded", slog.String("task_name", taskName), slog.String("app_code", appCode), slog.String("run_id", runID), slog.Int("processed_count", run.ProcessedCount), slog.Int("claimed_count", run.ClaimedCount), slog.Bool("has_more", result.HasMore), slog.Int64("duration_ms", run.DurationMs)) return result.HasMore } func parseDuration(raw string, fallback time.Duration) time.Duration { duration, err := time.ParseDuration(strings.TrimSpace(raw)) if err != nil || duration <= 0 { return fallback } return duration }