264 lines
8.3 KiB
Go

// Package scheduler runs configured cron tasks and delegates business work to handlers.
package scheduler
import (
"context"
"errors"
"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)
// AppCodeProvider 返回当前启用租户;调度器缓存短时间快照,同时会自动发现后续启用的 App。
type AppCodeProvider interface {
ListEnabledAppCodes(ctx context.Context) ([]string, 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
appCodes AppCodeProvider
appMu sync.Mutex
appCache []string
appUntil time.Time
}
// 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, providers ...AppCodeProvider) *Scheduler {
scheduler := &Scheduler{
nodeID: strings.TrimSpace(nodeID),
tasks: tasks,
store: store,
handlers: handlers,
}
if len(providers) > 0 {
scheduler.appCodes = providers[0]
}
return scheduler
}
// 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
}
wg.Go(func() {
s.runTaskLoop(ctx, taskName, task, handler)
})
}
wg.Wait()
}
func (s *Scheduler) runTaskLoop(ctx context.Context, taskName 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:
appCodes, err := s.enabledAppCodes(ctx)
if err != nil {
logx.Error(ctx, "cron_app_registry_failed", err, slog.String("task_name", taskName))
timer.Reset(interval)
continue
}
// 每个 app_code 仍使用独立租约和运行记录;这里只把租户来源从 YAML 改成启用 App 注册表。
results := make(chan bool, len(appCodes))
var batch sync.WaitGroup
for _, appCode := range appCodes {
appCode := appCode
batch.Go(func() {
results <- s.runOnce(ctx, taskName, appCode, task, handler)
})
}
batch.Wait()
close(results)
hasMore := false
for result := range results {
hasMore = hasMore || result
}
if hasMore {
timer.Reset(0)
continue
}
timer.Reset(interval)
}
}
}
func (s *Scheduler) enabledAppCodes(ctx context.Context) ([]string, error) {
s.appMu.Lock()
defer s.appMu.Unlock()
if len(s.appCache) > 0 && time.Now().UTC().Before(s.appUntil) {
return append([]string(nil), s.appCache...), nil
}
if s.appCodes == nil {
return nil, errors.New("enabled app registry is not configured")
}
values, err := s.appCodes.ListEnabledAppCodes(ctx)
if err != nil {
return nil, err
}
seen := make(map[string]bool, len(values))
appCodes := make([]string, 0, len(values))
for _, value := range values {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" || seen[value] {
continue
}
seen[value] = true
appCodes = append(appCodes, value)
}
sort.Strings(appCodes)
if len(appCodes) == 0 {
return nil, errors.New("enabled app registry returned no tenants")
}
s.appCache = append(s.appCache[:0], appCodes...)
s.appUntil = time.Now().UTC().Add(30 * time.Second)
return append([]string(nil), appCodes...), nil
}
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
}