feat: add durable relative task delays and outage recovery regression
This commit is contained in:
@@ -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
|
||||
|
||||
25
pulse/schedule/service_test.go
Normal file
25
pulse/schedule/service_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user