Files
orbit/pulse/schedule/planner.go

136 lines
4.9 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
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
}