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

173
pulse/schedule/rule.go Normal file
View File

@@ -0,0 +1,173 @@
// Package schedule 计算计划触发点和有限的漏跑决策;不运行内存 Cron 或业务处理器。
package schedule
import (
"errors"
"strings"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/timezone"
"github.com/robfig/cron/v3"
)
// Rule 是不可变计划版本中的时间规则。所有边界精确到 UTC 微秒。
type Rule struct {
CalendarVersion string `json:"calendar_version"` // CalendarVersion 冻结的时区快照版本;提交为空时采用 v1 固定默认快照,升级不能静默改变。
Kind string `json:"kind"` // Kind CRON 或 FIXED_RATE;不支持以完成时间推进的 fixed-delay。
Timezone string `json:"timezone"` // Timezone 显式 IANA 名称,禁止依赖节点 Local 设置。
StartAt time.Time `json:"start_at"` // StartAt 包含此边界;固定间隔以此为唯一基准。
EndAt *time.Time `json:"end_at,omitempty"` // EndAt 排除此边界;为空时运行至支持的日期上限。
Cron string `json:"cron,omitempty"` // Cron 五段表达式:分钟、小时、月日、月、星期。
IntervalMS uint64 `json:"interval_ms,omitempty"` // IntervalMS 固定间隔毫秒数,范围 1000–2592000000。
}
// Calendar 持有已验证且不可变的计算规则,可并发读取,不包含当前进度。
type Calendar struct {
rule Rule
zone *time.Location
cron *cron.SpecSchedule
}
func validTime(t time.Time) bool {
return t.Year() >= 2000 && t.Year() <= 9998 && t.Nanosecond()%1000 == 0
}
// Compile 规范化规则并拒绝无解、混合字段和隐式本地时区。
func Compile(in Rule) (*Calendar, error) {
in.StartAt = in.StartAt.UTC()
if !validTime(in.StartAt) {
return nil, errors.New("start_at must use UTC-compatible microseconds and years 2000 to 9998")
}
if in.EndAt != nil {
end := in.EndAt.UTC()
if !validTime(end) || !end.After(in.StartAt) {
return nil, errors.New("end_at must be after start_at")
}
in.EndAt = &end
}
if in.Timezone == "" || in.Timezone == "Local" || len(in.Timezone) > 64 {
return nil, errors.New("explicit IANA timezone required")
}
if in.CalendarVersion == "" {
in.CalendarVersion = timezone.Version
}
zone, err := timezone.Load(in.Timezone, in.CalendarVersion)
if err != nil {
return nil, errors.New("invalid IANA timezone")
}
c := &Calendar{rule: in, zone: zone}
switch in.Kind {
case "FIXED_RATE":
if in.Cron != "" || in.IntervalMS < 1000 || in.IntervalMS > 2592000000 {
return nil, errors.New("fixed rate requires interval_ms 1000 to 2592000000 and no cron")
}
case "CRON":
fields := strings.Fields(in.Cron)
if in.IntervalMS != 0 || len(in.Cron) > 256 || len(fields) != 5 || strings.HasPrefix(fields[0], "@") || strings.Contains(fields[0], "=") {
return nil, errors.New("cron requires exactly five fields and no interval or embedded timezone")
}
c.rule.Cron = strings.Join(fields, " ")
parsed, err := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow).Parse(c.rule.Cron)
if err != nil {
return nil, errors.New("invalid five-field cron")
}
c.cron = parsed.(*cron.SpecSchedule)
// 仅用上游计算 UTC 日历;实际时区转换由恒定偏移区间处理,避免非整小时夏令时跳变遗漏。
c.cron.Location = time.UTC
default:
return nil, errors.New("rule kind must be CRON or FIXED_RATE")
}
first, err := c.AtOrAfter(in.StartAt)
if err != nil {
return nil, err
}
if first == nil {
return nil, errors.New("rule has no occurrence in its time bounds")
}
return c, nil
}
// Rule 返回独立规则副本,不暴露内部可变时间指针。
func (c *Calendar) Rule() Rule {
out := c.rule
if out.EndAt != nil {
v := *out.EndAt
out.EndAt = &v
}
return out
}
// AtOrAfter 返回不早于指定时刻的第一个原计划触发点;无后续返回 nil。
// DST 缺失时刻跳过,重复时刻分别按两个 UTC 身份触发;固定间隔按真实经过时间推进。
func (c *Calendar) AtOrAfter(at time.Time) (*time.Time, error) {
at = at.UTC()
if at.Before(c.rule.StartAt) {
at = c.rule.StartAt
}
end := time.Date(9999, 1, 1, 0, 0, 0, 0, time.UTC)
if c.rule.EndAt != nil {
end = *c.rule.EndAt
}
if !at.Before(end) {
return nil, nil
}
if c.cron == nil {
// 使用微秒整数而非 time.Sub,避免跨越 292 年时 Duration 饱和。
start := c.rule.StartAt.UnixMicro()
step := int64(c.rule.IntervalMS) * 1000
delta := at.UnixMicro() - start
n := delta / step
if delta%step != 0 || at.Nanosecond()%1000 != 0 {
n++
}
next := time.UnixMicro(start + n*step).UTC()
if !next.Before(end) {
return nil, nil
}
return &next, nil
}
// 五段 Gregorian 规则的最大日期间隔为世纪闰年边界的八年;十年是计算保护界。
limit := at.AddDate(10, 0, 0)
if end.Before(limit) {
limit = end
}
for segment := 0; segment < 256 && at.Before(limit); segment++ {
local := at.In(c.zone)
_, offset := local.Zone()
_, until := local.ZoneBounds()
wall := at.Add(time.Duration(offset) * time.Second)
candidate := c.cron.Next(wall.Add(-time.Nanosecond))
if candidate.IsZero() {
// 上游单次只查五年;2096→2104 等合法闰日规则需要第二个区间。
candidate = c.cron.Next(wall.AddDate(5, 0, 0).Add(-time.Nanosecond))
}
if candidate.IsZero() {
return nil, nil
}
candidate = candidate.Add(-time.Duration(offset) * time.Second).UTC()
if !until.IsZero() && !candidate.Before(until) {
if !until.After(at) {
return nil, errors.New("timezone interval did not advance")
}
at = until.UTC()
continue
}
if candidate.Before(at) {
return nil, errors.New("calendar moved backwards")
}
if !candidate.Before(limit) {
return nil, nil
}
return &candidate, nil
}
if at.Before(limit) {
return nil, errors.New("timezone transition budget exceeded")
}
return nil, nil
}
// After 返回严格晚于该触发点的下一个 UTC 身份。
func (c *Calendar) After(at time.Time) (*time.Time, error) {
return c.AtOrAfter(at.Add(time.Microsecond))
}