2026-05-09 21:47:33 +08:00

202 lines
6.5 KiB
Go

// 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 {
wg.Add(1)
go func(taskName string, appCode string, task config.TaskConfig, handler Handler) {
defer wg.Done()
s.runTaskLoop(ctx, taskName, appCode, task, handler)
}(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()
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()
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
}