diff --git a/internal/server/http/catchup.go b/internal/server/http/catchup.go new file mode 100644 index 0000000..15ae429 --- /dev/null +++ b/internal/server/http/catchup.go @@ -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) +} diff --git a/internal/server/http/catchup_test.go b/internal/server/http/catchup_test.go new file mode 100644 index 0000000..a9de720 --- /dev/null +++ b/internal/server/http/catchup_test.go @@ -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 }