feat: add durable relative task delays and outage recovery regression

This commit is contained in:
2026-09-17 23:43:57 +08:00
parent 522fe2ef3e
commit a664ec9370
34 changed files with 811 additions and 28 deletions

View File

@@ -16,7 +16,7 @@ import (
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。
Task service.SubmitInput `json:"task"` // Task 每次触发的模板,禁止 available_at/start_deadline_at 和非零 delay_ms。
}
// CreateInput 创建持久化计划,暂停计划也保存完整的触发进度。
@@ -121,8 +121,8 @@ func Normalize(in Definition) (Definition, error) {
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")
if in.Task.AvailableAt != nil || in.Task.StartDeadlineAt != nil || in.Task.DelayMS != 0 {
return in, fault.Invalid("schedule templates cannot contain task times or a nonzero delay_ms")
}
in.Task, err = service.NormalizeSubmit(in.Task)
return in, err

View File

@@ -0,0 +1,25 @@
package schedule
import (
"encoding/json"
"testing"
"time"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/pulse/service"
)
func TestScheduleDoesNotSilentlyIgnoreTaskDelay(t *testing.T) {
in := Definition{
Rule: Rule{Kind: "FIXED_RATE", Timezone: "UTC", StartAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), IntervalMS: 1000},
Policy: Policy{Misfire: "SKIP", Overlap: "PARALLEL", CatchupLimit: 1, CatchupWindowMS: 30000, GraceMS: 1000},
Task: service.SubmitInput{QueueID: core.ID("11111111111111111111111111111111"), Handler: "object.prepare", HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{}`)},
}
if _, err := Normalize(in); err != nil {
t.Fatal(err)
}
in.Task.DelayMS = 1
if _, err := Normalize(in); err == nil {
t.Fatal("schedule accepted a delay outside its firing rule")
}
}

View File

@@ -64,6 +64,9 @@ func NormalizeSubmit(in SubmitInput) (SubmitInput, error) {
if in.TaskTimeoutMS > 30*24*60*60*1000 {
return in, fault.Invalid("task_timeout_ms exceeds 30 days")
}
if in.DelayMS >= in.TaskTimeoutMS || (in.DelayMS != 0 && in.AvailableAt != nil) {
return in, fault.Invalid("delay_ms must be less than task_timeout_ms and cannot be combined with available_at")
}
if in.RecoverySafety == "" {
in.RecoverySafety = pulse.RecoveryReconcileOnly
}

View File

@@ -24,7 +24,14 @@ func TestSubmitNormalizationAndBounds(t *testing.T) {
"unbounded attempts": func(v *SubmitInput) { v.Retry.MaxAttempts = 101 },
"unknown recovery policy": func(v *SubmitInput) { v.RecoverySafety = "UNKNOWN" },
"excess timeout": func(v *SubmitInput) { v.TaskTimeoutMS = 30*86400000 + 1 },
"sub-microsecond time": func(v *SubmitInput) { now := time.Date(2026, 1, 1, 0, 0, 0, 1, time.UTC); v.AvailableAt = &now },
"delay equals deadline": func(v *SubmitInput) { v.DelayMS = 86400000 },
"delay overflow": func(v *SubmitInput) { v.DelayMS = ^uint64(0) },
"both timing modes": func(v *SubmitInput) {
now := time.Now().UTC().Truncate(time.Microsecond)
v.AvailableAt = &now
v.DelayMS = 1
},
"sub-microsecond time": func(v *SubmitInput) { now := time.Date(2026, 1, 1, 0, 0, 0, 1, time.UTC); v.AvailableAt = &now },
} {
t.Run(name, func(t *testing.T) {
in := base
@@ -34,6 +41,10 @@ func TestSubmitNormalizationAndBounds(t *testing.T) {
}
})
}
base.DelayMS = 86400000 - 1
if out, err := NormalizeSubmit(base); err != nil || out.DelayMS != base.DelayMS || out.AvailableAt != nil {
t.Fatal("relative delay was resolved before database acceptance", err)
}
for _, key := range []string{"", strings.Repeat("a", 129), "with space", "带中文", "line\n"} {
if ValidateKey(key) == nil {
t.Fatal("accepted invalid key")

View File

@@ -29,7 +29,9 @@ type SubmitInput struct {
ConcurrencyKey string `json:"concurrency_key,omitempty"`
// Priority 冻结优先级,0至9,每级30秒排序优势,不改变最早执行时间。
Priority int16 `json:"priority,omitempty"`
// AvailableAt 最早执行时间;为空表示受理后即可进入派发范围。
// DelayMS 相对首次受理数据库时间的等待毫秒数;零为不延迟,非零不能与 AvailableAt 同用。
DelayMS uint64 `json:"delay_ms,omitempty"`
// AvailableAt 最早执行时间;为空且 DelayMS 为零表示受理后即可进入派发范围。
AvailableAt *time.Time `json:"available_at,omitempty"`
// StartDeadlineAt 最晚开始时间;为空表示使用任务总截止时间。
StartDeadlineAt *time.Time `json:"start_deadline_at,omitempty"`