Add cron schedules and sync docs
Some checks failed
ci / openapi (push) Failing after 7s
ci / agent (push) Failing after 5s
ci / server (push) Failing after 6s
ci / stack-smoke (push) Has been skipped
ci / web (push) Failing after 5s

This commit is contained in:
Stanislav Rossovskii
2026-06-23 19:18:01 +04:00
parent f5ec97fcb9
commit d6f9335398
24 changed files with 638 additions and 131 deletions

View File

@@ -11,6 +11,8 @@ import (
"github.com/monlet/agent/internal/runner"
)
var parseCronSchedule = config.ParseCronSchedule
// Event is a contract-shaped check result emitted by the scheduler.
type Event struct {
EventID string `json:"event_id"`
@@ -207,8 +209,10 @@ func (w *checkWorker) loop(initial config.CheckConfig) {
w.wg.Done()
}()
cfg := initial
t := time.NewTicker(cfg.Interval.Duration)
t := time.NewTimer(time.Hour)
stopTimer(t)
defer t.Stop()
var timerCh <-chan time.Time
running := false
stopping := false
@@ -230,7 +234,26 @@ func (w *checkWorker) loop(initial config.CheckConfig) {
}()
}
start(cfg)
arm := func(c config.CheckConfig, immediateInterval bool) bool {
delay, err := nextDelay(c, time.Now(), immediateInterval)
if err != nil {
return false
}
resetTimer(t, delay)
timerCh = t.C
return true
}
disarm := func() {
stopTimer(t)
timerCh = nil
}
if !arm(cfg, true) {
if w.hooks.OnStopped != nil {
w.hooks.OnStopped(cfg.ID)
}
return
}
for {
select {
@@ -257,11 +280,21 @@ func (w *checkWorker) loop(initial config.CheckConfig) {
continue
}
cfg = next
t.Reset(cfg.Interval.Duration)
if cfg.UsesCron() {
runAfterCurrent = false
if !arm(cfg, false) {
return
}
continue
}
if running {
runAfterCurrent = true
disarm()
} else {
start(cfg)
if !arm(cfg, false) {
return
}
}
case <-w.doneCh:
running = false
@@ -281,26 +314,35 @@ func (w *checkWorker) loop(initial config.CheckConfig) {
}
if runAfterCurrent {
runAfterCurrent = false
startAfterDrain := true
for {
select {
case next := <-w.updateCh:
if !reflect.DeepEqual(cfg, next) {
cfg = next
t.Reset(cfg.Interval.Duration)
startAfterDrain = !cfg.UsesCron()
}
default:
start(cfg)
if startAfterDrain {
start(cfg)
}
if !arm(cfg, false) {
return
}
goto nextLoop
}
}
nextLoop:
continue
}
case <-t.C:
case <-timerCh:
if stopping {
continue
}
start(cfg)
if !arm(cfg, false) {
return
}
}
}
}
@@ -358,3 +400,41 @@ func incidentKey(agentID string, c config.CheckConfig) string {
}
return agentID + ":" + c.ID
}
func nextDelay(c config.CheckConfig, now time.Time, immediateInterval bool) (time.Duration, error) {
if c.UsesCron() {
schedule, err := parseCronSchedule(c.Cron)
if err != nil {
return 0, err
}
return delayUntil(now, schedule.Next(now)), nil
}
if immediateInterval {
return 0, nil
}
return c.Interval.Duration, nil
}
func delayUntil(now, next time.Time) time.Duration {
if next.Before(now) {
return 0
}
return next.Sub(now)
}
func resetTimer(t *time.Timer, d time.Duration) {
if d < 0 {
d = 0
}
stopTimer(t)
t.Reset(d)
}
func stopTimer(t *time.Timer) {
if !t.Stop() {
select {
case <-t.C:
default:
}
}
}

View File

@@ -8,6 +8,7 @@ import (
"time"
"github.com/monlet/agent/internal/config"
"github.com/robfig/cron/v3"
)
func runScheduler(ctx context.Context, agentID string, checks []config.CheckConfig, out chan<- Event, h Hooks) {
@@ -27,6 +28,32 @@ func mkCheck(id, cmd string, interval, timeout time.Duration) config.CheckConfig
}
}
type fakeCronSchedule struct {
delay time.Duration
}
func (s fakeCronSchedule) Next(t time.Time) time.Time {
return t.Add(s.delay)
}
func withFakeCron(t *testing.T, delay time.Duration) {
t.Helper()
old := parseCronSchedule
parseCronSchedule = func(string) (cron.Schedule, error) {
return fakeCronSchedule{delay: delay}, nil
}
t.Cleanup(func() { parseCronSchedule = old })
}
func mkCronCheck(id, cmd string, timeout time.Duration) config.CheckConfig {
return config.CheckConfig{
ID: id,
Command: cmd,
Cron: "0 * * * *",
Timeout: config.Duration{Duration: timeout},
}
}
func TestEmitsEvents(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
@@ -45,6 +72,41 @@ func TestEmitsEvents(t *testing.T) {
}
}
func TestCronDoesNotRunImmediately(t *testing.T) {
withFakeCron(t, 200*time.Millisecond)
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
out := make(chan Event, 10)
m := NewManager(ctx, "a1", out, Hooks{})
m.Update([]config.CheckConfig{mkCronCheck("cron", "echo cron", 100*time.Millisecond)})
select {
case ev := <-out:
t.Fatalf("cron check ran before next slot: %+v", ev)
case <-ctx.Done():
}
m.Stop()
m.Wait()
}
func TestCronEmitsOnDueSlot(t *testing.T) {
withFakeCron(t, 30*time.Millisecond)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
out := make(chan Event, 10)
m := NewManager(ctx, "a1", out, Hooks{})
m.Update([]config.CheckConfig{mkCronCheck("cron", "echo cron", 100*time.Millisecond)})
select {
case ev := <-out:
if ev.CheckID != "cron" || ev.Status != "ok" {
t.Fatalf("unexpected event: %+v", ev)
}
case <-ctx.Done():
t.Fatal("no cron event")
}
m.Stop()
m.Wait()
}
func TestNoOverlapSkips(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 600*time.Millisecond)
defer cancel()
@@ -59,6 +121,23 @@ func TestNoOverlapSkips(t *testing.T) {
}
}
func TestCronNoOverlapSkips(t *testing.T) {
withFakeCron(t, 50*time.Millisecond)
ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond)
defer cancel()
out := make(chan Event, 10)
var skipped int32
hooks := Hooks{OnSkipped: func(string) { atomic.AddInt32(&skipped, 1) }}
m := NewManager(ctx, "a1", out, hooks)
m.Update([]config.CheckConfig{mkCronCheck("cron", "sleep 0.2", 500*time.Millisecond)})
<-ctx.Done()
m.Stop()
m.Wait()
if atomic.LoadInt32(&skipped) == 0 {
t.Fatal("expected at least one skipped cron slot")
}
}
func TestReportsFullEventChannel(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()