feat: add durable schedule management and multi-node crash recovery

This commit is contained in:
2026-09-17 16:28:58 +08:00
parent d65cf96ea7
commit 6805506450
43 changed files with 2952 additions and 29 deletions

107
pulse/schedule/rule_test.go Normal file
View File

@@ -0,0 +1,107 @@
package schedule
import (
"testing"
"time"
)
func stamp(v string) time.Time {
t, err := time.Parse(time.RFC3339Nano, v)
if err != nil {
panic(err)
}
return t
}
func calendar(t *testing.T, expr, zone, start string) *Calendar {
t.Helper()
c, err := Compile(Rule{Kind: "CRON", Cron: expr, Timezone: zone, StartAt: stamp(start)})
if err != nil {
t.Fatal(err)
}
return c
}
func TestCronCalendarUTCAndDST(t *testing.T) {
for _, tc := range []struct {
name, expr, zone, from string
want []string
}{
{"five_fields", "*/15 9-10 * * MON-FRI", "Asia/Shanghai", "2026-09-18T00:59:59Z", []string{"2026-09-18T01:00:00Z", "2026-09-18T01:15:00Z"}},
{"spring_missing", "30 2 * * *", "America/New_York", "2026-03-08T00:00:00Z", []string{"2026-03-09T06:30:00Z"}},
{"autumn_repeated", "30 1 * * *", "America/New_York", "2026-11-01T00:00:00Z", []string{"2026-11-01T05:30:00Z", "2026-11-01T06:30:00Z", "2026-11-02T06:30:00Z"}},
{"half_hour_spring", "45 2 * * *", "Australia/Lord_Howe", "2026-10-03T14:59:00Z", []string{"2026-10-03T15:45:00Z", "2026-10-04T15:45:00Z"}},
{"half_hour_autumn", "45 1 * * *", "Australia/Lord_Howe", "2026-04-04T13:00:00Z", []string{"2026-04-04T14:45:00Z", "2026-04-04T15:15:00Z"}},
{"skipped_date", "0 0 * * *", "Pacific/Apia", "2011-12-29T10:00:00Z", []string{"2011-12-29T10:00:00Z", "2011-12-30T10:00:00Z"}},
{"century_leap", "0 0 29 2 *", "UTC", "2096-03-01T00:00:00Z", []string{"2104-02-29T00:00:00Z", "2108-02-29T00:00:00Z"}},
{"day_or_weekday", "0 0 13 * MON", "UTC", "2026-09-12T00:00:00Z", []string{"2026-09-13T00:00:00Z", "2026-09-14T00:00:00Z"}},
} {
t.Run(tc.name, func(t *testing.T) {
c := calendar(t, tc.expr, tc.zone, tc.from)
at := stamp(tc.from)
for _, want := range tc.want {
got, err := c.AtOrAfter(at)
if err != nil || got == nil || !got.Equal(stamp(want)) {
t.Fatalf("at %s got %v %v want %s", at, got, err, want)
}
at = got.Add(time.Microsecond)
}
})
}
}
func TestRuleBoundsAndFixedRate(t *testing.T) {
start := stamp("2026-09-17T00:00:00.000001Z")
end := start.Add(3 * time.Second)
c, err := Compile(Rule{Kind: "FIXED_RATE", Timezone: "UTC", StartAt: start, EndAt: &end, IntervalMS: 1000})
if err != nil {
t.Fatal(err)
}
for _, at := range []time.Time{start.Add(-time.Hour), start, start.Add(time.Nanosecond), start.Add(1500 * time.Millisecond), end} {
got, err := c.AtOrAfter(at)
if err != nil {
t.Fatal(err)
}
if at.Equal(end) {
if got != nil {
t.Fatal("end is inclusive")
}
continue
}
if got == nil || got.Before(at) || !got.Before(end) || (got.UnixMicro()-start.UnixMicro())%1000000 != 0 {
t.Fatal("fixed rate drift")
}
}
// 规则副本不能修改已编译日历。
copy := c.Rule()
*copy.EndAt = start
if got, _ := c.AtOrAfter(start); got == nil {
t.Fatal("rule alias")
}
c, err = Compile(Rule{Kind: "FIXED_RATE", Timezone: "UTC", StartAt: stamp("2000-01-01T00:00:00Z"), IntervalMS: 1000})
if err != nil {
t.Fatal(err)
}
if got, err := c.AtOrAfter(stamp("9998-12-31T23:59:59.000001Z")); err != nil || got != nil {
t.Fatal("calendar overflow")
}
}
func TestRuleRejectsAmbiguityAndImpossibleDates(t *testing.T) {
base := Rule{Kind: "CRON", Timezone: "UTC", StartAt: stamp("2026-01-01T00:00:00Z"), Cron: "* * * * *"}
for _, expr := range []string{"@every 1s", "0 0 0 * * *", "TZ=UTC 0 0 * * *", "0 0 31 2 *", "60 * * * *", "0 0 * * 7", "*/0 * * * *"} {
in := base
in.Cron = expr
if _, err := Compile(in); err == nil {
t.Fatalf("accepted %q", expr)
}
}
for _, zone := range []string{"", "Local", "../UTC", "Unknown/Zone"} {
in := base
in.Timezone = zone
if _, err := Compile(in); err == nil {
t.Fatalf("accepted zone %s", zone)
}
}
in := base
in.IntervalMS = 1000
if _, err := Compile(in); err == nil {
t.Fatal("accepted mixed rule")
}
}