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.
This commit is contained in:
2026-06-02 14:50:25 +08:00
parent ba2f117265
commit 842782b3dd
7 changed files with 343 additions and 8 deletions
+109
View File
@@ -270,6 +270,115 @@ func (r *SchedulerRepository) CreateTrigger(ctx context.Context, in SchedulerTri
`, in.JobKey, in.TriggerType, in.RequestedBy, reason, configJSON))
}
func (r *SchedulerRepository) CreateDedupedManualTrigger(ctx context.Context, in SchedulerTriggerCreate) (*domain.SchedulerTrigger, bool, error) {
if in.TriggerType != domain.SchedulerTriggerManual {
trigger, err := r.CreateTrigger(ctx, in)
return trigger, true, err
}
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, false, err
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))`, "scheduler_trigger", in.JobKey); err != nil {
return nil, false, err
}
if item, ok, err := findActiveTrigger(ctx, tx, in.JobKey, in.TriggerType); err != nil {
return nil, false, err
} else if ok {
return item, false, tx.Commit(ctx)
}
if _, ok, err := findRunningRun(ctx, tx, in.JobKey); err != nil {
return nil, false, err
} else if ok {
reason := in.Reason
if reason == "" {
reason = "已有调度任务正在执行,本次请求已合并"
}
item := &domain.SchedulerTrigger{
ID: 0,
JobKey: in.JobKey,
TriggerType: in.TriggerType,
Status: domain.SchedulerRunSkipped,
RequestedBy: in.RequestedBy,
Reason: nullableString(reason),
ConfigOverride: decodeSchedulerJSONMap(nil),
RequestedAt: time.Now().UTC(),
}
if in.ConfigOverride != nil {
item.ConfigOverride = in.ConfigOverride
}
return item, false, tx.Commit(ctx)
}
configJSON, err := marshalSchedulerJSONMap(in.ConfigOverride)
if err != nil {
return nil, false, err
}
trigger, err := scanSchedulerTrigger(tx.QueryRow(ctx, `
INSERT INTO ops.scheduler_job_triggers (job_key, trigger_type, requested_by, reason, config_override)
VALUES ($1, $2, $3, $4, COALESCE($5::jsonb, '{}'::jsonb))
RETURNING id, job_key, trigger_type, status, requested_by, reason, config_override,
scheduler_instance_id, run_id, requested_at, claimed_at, finished_at, error_message
`, in.JobKey, in.TriggerType, in.RequestedBy, nullableString(in.Reason), configJSON))
if err != nil {
return nil, false, err
}
return trigger, true, tx.Commit(ctx)
}
func (r *SchedulerRepository) FindActiveTrigger(ctx context.Context, jobKey, triggerType string) (*domain.SchedulerTrigger, bool, error) {
return findActiveTrigger(ctx, r.pool, jobKey, triggerType)
}
func findActiveTrigger(ctx context.Context, q interface {
QueryRow(context.Context, string, ...any) pgx.Row
}, jobKey, triggerType string) (*domain.SchedulerTrigger, bool, error) {
item, err := scanSchedulerTrigger(q.QueryRow(ctx, `
SELECT id, job_key, trigger_type, status, requested_by, reason, config_override,
scheduler_instance_id, run_id, requested_at, claimed_at, finished_at, error_message
FROM ops.scheduler_job_triggers
WHERE job_key = $1
AND trigger_type = $2
AND status IN ('pending', 'claimed')
ORDER BY requested_at ASC, id ASC
LIMIT 1
`, jobKey, triggerType))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, false, nil
}
return nil, false, err
}
return item, true, nil
}
func (r *SchedulerRepository) FindRunningRun(ctx context.Context, jobKey string) (*domain.SchedulerRun, bool, error) {
return findRunningRun(ctx, r.pool, jobKey)
}
func findRunningRun(ctx context.Context, q interface {
QueryRow(context.Context, string, ...any) pgx.Row
}, jobKey string) (*domain.SchedulerRun, bool, error) {
item, err := scanSchedulerRun(q.QueryRow(ctx, `
SELECT id, job_key, trigger_id, scheduler_instance_id, trigger_type, status,
config_version, config_snapshot, stats, error_message, started_at,
finished_at, duration_ms
FROM ops.scheduler_job_runs
WHERE job_key = $1 AND status = 'running'
ORDER BY started_at DESC, id DESC
LIMIT 1
`, jobKey))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, false, nil
}
return nil, false, err
}
return item, true, nil
}
func (r *SchedulerRepository) ClaimPendingTrigger(ctx context.Context, jobKey, instanceID string) (*SchedulerTriggerClaim, bool, error) {
var claim SchedulerTriggerClaim
var configRaw []byte