feat: add durable schedule management and multi-node crash recovery
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user