mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-03 11:10:23 +02:00
Remove the concurrency_policy system (skip/queue/replace) — skip had an orphan bug that permanently blocked triggers, queue didn't actually queue, and replace didn't cancel running tasks. Every trigger now simply executes. Bug fixes: - Listener now handles in_review status (was silently ignored) - Issue deletion fails linked autopilot runs before DELETE (prevents orphans) - ComputeNextRun rejects invalid timezones instead of silent UTC fallback - dispatchCreateIssue post-commit failures now properly fail the run Reliability: - Scheduler recovers lost triggers on startup (crash recovery) - New index on autopilot_run(issue_id) for deletion lookups - Migration 043 cleans up historical orphaned/skipped/pending runs
140 lines
4.0 KiB
Go
140 lines
4.0 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/multica-ai/multica/server/internal/service"
|
|
"github.com/multica-ai/multica/server/internal/util"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
const schedulerInterval = 30 * time.Second
|
|
|
|
// runAutopilotScheduler polls for due schedule triggers and dispatches them.
|
|
func runAutopilotScheduler(ctx context.Context, queries *db.Queries, svc *service.AutopilotService) {
|
|
// Recover triggers that were claimed but never advanced (e.g. after a crash).
|
|
recoverLostTriggers(ctx, queries)
|
|
|
|
ticker := time.NewTicker(schedulerInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
tickScheduledAutopilots(ctx, queries, svc)
|
|
}
|
|
}
|
|
}
|
|
|
|
// recoverLostTriggers finds schedule triggers whose next_run_at is NULL
|
|
// (claimed but never advanced, typically after a crash) and recomputes it.
|
|
func recoverLostTriggers(ctx context.Context, queries *db.Queries) {
|
|
triggers, err := queries.RecoverLostTriggers(ctx)
|
|
if err != nil {
|
|
slog.Warn("autopilot scheduler: failed to recover lost triggers", "error", err)
|
|
return
|
|
}
|
|
if len(triggers) == 0 {
|
|
return
|
|
}
|
|
|
|
slog.Info("autopilot scheduler: recovering lost triggers", "count", len(triggers))
|
|
for _, t := range triggers {
|
|
if !t.CronExpression.Valid || t.CronExpression.String == "" {
|
|
continue
|
|
}
|
|
tz := "UTC"
|
|
if t.Timezone.Valid && t.Timezone.String != "" {
|
|
tz = t.Timezone.String
|
|
}
|
|
next, err := service.ComputeNextRun(t.CronExpression.String, tz)
|
|
if err != nil {
|
|
slog.Warn("autopilot scheduler: failed to compute next run for recovery",
|
|
"trigger_id", util.UUIDToString(t.ID), "error", err)
|
|
continue
|
|
}
|
|
if err := queries.AdvanceTriggerNextRun(ctx, db.AdvanceTriggerNextRunParams{
|
|
ID: t.ID,
|
|
NextRunAt: pgtype.Timestamptz{Time: next, Valid: true},
|
|
}); err != nil {
|
|
slog.Warn("autopilot scheduler: failed to recover trigger",
|
|
"trigger_id", util.UUIDToString(t.ID), "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// tickScheduledAutopilots claims all due triggers and dispatches each one.
|
|
func tickScheduledAutopilots(ctx context.Context, queries *db.Queries, svc *service.AutopilotService) {
|
|
triggers, err := queries.ClaimDueScheduleTriggers(ctx)
|
|
if err != nil {
|
|
slog.Warn("autopilot scheduler: failed to claim due triggers", "error", err)
|
|
return
|
|
}
|
|
if len(triggers) == 0 {
|
|
return
|
|
}
|
|
|
|
slog.Info("autopilot scheduler: claimed due triggers", "count", len(triggers))
|
|
|
|
for _, t := range triggers {
|
|
autopilot, err := queries.GetAutopilot(ctx, t.AutopilotID)
|
|
if err != nil {
|
|
slog.Warn("autopilot scheduler: failed to load autopilot",
|
|
"trigger_id", util.UUIDToString(t.ID),
|
|
"autopilot_id", util.UUIDToString(t.AutopilotID),
|
|
"error", err,
|
|
)
|
|
continue
|
|
}
|
|
|
|
// Dispatch the autopilot run.
|
|
if _, err := svc.DispatchAutopilot(ctx, autopilot, t.ID, "schedule", nil); err != nil {
|
|
slog.Warn("autopilot scheduler: dispatch failed",
|
|
"autopilot_id", util.UUIDToString(autopilot.ID),
|
|
"trigger_id", util.UUIDToString(t.ID),
|
|
"error", err,
|
|
)
|
|
}
|
|
|
|
// Advance next_run_at for this trigger.
|
|
advanceNextRun(ctx, queries, t)
|
|
}
|
|
}
|
|
|
|
// advanceNextRun computes the next fire time and updates the trigger.
|
|
func advanceNextRun(ctx context.Context, queries *db.Queries, t db.ClaimDueScheduleTriggersRow) {
|
|
if !t.CronExpression.Valid || t.CronExpression.String == "" {
|
|
return
|
|
}
|
|
|
|
tz := "UTC"
|
|
if t.Timezone.Valid && t.Timezone.String != "" {
|
|
tz = t.Timezone.String
|
|
}
|
|
|
|
next, err := service.ComputeNextRun(t.CronExpression.String, tz)
|
|
if err != nil {
|
|
slog.Warn("autopilot scheduler: failed to compute next run",
|
|
"trigger_id", util.UUIDToString(t.ID),
|
|
"cron", t.CronExpression.String,
|
|
"error", err,
|
|
)
|
|
return
|
|
}
|
|
|
|
if err := queries.AdvanceTriggerNextRun(ctx, db.AdvanceTriggerNextRunParams{
|
|
ID: t.ID,
|
|
NextRunAt: pgtype.Timestamptz{Time: next, Valid: true},
|
|
}); err != nil {
|
|
slog.Warn("autopilot scheduler: failed to advance next_run_at",
|
|
"trigger_id", util.UUIDToString(t.ID),
|
|
"error", err,
|
|
)
|
|
}
|
|
}
|