Files
geo/server/internal/ops/app/scheduler.go
T
root 842782b3dd feat(scheduler): random run-window scheduling and deduped manual triggers
Support a random daily run window (random_window_start/end) where the
next auto-run time is derived from a stable per-job hash, avoiding
thundering-herd starts while keeping one run per day. Honors the last
auto-run on initial scheduling so restarts don't double-fire.

Manual triggers can opt into dedupe (dedupe_manual_triggers): an
advisory-locked path collapses concurrent requests onto an existing
pending/running job instead of queueing duplicates. Scheduler error
messages are localized to Chinese.

Register the media_supply_cache_sync job (dry-run aware) and add the
ops migration that seeds it.
2026-06-02 14:50:25 +08:00

332 lines
10 KiB
Go

package app
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/geo-platform/tenant-api/internal/ops/domain"
"github.com/geo-platform/tenant-api/internal/ops/repository"
"github.com/geo-platform/tenant-api/internal/shared/response"
)
const (
ActionSchedulerJobUpdate = "scheduler_job.update"
ActionSchedulerJobEnable = "scheduler_job.enable"
ActionSchedulerJobDisable = "scheduler_job.disable"
ActionSchedulerJobRun = "scheduler_job.run"
ActionSchedulerJobDryRun = "scheduler_job.dry_run"
)
type SchedulerService struct {
repo *repository.SchedulerRepository
audits *AuditService
}
func NewSchedulerService(repo *repository.SchedulerRepository, audits *AuditService) *SchedulerService {
return &SchedulerService{repo: repo, audits: audits}
}
type SchedulerJobListInput struct {
Category string
Keyword string
Enabled *bool
Page int
Size int
}
type SchedulerJobListResult struct {
Items []domain.SchedulerJob `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
}
type SchedulerRunListResult struct {
Items []domain.SchedulerRun `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
}
type SchedulerJobUpdateInput struct {
Enabled *bool `json:"enabled"`
ScheduleType *string `json:"schedule_type"`
IntervalSeconds *int `json:"interval_seconds"`
CronExpr *string `json:"cron_expr"`
Timezone *string `json:"timezone"`
TimeoutSeconds *int `json:"timeout_seconds"`
BatchSize *int `json:"batch_size"`
MaxConcurrency *int `json:"max_concurrency"`
Config map[string]any `json:"config"`
}
type SchedulerTriggerInput struct {
Reason string `json:"reason"`
ConfigOverride map[string]any `json:"config_override"`
}
func (s *SchedulerService) ListJobs(ctx context.Context, in SchedulerJobListInput) (*SchedulerJobListResult, error) {
page := in.Page
if page <= 0 {
page = 1
}
size := in.Size
if size <= 0 || size > 200 {
size = 50
}
items, total, err := s.repo.ListJobs(ctx, repository.SchedulerJobFilter{
Category: strings.TrimSpace(in.Category),
Keyword: strings.TrimSpace(in.Keyword),
Enabled: in.Enabled,
Limit: size,
Offset: (page - 1) * size,
})
if err != nil {
return nil, response.ErrInternal(53001, "scheduler_jobs_query_failed", "调度任务列表读取失败")
}
return &SchedulerJobListResult{Items: items, Total: total, Page: page, Size: size}, nil
}
func (s *SchedulerService) GetJob(ctx context.Context, key string) (*domain.SchedulerJob, error) {
item, err := s.repo.GetJob(ctx, normalizeSchedulerJobKey(key))
if err != nil {
return nil, mapSchedulerJobError(err)
}
return item, nil
}
func (s *SchedulerService) UpdateJob(ctx context.Context, actor *Actor, key string, in SchedulerJobUpdateInput) (*domain.SchedulerJob, error) {
normalized, err := normalizeSchedulerJobUpdate(in)
if err != nil {
return nil, err
}
updatedBy := actorID(actor)
item, err := s.repo.UpdateJob(ctx, normalizeSchedulerJobKey(key), repository.SchedulerJobUpdate{
Enabled: normalized.Enabled,
ScheduleType: normalized.ScheduleType,
IntervalSeconds: normalized.IntervalSeconds,
CronExpr: normalized.CronExpr,
Timezone: normalized.Timezone,
TimeoutSeconds: normalized.TimeoutSeconds,
BatchSize: normalized.BatchSize,
MaxConcurrency: normalized.MaxConcurrency,
Config: normalized.Config,
UpdatedBy: updatedBy,
})
if err != nil {
return nil, mapSchedulerJobError(err)
}
s.auditScheduler(ctx, actor, ActionSchedulerJobUpdate, item.JobKey, map[string]any{
"version": item.Version,
})
return item, nil
}
func (s *SchedulerService) SetEnabled(ctx context.Context, actor *Actor, key string, enabled bool) (*domain.SchedulerJob, error) {
item, err := s.repo.SetJobEnabled(ctx, normalizeSchedulerJobKey(key), enabled, actorID(actor))
if err != nil {
return nil, mapSchedulerJobError(err)
}
action := ActionSchedulerJobDisable
if enabled {
action = ActionSchedulerJobEnable
}
s.auditScheduler(ctx, actor, action, item.JobKey, map[string]any{"version": item.Version})
return item, nil
}
func (s *SchedulerService) Trigger(ctx context.Context, actor *Actor, key string, triggerType string, in SchedulerTriggerInput) (*domain.SchedulerTrigger, error) {
key = normalizeSchedulerJobKey(key)
job, err := s.repo.GetJobForRuntime(ctx, key)
if err != nil {
return nil, mapSchedulerJobError(err)
}
triggerType = strings.TrimSpace(triggerType)
if triggerType != domain.SchedulerTriggerManual && triggerType != domain.SchedulerTriggerDryRun {
return nil, response.ErrBadRequest(40083, "invalid_trigger_type", "trigger_type 不合法")
}
create := repository.SchedulerTriggerCreate{
JobKey: key,
TriggerType: triggerType,
RequestedBy: actorID(actor),
Reason: strings.TrimSpace(in.Reason),
ConfigOverride: sanitizeSchedulerConfig(in.ConfigOverride),
}
if triggerType == domain.SchedulerTriggerManual && boolFromSchedulerConfig(job.Config, "dedupe_manual_triggers") {
trigger, created, triggerErr := s.repo.CreateDedupedManualTrigger(ctx, create)
if triggerErr != nil {
return nil, response.ErrInternal(53004, "scheduler_trigger_create_failed", "创建调度触发失败")
}
if created {
s.auditScheduler(ctx, actor, ActionSchedulerJobRun, key, map[string]any{
"trigger_id": trigger.ID,
"reason": strings.TrimSpace(in.Reason),
})
}
return trigger, nil
}
trigger, err := s.repo.CreateTrigger(ctx, create)
if err != nil {
return nil, response.ErrInternal(53004, "scheduler_trigger_create_failed", "创建调度触发失败")
}
action := ActionSchedulerJobRun
if triggerType == domain.SchedulerTriggerDryRun {
action = ActionSchedulerJobDryRun
}
s.auditScheduler(ctx, actor, action, key, map[string]any{
"trigger_id": trigger.ID,
"reason": strings.TrimSpace(in.Reason),
})
return trigger, nil
}
func (s *SchedulerService) ListRuns(ctx context.Context, key, status string, page, size int) (*SchedulerRunListResult, error) {
page = maxInt(page, 1)
if size <= 0 || size > 200 {
size = 50
}
items, total, err := s.repo.ListRuns(ctx, repository.SchedulerRunFilter{
JobKey: normalizeSchedulerJobKey(key),
Status: strings.TrimSpace(status),
Limit: size,
Offset: (page - 1) * size,
})
if err != nil {
return nil, response.ErrInternal(53005, "scheduler_runs_query_failed", "调度运行历史读取失败")
}
return &SchedulerRunListResult{Items: items, Total: total, Page: page, Size: size}, nil
}
func boolFromSchedulerConfig(config map[string]any, key string) bool {
value, ok := config[key]
if !ok || value == nil {
return false
}
switch typed := value.(type) {
case bool:
return typed
case string:
return strings.EqualFold(strings.TrimSpace(typed), "true")
default:
return false
}
}
func (s *SchedulerService) ListInstances(ctx context.Context) ([]domain.SchedulerInstance, error) {
items, err := s.repo.ListInstances(ctx)
if err != nil {
return nil, response.ErrInternal(53006, "scheduler_instances_query_failed", "调度实例读取失败")
}
return items, nil
}
func normalizeSchedulerJobUpdate(in SchedulerJobUpdateInput) (SchedulerJobUpdateInput, error) {
out := in
if out.ScheduleType != nil {
value := strings.TrimSpace(*out.ScheduleType)
if value != "interval" && value != "cron" && value != "manual" {
return out, response.ErrBadRequest(40080, "invalid_schedule_type", "schedule_type 不合法")
}
out.ScheduleType = &value
}
if out.IntervalSeconds != nil && *out.IntervalSeconds <= 0 {
return out, response.ErrBadRequest(40081, "invalid_interval_seconds", "interval_seconds 必须大于 0")
}
if out.TimeoutSeconds != nil && *out.TimeoutSeconds <= 0 {
return out, response.ErrBadRequest(40082, "invalid_timeout_seconds", "timeout_seconds 必须大于 0")
}
if out.BatchSize != nil && *out.BatchSize <= 0 {
return out, response.ErrBadRequest(40084, "invalid_batch_size", "batch_size 必须大于 0")
}
if out.MaxConcurrency != nil && *out.MaxConcurrency <= 0 {
return out, response.ErrBadRequest(40085, "invalid_max_concurrency", "max_concurrency 必须大于 0")
}
if out.Timezone != nil {
value := strings.TrimSpace(*out.Timezone)
if value == "" {
value = "Asia/Shanghai"
}
if _, err := time.LoadLocation(value); err != nil {
return out, response.ErrBadRequest(40086, "invalid_timezone", "timezone 不合法")
}
out.Timezone = &value
}
if out.CronExpr != nil {
value := strings.TrimSpace(*out.CronExpr)
out.CronExpr = &value
}
out.Config = sanitizeSchedulerConfig(out.Config)
return out, nil
}
func sanitizeSchedulerConfig(config map[string]any) map[string]any {
if config == nil {
return nil
}
out := make(map[string]any, len(config))
for key, value := range config {
trimmed := strings.TrimSpace(key)
if trimmed == "" {
continue
}
out[trimmed] = value
}
return out
}
func normalizeSchedulerJobKey(key string) string {
return strings.TrimSpace(key)
}
func mapSchedulerJobError(err error) error {
if errors.Is(err, repository.ErrSchedulerJobNotFound) {
return response.ErrNotFound(40480, "scheduler_job_not_found", "调度任务不存在")
}
return response.ErrInternal(53002, "scheduler_job_query_failed", "调度任务读取失败")
}
func actorID(actor *Actor) *int64 {
if actor == nil || actor.OperatorID <= 0 {
return nil
}
id := actor.OperatorID
return &id
}
func (s *SchedulerService) auditScheduler(ctx context.Context, actor *Actor, action, key string, metadata map[string]any) {
if actor == nil || s.audits == nil {
return
}
id := actor.OperatorID
event := domain.AuditEvent{
OperatorID: &id,
OperatorName: actor.DisplayName,
Action: action,
TargetType: "scheduler_job",
TargetID: key,
Metadata: metadata,
IP: actor.IP,
UserAgent: actor.UserAgent,
RequestID: actor.RequestID,
}
_ = s.audits.Append(ctx, event)
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
func SchedulerJobConfigAsString(config map[string]any, key string) string {
value, ok := config[key]
if !ok || value == nil {
return ""
}
return fmt.Sprint(value)
}