Files
kandev/apps/backend/internal/scheduler/cron/cron.go
T
2026-08-24 11:09:03 +00:00

187 lines
5.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package cron owns the Phase 5 cron-driven trigger handlers (see
// docs/specs/tasks/system-design/model-unification.md, B5.1–B5.4).
//
// Three handlers run on a shared tick loop:
//
// - HeartbeatHandler iterates (task, step) pairs whose steps have an
// on_heartbeat trigger, gates them on cooldown / paused / archived,
// and fires engine.TriggerOnHeartbeat per eligible pair so the step's
// queue_run action emits a run.
// - BudgetHandler scans configured budget policies, detects threshold
// crossings, and fires engine.TriggerOnBudgetAlert so the step's
// on_budget_alert action decides what to queue (typically one run on
// the workspace coordination task rather than fanning out per-task).
// - RoutinesHandler is a thin adapter over the existing
// office/routines RoutineService.TickScheduledTriggers — routine cron
// behaviour is unchanged in Phase 5; the only collapse is that the
// newly-created task's first step on_enter handles the wakeup via
// the engine instead of needing a manual task_assigned signal.
//
// All three handlers share a single Loop so the backend has one cron
// goroutine driving all phase-5 timers. Handlers are independent — each
// one's Tick errors are logged and swallowed so a transient failure in
// one handler does not stall the others.
package cron
import (
"context"
"sync"
"time"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/logger"
)
// DefaultTickInterval is the shared cron tick. 30s is short enough that
// a heartbeat configured for "every 60s" fires close to schedule (next
// tick after the cooldown elapses) and long enough that the cron
// goroutine doesn't churn CPU when the system is idle. Individual
// handlers apply their own cadence on top of this tick — a handler
// that hasn't been due since its last fire returns immediately.
const DefaultTickInterval = 30 * time.Second
// Handler is one logical cron consumer wired into the shared loop.
// Implementations MUST be safe for concurrent calls (the loop fires
// each handler in its own goroutine per tick) but typically they are
// invoked serially per handler.
type Handler interface {
// Name returns a stable identifier used in logs and telemetry. It
// SHOULD be a short snake_case string (e.g. "heartbeat", "budget").
Name() string
// Tick performs one pass. Errors returned here are logged by the
// loop and otherwise swallowed — they MUST NOT panic.
Tick(ctx context.Context) error
}
// Loop drives a shared ticker that invokes every registered handler
// once per tick.
type Loop struct {
interval time.Duration
handlers []Handler
log *logger.Logger
mu sync.Mutex
cancel context.CancelFunc
wg sync.WaitGroup
started bool
}
// NewLoop constructs a Loop. A non-positive interval falls back to
// DefaultTickInterval. The handler order is preserved across ticks but
// each handler runs in its own goroutine per tick so a slow handler
// does not block its peers.
func NewLoop(interval time.Duration, log *logger.Logger, handlers ...Handler) *Loop {
if interval <= 0 {
interval = DefaultTickInterval
}
configured := make([]Handler, 0, len(handlers))
for _, handler := range handlers {
if handler != nil {
configured = append(configured, handler)
}
}
return &Loop{
interval: interval,
handlers: configured,
log: log.WithFields(zap.String("component", "scheduler-cron")),
}
}
// Start launches the loop. Calling Start more than once without Stop is a
// no-op. The first tick fires after interval — handlers must not rely on a
// "tick at t=0" semantic.
func (l *Loop) Start(ctx context.Context) {
if len(l.handlers) == 0 {
l.log.Info("cron loop start: no handlers, exiting")
return
}
l.mu.Lock()
if l.started {
l.mu.Unlock()
return
}
l.started = true
loopCtx, cancel := context.WithCancel(ctx)
l.cancel = cancel
l.wg.Add(1)
l.mu.Unlock()
go l.loop(loopCtx)
}
// Stop cancels the loop and waits for all active handlers to return. It is
// safe to call repeatedly and concurrently with Start.
func (l *Loop) Stop() error {
l.mu.Lock()
defer l.mu.Unlock()
if !l.started {
return nil
}
if l.cancel != nil {
l.cancel()
}
l.wg.Wait()
l.started = false
l.cancel = nil
l.log.Info("cron loop stopped")
return nil
}
func (l *Loop) loop(ctx context.Context) {
defer l.wg.Done()
names := make([]string, 0, len(l.handlers))
for _, h := range l.handlers {
names = append(names, h.Name())
}
l.log.Info("cron loop starting",
zap.Duration("interval", l.interval),
zap.Strings("handlers", names))
ticker := time.NewTicker(l.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
l.log.Info("cron loop stopping")
return
case <-ticker.C:
if ctx.Err() != nil {
return
}
l.fanOut(ctx)
}
}
}
// fanOut invokes every handler concurrently and waits for them all to
// return. Concurrent fan-out keeps a slow handler (e.g. a budget query
// hitting a transient SQLite lock) from delaying the others by up to a
// full tick.
func (l *Loop) fanOut(ctx context.Context) {
var wg sync.WaitGroup
wg.Add(len(l.handlers))
for _, h := range l.handlers {
go func(h Handler) {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
l.log.Error("cron handler panic",
zap.String("handler", h.Name()),
zap.Any("recover", r))
}
}()
if err := h.Tick(ctx); err != nil && ctx.Err() == nil {
l.log.Error("cron handler tick failed",
zap.String("handler", h.Name()),
zap.Error(err))
}
}(h)
}
wg.Wait()
}