feat: add durable schedule management and multi-node crash recovery
This commit is contained in:
@@ -19,11 +19,11 @@ type Occurrence struct {
|
||||
ScheduledAt time.Time `db:"scheduled_at"`
|
||||
// Decision 触发处理决定:已触发、已跳过或已合并。
|
||||
Decision string `db:"decision"`
|
||||
// TaskID 关联的 Pulse 任务标识;可为空,表示尚未设置该关联或值。
|
||||
// TaskID 已触发或合并生成的 Pulse 任务标识;跳过时为空。
|
||||
TaskID *core.ID `db:"task_id"`
|
||||
// CoveredUntil 合并触发覆盖的结束边界;没有合并范围时为空。
|
||||
// CoveredUntil 合并或跳过范围的排除边界;单点处理时为空。
|
||||
CoveredUntil *time.Time `db:"covered_until"`
|
||||
// Reason 操作原因或处理说明,需要保留审计信息;可为空,表示尚未设置该关联或值。
|
||||
// Reason 稳定处理分类;新调度器始终写入,旧人工记录可为空。
|
||||
Reason *string `db:"reason"`
|
||||
}
|
||||
|
||||
|
||||
@@ -13,11 +13,17 @@ type Schedule struct {
|
||||
core.Record
|
||||
// Name 可读名称,唯一性范围由对应数据表约束。
|
||||
Name string `db:"name"`
|
||||
// QueueID 计划终身绑定的队列;旧结构人工行可为空,不允许参与扫描。
|
||||
QueueID *core.ID `db:"queue_id"`
|
||||
// ProcessingVersion 当前积压处理所用版本;发布新版本不覆盖旧进度。
|
||||
ProcessingVersion uint64 `db:"processing_version"`
|
||||
// CheckAt 下次允许扫描的 UTC 时间;为空表示当前没有扫描工作。
|
||||
CheckAt *time.Time `db:"check_at"`
|
||||
// CurrentVersion 当前生效的计划版本号,发布时与版本记录原子更新。
|
||||
CurrentVersion uint64 `db:"current_version"`
|
||||
// Paused 暂停标记;停止新的受控工作,不表示在途请求已停止。
|
||||
Paused bool `db:"paused"`
|
||||
// NextFireAt 下一计划触发时间;没有后续触发时为空。
|
||||
// NextFireAt 下一原计划触发时间;版本刚切换尚待计算或已经结束时为空,需结合 CheckAt 判断。
|
||||
NextFireAt *time.Time `db:"next_fire_at"`
|
||||
}
|
||||
|
||||
|
||||
@@ -27,8 +27,12 @@ type ScheduleVersion struct {
|
||||
MisfirePolicy string `db:"misfire_policy"`
|
||||
// OverlapPolicy 上次未完成时的策略:并行、串行或跳过。
|
||||
OverlapPolicy string `db:"overlap_policy"`
|
||||
// CatchupLimit 单次漏跑补偿上限;补跑策略必须配置正数。
|
||||
// CatchupLimit 每批生成任务数量上限,所有策略均要求 1–100。
|
||||
CatchupLimit uint32 `db:"catchup_limit"`
|
||||
// CatchupWindowMS 未处理触发点最多保留的毫秒数,过旧范围记录跳过。
|
||||
CatchupWindowMS uint64 `db:"catchup_window_ms"`
|
||||
// GraceMS 正常派发允许延迟的毫秒数,超出后按漏跑策略处理。
|
||||
GraceMS uint64 `db:"grace_ms"`
|
||||
}
|
||||
|
||||
func (ScheduleVersion) TableName() string { return "pulse_schedule_versions" }
|
||||
|
||||
135
pulse/schedule/planner.go
Normal file
135
pulse/schedule/planner.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Policy 显式规定漏跑与重叠;窗口、批量和持久化扫描间隔共同限制补跑。
|
||||
type Policy struct {
|
||||
Misfire string `json:"misfire"` // Misfire SKIP、COALESCE 或 CATCH_UP,必须明确提供。
|
||||
Overlap string `json:"overlap"` // Overlap PARALLEL、SERIAL 或 SKIP,必须明确提供。
|
||||
CatchupLimit uint32 `json:"catchup_limit"` // CatchupLimit 每批最多生成的任务数,1–100;串行最多一个。
|
||||
CatchupWindowMS uint64 `json:"catchup_window_ms"` // CatchupWindowMS 最多保留多久的未处理触发点,1000 毫秒–30 天。
|
||||
GraceMS uint64 `json:"grace_ms"` // GraceMS 允许正常派发延迟的毫秒数,1000–60000,不超过窗口。
|
||||
}
|
||||
|
||||
func (p Policy) Validate() error {
|
||||
if p.Misfire != "SKIP" && p.Misfire != "COALESCE" && p.Misfire != "CATCH_UP" {
|
||||
return errors.New("explicit misfire policy required")
|
||||
}
|
||||
if p.Overlap != "PARALLEL" && p.Overlap != "SERIAL" && p.Overlap != "SKIP" {
|
||||
return errors.New("explicit overlap policy required")
|
||||
}
|
||||
if p.CatchupLimit < 1 || p.CatchupLimit > 100 || p.CatchupWindowMS < 1000 || p.CatchupWindowMS > 2592000000 || p.GraceMS < 1000 || p.GraceMS > 60000 || p.GraceMS > p.CatchupWindowMS {
|
||||
return errors.New("invalid bounded catchup policy")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Decision 描述单点或连续范围的处理;范围为 [ScheduledAt,CoveredUntil),结束不包含。
|
||||
type Decision struct {
|
||||
ScheduledAt time.Time // ScheduledAt 范围中的首个原触发点,作为唯一去重身份。
|
||||
CoveredUntil *time.Time // CoveredUntil 合并/跳过范围的排除边界;单点为空。
|
||||
Kind string // Kind FIRED、COALESCED 或 SKIPPED。
|
||||
Reason string // Reason 稳定处理分类,不包含业务参数。
|
||||
}
|
||||
|
||||
// Batch 是同一个不可变版本的一批决定,必须与任务及进度原子持久化。
|
||||
type Batch struct {
|
||||
Decisions []Decision // Decisions 有界决策,最多 CatchupLimit+2 条。
|
||||
Next *time.Time // Next 当前版本下一触发点;nil 表示版本区间结束。
|
||||
Blocked bool // Blocked SERIAL 正在等待已有非终态任务,不能跨越该点。
|
||||
}
|
||||
|
||||
// Plan 不读取墙上时钟。now/active 由资源锁定后的数据库事实提供。
|
||||
// before 是下一版本的生效排除边界,旧积压仍按旧版本计算。
|
||||
func Plan(c *Calendar, p Policy, due, now time.Time, before *time.Time, active bool) (Batch, error) {
|
||||
out := Batch{Decisions: []Decision{}}
|
||||
if err := p.Validate(); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if due.IsZero() || now.IsZero() {
|
||||
return out, errors.New("schedule facts required")
|
||||
}
|
||||
actual, err := c.AtOrAfter(due)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if actual == nil || !actual.Equal(due) {
|
||||
return out, errors.New("cursor is not an occurrence")
|
||||
}
|
||||
if before != nil && !due.Before(*before) {
|
||||
return out, nil
|
||||
}
|
||||
out.Next = &due
|
||||
upper := now.Add(time.Microsecond)
|
||||
if before != nil && before.Before(upper) {
|
||||
upper = *before
|
||||
}
|
||||
advance := func(at time.Time) error {
|
||||
next, e := c.AtOrAfter(at)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if next != nil && before != nil && !next.Before(*before) {
|
||||
next = nil
|
||||
}
|
||||
out.Next = next
|
||||
return nil
|
||||
}
|
||||
skip := func(until time.Time, reason string) error {
|
||||
out.Decisions = append(out.Decisions, Decision{ScheduledAt: *out.Next, CoveredUntil: &until, Kind: "SKIPPED", Reason: reason})
|
||||
return advance(until)
|
||||
}
|
||||
cutoff := now.Add(-time.Duration(p.CatchupWindowMS) * time.Millisecond)
|
||||
if upper.Before(cutoff) {
|
||||
cutoff = upper
|
||||
}
|
||||
if out.Next.Before(cutoff) {
|
||||
if err := skip(cutoff, "CATCHUP_WINDOW_EXCEEDED"); err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
if out.Next == nil || !out.Next.Before(upper) {
|
||||
return out, nil
|
||||
}
|
||||
if p.Misfire == "SKIP" {
|
||||
grace := now.Add(-time.Duration(p.GraceMS) * time.Millisecond)
|
||||
if upper.Before(grace) {
|
||||
grace = upper
|
||||
}
|
||||
if out.Next.Before(grace) {
|
||||
if err := skip(grace, "MISFIRE_SKIPPED"); err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if out.Next == nil || !out.Next.Before(upper) {
|
||||
return out, nil
|
||||
}
|
||||
if active && p.Overlap == "SERIAL" {
|
||||
out.Blocked = true
|
||||
return out, nil
|
||||
}
|
||||
if active && p.Overlap == "SKIP" {
|
||||
err := skip(upper, "OVERLAP_SKIPPED")
|
||||
return out, err
|
||||
}
|
||||
if p.Misfire == "COALESCE" && out.Next.Before(now.Add(-time.Duration(p.GraceMS)*time.Millisecond)) {
|
||||
out.Decisions = append(out.Decisions, Decision{ScheduledAt: *out.Next, CoveredUntil: &upper, Kind: "COALESCED", Reason: "MISFIRE_COALESCED"})
|
||||
err := advance(upper)
|
||||
return out, err
|
||||
}
|
||||
for n := uint32(0); n < p.CatchupLimit && out.Next != nil && out.Next.Before(upper); n++ {
|
||||
at := *out.Next
|
||||
out.Decisions = append(out.Decisions, Decision{ScheduledAt: at, Kind: "FIRED", Reason: "SCHEDULED"})
|
||||
if err := advance(at.Add(time.Microsecond)); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if p.Overlap == "SERIAL" || p.Overlap == "SKIP" {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
56
pulse/schedule/planner_test.go
Normal file
56
pulse/schedule/planner_test.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMisfireOverlapAndBoundedCatchup(t *testing.T) {
|
||||
start := stamp("2026-09-17T00:00:00Z")
|
||||
c, err := Compile(Rule{Kind: "FIXED_RATE", Timezone: "UTC", StartAt: start, IntervalMS: 1000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := Policy{Misfire: "CATCH_UP", Overlap: "PARALLEL", CatchupLimit: 3, CatchupWindowMS: 10000, GraceMS: 1000}
|
||||
for _, tc := range []struct {
|
||||
name, misfire, overlap string
|
||||
active bool
|
||||
kinds []string
|
||||
next int
|
||||
blocked bool
|
||||
}{
|
||||
{"catchup", "CATCH_UP", "PARALLEL", false, []string{"SKIPPED", "FIRED", "FIRED", "FIRED"}, 13, false},
|
||||
{"coalesce", "COALESCE", "PARALLEL", false, []string{"SKIPPED", "COALESCED"}, 21, false},
|
||||
{"skip", "SKIP", "PARALLEL", false, []string{"SKIPPED", "SKIPPED", "FIRED", "FIRED"}, 21, false},
|
||||
{"serial_wait", "CATCH_UP", "SERIAL", true, []string{"SKIPPED"}, 10, true},
|
||||
{"serial_one", "CATCH_UP", "SERIAL", false, []string{"SKIPPED", "FIRED"}, 11, false},
|
||||
{"overlap_skip", "CATCH_UP", "SKIP", true, []string{"SKIPPED", "SKIPPED"}, 21, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p := base
|
||||
p.Misfire = tc.misfire
|
||||
p.Overlap = tc.overlap
|
||||
out, err := Plan(c, p, start, start.Add(20*time.Second), nil, tc.active)
|
||||
if err != nil || out.Next == nil || !out.Next.Equal(start.Add(time.Duration(tc.next)*time.Second)) || out.Blocked != tc.blocked || len(out.Decisions) != len(tc.kinds) {
|
||||
t.Fatalf("unexpected batch %+v %v", out, err)
|
||||
}
|
||||
for i, kind := range tc.kinds {
|
||||
if out.Decisions[i].Kind != kind {
|
||||
t.Fatal("wrong decision", out.Decisions)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
// 旧版本只覆盖生效边界之前,不能把新版本的时间合并进去。
|
||||
before := start.Add(15 * time.Second)
|
||||
base.Misfire = "COALESCE"
|
||||
out, err := Plan(c, base, start, start.Add(20*time.Second), &before, false)
|
||||
if err != nil || out.Next != nil || len(out.Decisions) != 2 || !out.Decisions[1].CoveredUntil.Equal(before) {
|
||||
t.Fatalf("version boundary lost: %+v %v", out, err)
|
||||
}
|
||||
// 未到期不产生记录;同一快照重算得到同一原触发点。
|
||||
out, err = Plan(c, base, start, start.Add(-time.Second), nil, false)
|
||||
if err != nil || len(out.Decisions) != 0 || out.Next == nil || !out.Next.Equal(start) {
|
||||
t.Fatal("future point processed")
|
||||
}
|
||||
}
|
||||
173
pulse/schedule/rule.go
Normal file
173
pulse/schedule/rule.go
Normal 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))
|
||||
}
|
||||
107
pulse/schedule/rule_test.go
Normal file
107
pulse/schedule/rule_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
207
pulse/schedule/service.go
Normal file
207
pulse/schedule/service.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||
)
|
||||
|
||||
// Definition 是每次发布冻结的完整定义,业务内容仍由独立 Worker 解释。
|
||||
type Definition struct {
|
||||
Rule Rule `json:"rule"` // Rule 时间规则;start_at 是基准,不允许追溯到版本生效前产生新任务。
|
||||
Policy Policy `json:"policy"` // Policy 明确的漏跑、重叠和补跑边界。
|
||||
Task service.SubmitInput `json:"task"` // Task 每次触发的模板,禁止绝对 available_at/start_deadline_at。
|
||||
}
|
||||
|
||||
// CreateInput 创建持久化计划,暂停计划也保存完整的触发进度。
|
||||
type CreateInput struct {
|
||||
Name string `json:"name"` // Name 命名空间内唯一的 ASCII 名称。
|
||||
Definition Definition `json:"definition"` // Definition 首个不可变版本。
|
||||
Paused bool `json:"paused"` // Paused 是否从暂停状态开始,恢复时处理期间漏跑。
|
||||
}
|
||||
|
||||
// PublishInput 发布立即生效的新版本;旧积压仍按当时版本处理。
|
||||
type PublishInput struct {
|
||||
ExpectedRevision uint64 `json:"expected_revision,string"` // ExpectedRevision 当前控制版本,必须非零。
|
||||
Reason string `json:"reason"` // Reason 必填操作原因,写入审计。
|
||||
Definition Definition `json:"definition"` // Definition 完整替换定义;队列不能更换。
|
||||
}
|
||||
|
||||
// ControlInput 暂停/恢复计划,不能取消已产生的任务。
|
||||
type ControlInput struct {
|
||||
ExpectedRevision uint64 `json:"expected_revision,string"` // ExpectedRevision 乐观并发版本。
|
||||
Reason string `json:"reason"` // Reason 必填操作原因,最多 1024 字符。
|
||||
}
|
||||
|
||||
// VersionView 保留规则、模板和原生效时间,已生成任务不会采用后续版本。
|
||||
type VersionView struct {
|
||||
Version uint64 `json:"version,string"` // Version 发布序号,从一递增。
|
||||
EffectiveAt time.Time `json:"effective_at"` // EffectiveAt 数据库事务内取得的生效边界,包含此时间。
|
||||
Definition Definition `json:"definition"` // Definition 已冻结内容。
|
||||
}
|
||||
|
||||
// Summary 用于计划列表,不包含大任务参数。
|
||||
type Summary struct {
|
||||
ID core.ID `json:"id"` // ID 计划稳定身份。
|
||||
NamespaceID core.ID `json:"namespace_id"` // NamespaceID 隔离范围。
|
||||
QueueID core.ID `json:"queue_id"` // QueueID 计划终身绑定队列。
|
||||
Name string `json:"name"` // Name 同命名空间内唯一名称。
|
||||
Revision uint64 `json:"revision,string"` // Revision 控制和进度变更版本。
|
||||
CurrentVersion uint64 `json:"current_version,string"` // CurrentVersion 最新发布版本。
|
||||
ProcessingVersion uint64 `json:"processing_version,string"` // ProcessingVersion 尚在处理积压的历史版本。
|
||||
Paused bool `json:"paused"` // Paused 为真时停止新触发。
|
||||
NextFireAt *time.Time `json:"next_fire_at,omitempty"` // NextFireAt 原计划时间;积压时可在过去,待初始化或结束时为空,需结合 CheckAt。
|
||||
CheckAt *time.Time `json:"check_at,omitempty"` // CheckAt 下次可扫描时间;为空表示没有扫描工作,非空也可能尚待初始化触发点。
|
||||
CreatedAt time.Time `json:"created_at"` // CreatedAt UTC 创建时间。
|
||||
UpdatedAt time.Time `json:"updated_at"` // UpdatedAt UTC 最近变更时间。
|
||||
}
|
||||
|
||||
// View 返回控制状态和最新版本,历史版本通过专用查询读取。
|
||||
type View struct {
|
||||
Summary
|
||||
Current VersionView `json:"current"` // Current 最新完整发布版本,不一定是当前积压所用版本。
|
||||
}
|
||||
|
||||
// Result 是幂等命令的原始回执,当前状态应使用 Get 查询。
|
||||
type Result struct {
|
||||
Schedule View `json:"schedule"` // Schedule 本次命令提交时的快照。
|
||||
Deduplicated bool `json:"deduplicated"` // Deduplicated 是否取回原回执。
|
||||
}
|
||||
|
||||
// OccurrenceView 描述一个已决定的触发点或不重叠范围。
|
||||
type OccurrenceView struct {
|
||||
ID core.ID `json:"id"` // ID 触发记录标识。
|
||||
ScheduleID core.ID `json:"schedule_id"` // ScheduleID 所属计划。
|
||||
ScheduleVersion uint64 `json:"schedule_version,string"` // ScheduleVersion 当时适用的不可变版本。
|
||||
ScheduledAt time.Time `json:"scheduled_at"` // ScheduledAt 原时间,参与唯一去重。
|
||||
Decision string `json:"decision"` // Decision FIRED、SKIPPED 或 COALESCED。
|
||||
TaskID *core.ID `json:"task_id,omitempty"` // TaskID 已生成任务;跳过时为空。
|
||||
CoveredUntil *time.Time `json:"covered_until,omitempty"` // CoveredUntil 范围排除边界,不一定是触发点。
|
||||
Reason string `json:"reason"` // Reason 稳定决策分类。
|
||||
CreatedAt time.Time `json:"created_at"` // CreatedAt 数据库记录时间。
|
||||
}
|
||||
type Page struct {
|
||||
Items []Summary `json:"items"` // Items 当前页摘要,始终为数组。
|
||||
NextCursor string `json:"next_cursor,omitempty"` // NextCursor 不透明后续游标。
|
||||
}
|
||||
type OccurrencePage struct {
|
||||
Items []OccurrenceView `json:"items"` // Items 当前页触发历史。
|
||||
NextCursor string `json:"next_cursor,omitempty"` // NextCursor 原样用于相同父资源查询。
|
||||
}
|
||||
type PageInput struct {
|
||||
Limit int // Limit 默认 20,最大 100。
|
||||
Cursor string // Cursor 最长 1024 字符,不能作为权限证明。
|
||||
}
|
||||
|
||||
// Store 必须原子执行命令授权、版本、游标、审计和幂等回执。
|
||||
type Store interface {
|
||||
Create(context.Context, auth.Token, string, CreateInput) (Result, error)
|
||||
Get(context.Context, auth.Token, core.ID) (View, error)
|
||||
Publish(context.Context, auth.Token, core.ID, string, PublishInput) (Result, error)
|
||||
Control(context.Context, auth.Token, core.ID, string, ControlInput, bool) (Result, error)
|
||||
List(context.Context, auth.Token, core.ID, PageInput) (Page, error)
|
||||
Version(context.Context, auth.Token, core.ID, uint64) (VersionView, error)
|
||||
Occurrences(context.Context, auth.Token, core.ID, PageInput) (OccurrencePage, error)
|
||||
}
|
||||
type Service struct{ store Store }
|
||||
|
||||
func New(store Store) *Service { return &Service{store: store} }
|
||||
func Normalize(in Definition) (Definition, error) {
|
||||
c, err := Compile(in.Rule)
|
||||
if err != nil {
|
||||
return in, fault.Invalid(err.Error())
|
||||
}
|
||||
in.Rule = c.Rule()
|
||||
if err := in.Policy.Validate(); err != nil {
|
||||
return in, fault.Invalid(err.Error())
|
||||
}
|
||||
if in.Task.AvailableAt != nil || in.Task.StartDeadlineAt != nil {
|
||||
return in, fault.Invalid("schedule templates cannot contain absolute task times")
|
||||
}
|
||||
in.Task, err = service.NormalizeSubmit(in.Task)
|
||||
return in, err
|
||||
}
|
||||
func control(id core.ID, key string, revision uint64, reason string) error {
|
||||
if id.Validate() != nil || revision == 0 || !utf8.ValidString(reason) || strings.TrimSpace(reason) == "" || utf8.RuneCountInString(reason) > 1024 {
|
||||
return fault.Invalid("valid schedule id, expected_revision and reason are required")
|
||||
}
|
||||
return service.ValidateKey(key)
|
||||
}
|
||||
func page(in PageInput) (PageInput, error) {
|
||||
if in.Limit == 0 {
|
||||
in.Limit = 20
|
||||
}
|
||||
if in.Limit < 1 || in.Limit > 100 || len(in.Cursor) > 1024 {
|
||||
return in, fault.Invalid("invalid schedule page")
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
func (s *Service) Create(ctx context.Context, t auth.Token, key string, in CreateInput) (Result, error) {
|
||||
if err := service.ValidateKey(key); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if !service.ValidName(in.Name, 128) {
|
||||
return Result{}, fault.Invalid("schedule name is invalid")
|
||||
}
|
||||
var err error
|
||||
in.Definition, err = Normalize(in.Definition)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return s.store.Create(ctx, t, key, in)
|
||||
}
|
||||
func (s *Service) Get(ctx context.Context, t auth.Token, id core.ID) (View, error) {
|
||||
if id.Validate() != nil {
|
||||
return View{}, fault.Invalid("invalid schedule id")
|
||||
}
|
||||
return s.store.Get(ctx, t, id)
|
||||
}
|
||||
func (s *Service) Publish(ctx context.Context, t auth.Token, id core.ID, key string, in PublishInput) (Result, error) {
|
||||
if err := control(id, key, in.ExpectedRevision, in.Reason); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
var err error
|
||||
in.Definition, err = Normalize(in.Definition)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return s.store.Publish(ctx, t, id, key, in)
|
||||
}
|
||||
func (s *Service) Control(ctx context.Context, t auth.Token, id core.ID, key string, in ControlInput, paused bool) (Result, error) {
|
||||
if err := control(id, key, in.ExpectedRevision, in.Reason); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return s.store.Control(ctx, t, id, key, in, paused)
|
||||
}
|
||||
func (s *Service) List(ctx context.Context, t auth.Token, queue core.ID, in PageInput) (Page, error) {
|
||||
if queue.Validate() != nil {
|
||||
return Page{}, fault.Invalid("queue_id is required")
|
||||
}
|
||||
in, err := page(in)
|
||||
if err != nil {
|
||||
return Page{}, err
|
||||
}
|
||||
return s.store.List(ctx, t, queue, in)
|
||||
}
|
||||
func (s *Service) Version(ctx context.Context, t auth.Token, id core.ID, version uint64) (VersionView, error) {
|
||||
if id.Validate() != nil || version == 0 {
|
||||
return VersionView{}, fault.Invalid("invalid schedule id or version")
|
||||
}
|
||||
return s.store.Version(ctx, t, id, version)
|
||||
}
|
||||
func (s *Service) Occurrences(ctx context.Context, t auth.Token, id core.ID, in PageInput) (OccurrencePage, error) {
|
||||
if id.Validate() != nil {
|
||||
return OccurrencePage{}, fault.Invalid("invalid schedule id")
|
||||
}
|
||||
in, err := page(in)
|
||||
if err != nil {
|
||||
return OccurrencePage{}, err
|
||||
}
|
||||
return s.store.Occurrences(ctx, t, id, in)
|
||||
}
|
||||
Reference in New Issue
Block a user