mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 20:45:37 +02:00
* refactor(autopilot): migrate scheduled dispatch to scheduler.Manager
PR 3 of 3 for the scheduled-Autopilot refactor on MUL-3551.
Replaces the legacy cmd/server/autopilot_scheduler.go goroutine
(30 s app-clock polling, app-time cron advancement, weak crash
recovery) with a JobSpec registered on the existing
scheduler.Manager. sys_cron_executions is now the lease + audit
table for scheduled Autopilot occurrences, and the unique key on
(job_name, scope_kind, scope_id, plan_time) is the primary
guarantee that the same planned fire time cannot produce two runs.
What changed
* server/internal/scheduler/jobs_autopilot.go
New AutopilotScheduleDispatchJob factory:
- scope_kind = "autopilot_trigger", scope_id = trigger.id
- PlansForScope hook (from PR 1) enumerates cron occurrences
in (lastPlan, dbNow] and collapses missed fires to the most
recent one (CatchUpLatestOnly — same policy the legacy
goroutine had, now provable via a one-row-per-tick audit).
- Handler re-loads trigger + autopilot inside the handler so a
between-tick state change (paused, disabled, deleted) takes
effect immediately and is recorded as a no-op SUCCESS row
with skipped_reason in the result JSON.
- Calls AutopilotService.DispatchAutopilotForPlan (from PR 2)
for the actual run creation; that path is itself idempotent
on (trigger_id, planned_at), so a stale-steal retry reuses
the run created by the prior attempt instead of duplicating.
- RunTimeout=2m, StaleTimeout=5m, HeartbeatInterval=30s,
AllowStaleReentry=true, MaxAttempts=3, RetryBackoff
[1m, 5m, 15m], MaxPlansPerTick=5 (safety cap).
* server/internal/scheduler/manager.go
Manager.runOnce promoted to RunOnce (exported) so external test
packages can drive deterministic ticks; existing call sites in
this package + cmd/server tests updated.
* server/internal/service/cron.go
NextOccurrenceAfterUTC and NextOccurrencesUTC: cron evaluators
that take an explicit "now" instant. Callers pass dbNow() so
schedule decisions stay consistent across app instances with
clock skew. Legacy ComputeNextRun is preserved (delegating to
NextOccurrenceAfterUTC with time.Now()) for the display-only
autopilot_trigger.next_run_at write path — scheduling decisions
no longer use it.
* server/pkg/db/queries/autopilot.sql
ListSchedulableAutopilotTriggers replaces the legacy
ClaimDueScheduleTriggers (the new path no longer mutates
autopilot_trigger.next_run_at on claim). RecoverLostTriggers
removed — sys_cron_executions lease theft now handles crash
recovery without an in-handler restart sweep.
* server/cmd/server/main.go
The "go runAutopilotScheduler(...)" line is gone. The new
JobSpec is registered alongside TaskUsageHourlyJob on the
existing schedulerMgr (still using sweepCtx for lifecycle).
* server/cmd/server/autopilot_scheduler.go DELETED.
Tests
* server/internal/service/cron_test.go — unit tests for the cron
helpers: timezone-aware enumeration, half-open (after, until]
window, plan_time-exclusive "after", invalid inputs surface
parse errors, and the "ignores wall clock" property the
scheduler relies on.
* server/cmd/server/autopilot_schedule_job_test.go — DB-backed
integration tests:
- DispatchesOnce: one tick → 1 SUCCESS exec row + 1
autopilot_run with planned_at set; a second tick does not
regress the count.
- MissedSchedulesCollapse: an hour of missed */5 fires
produce a single autopilot_run, not 12.
- CrashRecovery: simulated stale RUNNING lease at the same
plan_time → second tick reclaims it and DOES NOT duplicate
autopilot_run.
- TwoRunnersSingleWinner: two concurrent
scheduler.Manager instances on the same trigger →
per-plan_time uniqueness holds (sys_cron_executions never
has two RUNNING rows at the same plan_time, autopilot_run
count == exec row count).
- DisabledTriggerSkips: a trigger disabled between
scope-list and tick produces no exec row.
- PausedAutopilotSkipsAtHandler: an autopilot paused after
the first tick does not produce a new exec row.
- BadCronFailsLoudly: an invalid cron expression never fires
dispatch (parse error surfaces in the plan hook).
Existing autopilot listener / squad / dispatch tests still
pass.
* server/internal/scheduler/plans_for_scope_test.go from PR 1
still passes (RunOnce rename only).
Verification
* go build ./...
* go vet ./...
* go test ./internal/scheduler ./internal/service ./cmd/server
./internal/handler — all green.
Rollback
* Reverting this commit re-introduces the legacy goroutine.
Migration 124 (PR 2) and the scheduler hook (PR 1) stay in
place. Autopilot data on disk is forward- and backward-
compatible: planned_at columns are nullable, the legacy
goroutine never reads planned_at and the new job never reads
autopilot_trigger.next_run_at.
Refs MUL-3551
Co-authored-by: multica-agent <github@multica.ai>
* fix(autopilot): scheduler hook retries FAILED plans + tighten tests
Review fix for #4444 (MUL-3551).
Blocker: hook planner skipped the FAILED-with-retry plan_time
`autopilotPlansForScope` unconditionally set
`after = latest.PlanTime` when `latest.Found`, then enumerated cron
occurrences in the half-open interval `(after, dbNow]`. That
EXCLUDED the FAILED plan_time itself, so `tryClaim`'s
"FAILED-with-retry" branch — which only fires when the planner
returns the same plan_time — never ran. A claim + crash sequence
left the FAILED row stuck at attempt<max_attempts forever and the
scheduled occurrence was lost (MUL-3551 acceptance ③).
Fix: hook now branches on `latest.RetryEligible(now)` BEFORE
computing `after`. When the most recent stored row is FAILED with
attempts remaining and next_retry_at <= dbNow, the hook returns
`[latest.PlanTime]` unchanged. tryClaim's retry-from-FAILED path
fires, attempt increments, the run is retried, and the audit row
reaches SUCCESS at the same plan_time. Mirrors the cadence
planner's `info.RetryEligible(now)` branch in manager.plansForTick.
Tests
* TestAutopilotScheduleJobCrashRecovery rewritten to actually
pin the retry contract instead of just "no duplicate run":
- assert first attempt completes at attempt=1 with a real
task_id linkage (the "complete" snapshot the retry must
reuse);
- simulate a crash mid-dispatch (status=RUNNING, expired
stale_after, ghost lease_token);
- assert tick 2 transitions the SAME exec row (same plan_time)
to status=SUCCESS at attempt=2 (proving the planner did
NOT skip past the FAILED bucket);
- assert autopilot_run stays at exactly one row, reused from
the first attempt — proving DispatchAutopilotForPlan's
complete-run reuse path is what closes the loop.
* TestAutopilotScheduleJobPausedAutopilotSkipsAtHandler rewritten
to invoke `job.Handler` directly (the previous version drove
`mgr.RunOnce` which short-circuited at the scope-list SQL
filter and never reached the handler). The new test pauses the
autopilot AFTER setup, calls the handler with a fabricated
HandlerInput, and asserts the handler returns
skipped_reason=autopilot_inactive without creating an
autopilot_run.
* TestAutopilotScheduleJobBadCronFailsLoudly renamed to
TestAutopilotScheduleJobBadCronStaysSilent and updated to
match the real implementation: a parse error in the plan hook
surfaces as a manager-level warning log, NOT a
sys_cron_executions row (no plan_time was ever claimed). The
test now asserts zero exec rows AND zero autopilot_run rows,
documenting that bad cron is a permanent configuration error
(caught at HTTP create/update time first), not a transient
failure that belongs in the retry envelope.
Refs MUL-3551
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
184 lines
6.0 KiB
Go
184 lines
6.0 KiB
Go
package scheduler
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// TestManagerEveryPlanRetriesFailedSamePlanTime exercises the
|
|
// `every_plan` retry path that张大彪 flagged on PR #3707:
|
|
//
|
|
// "every_plan 的 FAILED retry 路径断了。CatchUpEveryPlan 规划必须把
|
|
// 还在 retry 窗口、attempts < max_attempts 的 FAILED row 先递回去
|
|
// 给 tryClaim 的 retry 分支,不能直接 latestPlan + cadence 跳过"
|
|
//
|
|
// The previous planner unconditionally advanced the cursor to
|
|
// `latestStored + cadence`, so after a FAILED row was written the next
|
|
// tick would skip past that plan_time and never re-attempt it — even
|
|
// though tryClaim's `(status='FAILED' AND COALESCE(next_retry_at, ...)
|
|
// <= now)` branch is the explicit retry path.
|
|
//
|
|
// This test:
|
|
//
|
|
// 1. Registers an every_plan job whose handler ALWAYS returns an
|
|
// error.
|
|
// 2. Runs a tick → FAILED row at plan_time T, attempt=1, next_retry_at
|
|
// stamped from RetryBackoff[0].
|
|
// 3. Forces next_retry_at into the past (test fast-forwards so we
|
|
// don't have to wait for the real backoff).
|
|
// 4. Runs a second tick → asserts the SAME plan_time T was retried
|
|
// and is now attempt=2 (not skipped to T+cadence).
|
|
//
|
|
// We pin a cadence well above the wall-clock difference between the
|
|
// two ticks so the rounded "current latest plan" remains the same
|
|
// bucket on both ticks, ensuring the cursor's behaviour is what we are
|
|
// actually measuring.
|
|
func TestManagerEveryPlanRetriesFailedSamePlanTime(t *testing.T) {
|
|
pool := integrationPool(t)
|
|
job := newTestJobSpec(uniqueJobName(t, "every_plan_retry"))
|
|
t.Cleanup(func() { cleanupExecutions(t, pool, job.Name) })
|
|
|
|
job.CatchUpMode = CatchUpEveryPlan
|
|
job.MaxPlansPerTick = 4
|
|
job.CatchUpWindow = 24 * time.Hour
|
|
// Long cadence so two consecutive runOnce calls land in the same
|
|
// plan_time bucket — the test is about the cursor, not the bucket
|
|
// math.
|
|
job.Cadence = time.Hour
|
|
job.MaxAttempts = 3
|
|
job.RetryBackoff = []time.Duration{
|
|
1 * time.Second, // attempt 1 → 2: sleep 1s
|
|
1 * time.Second, // attempt 2 → 3
|
|
}
|
|
job.AllowStaleReentry = false
|
|
|
|
var calls atomic.Int32
|
|
job.Handler = func(ctx context.Context, in HandlerInput) (HandlerResult, error) {
|
|
calls.Add(1)
|
|
return HandlerResult{}, errors.New("simulated handler failure")
|
|
}
|
|
|
|
mgr := NewManager(pool, Options{RunnerID: "retry-runner"})
|
|
if err := mgr.Register(*job); err != nil {
|
|
t.Fatalf("register: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
// Tick 1: handler fails at plan_time T attempt 1.
|
|
if err := mgr.RunOnce(ctx); err != nil {
|
|
t.Fatalf("first runOnce: %v", err)
|
|
}
|
|
|
|
rowsAfterTick1 := dumpJobRows(t, pool, job.Name)
|
|
if len(rowsAfterTick1) != 1 {
|
|
t.Fatalf("expected 1 row after tick 1, got %d: %+v", len(rowsAfterTick1), rowsAfterTick1)
|
|
}
|
|
r1 := rowsAfterTick1[0]
|
|
if r1.Status != "FAILED" {
|
|
t.Fatalf("expected FAILED after tick 1, got %q", r1.Status)
|
|
}
|
|
if r1.Attempt != 1 {
|
|
t.Fatalf("expected attempt=1 after tick 1, got %d", r1.Attempt)
|
|
}
|
|
if r1.NextRetryAt.IsZero() {
|
|
t.Fatalf("expected next_retry_at to be set after a retry-eligible failure")
|
|
}
|
|
|
|
planT := r1.PlanTime
|
|
|
|
// Force next_retry_at into the past so the second tick sees the
|
|
// retry as due. We deliberately use the DB's clock so this stays
|
|
// independent of the app process clock (consistent with the rest
|
|
// of the scheduler's time handling).
|
|
if _, err := pool.Exec(ctx, `
|
|
UPDATE sys_cron_executions
|
|
SET next_retry_at = now() - INTERVAL '1 minute'
|
|
WHERE id = $1
|
|
`, r1.ID); err != nil {
|
|
t.Fatalf("force next_retry_at into the past: %v", err)
|
|
}
|
|
|
|
// Tick 2: planner must keep cursor on plan_time T so tryClaim's
|
|
// FAILED-with-retry branch fires.
|
|
if err := mgr.RunOnce(ctx); err != nil {
|
|
t.Fatalf("second runOnce: %v", err)
|
|
}
|
|
|
|
rowsAfterTick2 := dumpJobRows(t, pool, job.Name)
|
|
// Still exactly one row at plan_time T — the retry reuses the
|
|
// same row, it does not create a new one.
|
|
if len(rowsAfterTick2) != 1 {
|
|
t.Fatalf("expected 1 row after tick 2 (retry reuses row), got %d: %+v",
|
|
len(rowsAfterTick2), rowsAfterTick2)
|
|
}
|
|
r2 := rowsAfterTick2[0]
|
|
if !r2.PlanTime.Equal(planT) {
|
|
t.Fatalf("planner skipped past failed plan_time: tick1=%s tick2=%s",
|
|
planT.Format(time.RFC3339), r2.PlanTime.Format(time.RFC3339))
|
|
}
|
|
if r2.Attempt != 2 {
|
|
t.Fatalf("expected attempt=2 after retry, got %d", r2.Attempt)
|
|
}
|
|
if r2.Status != "FAILED" {
|
|
// Handler still fails, so attempt 2 also lands as FAILED.
|
|
// We still want to confirm the retry actually ran.
|
|
t.Fatalf("expected attempt 2 to land FAILED again (handler still errors), got %q", r2.Status)
|
|
}
|
|
if calls.Load() != 2 {
|
|
t.Fatalf("expected handler called twice across two ticks, got %d calls", calls.Load())
|
|
}
|
|
}
|
|
|
|
// rowSnapshot is the subset of sys_cron_executions fields the test
|
|
// inspects.
|
|
type rowSnapshot struct {
|
|
ID string
|
|
PlanTime time.Time
|
|
Status string
|
|
Attempt int
|
|
MaxAttempts int
|
|
NextRetryAt time.Time
|
|
}
|
|
|
|
func dumpJobRows(t *testing.T, pool *pgxpool.Pool, jobName string) []rowSnapshot {
|
|
t.Helper()
|
|
rows, err := pool.Query(context.Background(), `
|
|
SELECT id, plan_time, status, attempt, max_attempts, COALESCE(next_retry_at, 'epoch'::timestamptz)
|
|
FROM sys_cron_executions
|
|
WHERE job_name = $1
|
|
ORDER BY plan_time ASC
|
|
`, jobName)
|
|
if err != nil {
|
|
t.Fatalf("query rows: %v", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []rowSnapshot
|
|
for rows.Next() {
|
|
var r rowSnapshot
|
|
if err := rows.Scan(&r.ID, &r.PlanTime, &r.Status, &r.Attempt, &r.MaxAttempts, &r.NextRetryAt); err != nil {
|
|
t.Fatalf("scan: %v", err)
|
|
}
|
|
// Treat the 'epoch' COALESCE sentinel as zero so callers can
|
|
// distinguish "no retry scheduled" from "retry scheduled at
|
|
// some real timestamp".
|
|
if r.NextRetryAt.Year() == 1970 {
|
|
r.NextRetryAt = time.Time{}
|
|
} else {
|
|
r.NextRetryAt = r.NextRetryAt.UTC()
|
|
}
|
|
r.PlanTime = r.PlanTime.UTC()
|
|
out = append(out, r)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
t.Fatalf("rows iter: %v", err)
|
|
}
|
|
return out
|
|
}
|