ProofedMesh/services/attest-reconciler/internal/reconcile/scheduler.go

96 lines
2.6 KiB
Go
Raw Normal View History

package reconcile
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"time"
)
// Scheduler drives Reconciler.RunOnce on a fixed cadence until ctx is
// cancelled. Spec accepts either "@every <duration>" (robfig/cron compat) or a
// bare Go duration string ("30s", "5m"). Keeping it to those two shapes buys
// us zero external deps while still matching the cron.Spec convention the
// parent issue asked for.
type Scheduler struct {
Spec string
Reconciler *Reconciler
Logger *slog.Logger
}
// Run blocks until ctx is cancelled. Returns ctx.Err() in that case; returns
// any other error immediately. Each tick's error is logged and swallowed —
// the whole point of the reconciler is retry-until-success.
func (s *Scheduler) Run(ctx context.Context) error {
if s.Reconciler == nil {
return errors.New("reconcile.Scheduler: Reconciler is nil")
}
interval, err := ParseSpec(s.Spec)
if err != nil {
return fmt.Errorf("parse spec %q: %w", s.Spec, err)
}
if s.Logger != nil {
s.Logger.Info("reconciler scheduler started", "interval", interval.String())
}
// Fire once immediately so pending rows don't sit a full interval on boot.
s.tick(ctx)
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
if s.Logger != nil {
s.Logger.Info("reconciler scheduler stopping", "reason", ctx.Err())
}
return ctx.Err()
case <-t.C:
s.tick(ctx)
}
}
}
func (s *Scheduler) tick(ctx context.Context) {
rep, err := s.Reconciler.RunOnce(ctx)
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return
}
if s.Logger != nil {
s.Logger.Error("reconciler tick failed", "err", err)
}
return
}
if s.Logger != nil && (rep.DirtyRows > 0 || rep.Failures > 0) {
s.Logger.Info("reconciler tick",
"dirty_rows", rep.DirtyRows,
"attempts", rep.Attempts,
"successes", rep.Successes,
"failures", rep.Failures)
}
}
// ParseSpec accepts "@every <duration>" or a bare Go duration. Anything else
// is rejected so operators who try a full crontab line get a loud error
// rather than a silent default.
func ParseSpec(spec string) (time.Duration, error) {
s := strings.TrimSpace(spec)
if s == "" {
return 0, errors.New("empty spec")
}
if strings.HasPrefix(s, "@every ") {
s = strings.TrimSpace(strings.TrimPrefix(s, "@every "))
} else if strings.ContainsAny(s, "* ") {
return 0, fmt.Errorf("spec %q: only @every <duration> and bare Go durations are supported", spec)
}
d, err := time.ParseDuration(s)
if err != nil {
return 0, err
}
if d <= 0 {
return 0, fmt.Errorf("spec %q: duration must be positive", spec)
}
return d, nil
}