feat(catchup): scheduleOverdue helper for missed-window detection
This commit is contained in:
@@ -0,0 +1,29 @@
|
|||||||
|
// catchup.go — server-side catch-up for intermittent (non-always-on)
|
||||||
|
// hosts. When such a host reconnects we wait a short settle window,
|
||||||
|
// then dispatch a backup for any schedule whose window elapsed while
|
||||||
|
// the host was asleep. This is separate from pending_runs: a host that
|
||||||
|
// was asleep never fired its local cron, so no pending row exists.
|
||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// scheduleOverdue reports whether a schedule's most recent expected
|
||||||
|
// fire is newer than the host's last successful backup — i.e. a window
|
||||||
|
// passed with no backup. A nil lastBackup means "never backed up" and
|
||||||
|
// is always overdue (provided the cron parses). An unparseable cron is
|
||||||
|
// treated as not-overdue so a bad expression can never trigger a
|
||||||
|
// surprise dispatch. Uses the same cronParser the agent's scheduler
|
||||||
|
// and schedule validation use, so interpretation is identical.
|
||||||
|
func scheduleOverdue(cronExpr string, lastBackup *time.Time, now time.Time) bool {
|
||||||
|
sched, err := cronParser.Parse(cronExpr)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if lastBackup == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
next := sched.Next(*lastBackup)
|
||||||
|
return !next.After(now)
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestScheduleOverdue(t *testing.T) {
|
||||||
|
mustParse := func(s string) time.Time {
|
||||||
|
t.Helper()
|
||||||
|
v, err := time.Parse(time.RFC3339, s)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse %q: %v", s, err)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
daily := "0 2 * * *" // 02:00 every day
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
cron string
|
||||||
|
lastBackup *time.Time
|
||||||
|
now time.Time
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "never backed up is overdue", cron: daily, lastBackup: nil, now: mustParse("2026-06-15T09:00:00Z"), want: true},
|
||||||
|
{name: "missed last nights window", cron: daily, lastBackup: ptrTime(mustParse("2026-06-13T02:05:00Z")), now: mustParse("2026-06-15T09:00:00Z"), want: true},
|
||||||
|
{name: "backed up after the most recent window", cron: daily, lastBackup: ptrTime(mustParse("2026-06-15T02:05:00Z")), now: mustParse("2026-06-15T09:00:00Z"), want: false},
|
||||||
|
{name: "unparseable cron is never overdue", cron: "not a cron", lastBackup: nil, now: mustParse("2026-06-15T09:00:00Z"), want: false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
got := scheduleOverdue(c.cron, c.lastBackup, c.now)
|
||||||
|
if got != c.want {
|
||||||
|
t.Fatalf("scheduleOverdue(%q, %v, %v) = %v, want %v", c.cron, c.lastBackup, c.now, got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptrTime(t time.Time) *time.Time { return &t }
|
||||||
Reference in New Issue
Block a user