Files
orbit/pulse/schedule/rule.go

174 lines
6.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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))
}