feat(jobs): periodic scheduler with panic-recovery and per-entry enable flag

This commit is contained in:
2026-05-05 19:47:51 +08:00
parent 2e8135bcdf
commit f235b1be63
2 changed files with 158 additions and 0 deletions
+76
View File
@@ -0,0 +1,76 @@
package jobs
import (
"context"
"log/slog"
"sync"
"time"
)
// Scheduler drives a set of named periodic runners. Each runner is a closure
// invoked at a fixed interval; panics are recovered and logged.
type Scheduler struct {
log *slog.Logger
entries []schedulerEntry
wg sync.WaitGroup
}
type schedulerEntry struct {
name string
interval time.Duration
enabled bool
fn func(context.Context)
}
// NewScheduler builds a Scheduler. Add entries before calling Start.
func NewScheduler(log *slog.Logger) *Scheduler {
return &Scheduler{log: log}
}
// Add registers a periodic runner. enabled=false skips it at Start.
func (s *Scheduler) Add(name string, interval time.Duration, enabled bool, fn func(context.Context)) {
s.entries = append(s.entries, schedulerEntry{name: name, interval: interval, enabled: enabled, fn: fn})
}
// Start launches one goroutine per enabled entry. Each goroutine exits when
// ctx is canceled. Wait blocks until all goroutines exit.
func (s *Scheduler) Start(ctx context.Context) {
for _, e := range s.entries {
if !e.enabled || e.interval <= 0 {
continue
}
s.wg.Add(1)
go s.run(ctx, e)
}
}
// Wait blocks until Start's goroutines exit (typically after ctx cancel).
func (s *Scheduler) Wait() { s.wg.Wait() }
func (s *Scheduler) run(ctx context.Context, e schedulerEntry) {
defer s.wg.Done()
t := time.NewTicker(e.interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
s.invoke(ctx, e)
}
}
}
func (s *Scheduler) invoke(ctx context.Context, e schedulerEntry) {
defer func() {
if r := recover(); r != nil {
s.log.Error("runner.panic", "name", e.name, "panic", r)
}
}()
start := time.Now()
e.fn(ctx)
s.log.Info("runner.tick",
"name", e.name,
"duration_ms", time.Since(start).Milliseconds(),
)
}