feat: add durable schedule management and multi-node crash recovery
This commit is contained in:
149
sdk/go/examples/schedules/main.go
Normal file
149
sdk/go/examples/schedules/main.go
Normal file
@@ -0,0 +1,149 @@
|
||||
// schedules 演示持久化周期任务:创建、恢复、Worker 执行、触发历史和暂停。
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
func run() error {
|
||||
token, queue := os.Getenv("ORBIT_TOKEN"), os.Getenv("ORBIT_QUEUE_ID")
|
||||
if path := os.Getenv("ORBIT_CREDENTIALS_FILE"); path != "" {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read private credentials: %w", err)
|
||||
}
|
||||
var credentials struct {
|
||||
Token string `json:"token"`
|
||||
QueueID string `json:"queue_id"`
|
||||
}
|
||||
if json.Unmarshal(raw, &credentials) != nil {
|
||||
return errors.New("invalid credentials file")
|
||||
}
|
||||
if token == "" {
|
||||
token = credentials.Token
|
||||
}
|
||||
if queue == "" {
|
||||
queue = credentials.QueueID
|
||||
}
|
||||
}
|
||||
client, err := orbit.NewClient(os.Getenv("ORBIT_URL"), orbit.Options{Token: token})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
operation, err := orbit.NewWorkerMessageID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
handler := "object.snapshot." + operation
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
|
||||
defer cancel()
|
||||
worker, err := orbit.NewWorker(client, orbit.WorkerOptions{QueueID: queue, Capacity: 1, ShutdownTimeout: 5 * time.Second, Handlers: []orbit.WorkerHandler{{Capability: orbit.WorkerCapability{Handler: handler, HandlerVersion: "v1", PayloadVersion: "v1"}, Handle: func(ctx context.Context, e orbit.WorkerExecution) (json.RawMessage, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 实际副作用应按 Task.ID 或业务操作键去重;此示例只返回输入快照。
|
||||
return e.Task.Payload, nil
|
||||
}}}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
start := time.Now().UTC().Truncate(time.Microsecond).Add(1500 * time.Millisecond)
|
||||
end := start.Add(10 * time.Second)
|
||||
created, err := client.CreateSchedule(ctx, "create-"+operation, orbit.CreateScheduleRequest{Name: handler, Paused: true, Definition: orbit.ScheduleDefinition{
|
||||
Rule: orbit.ScheduleRule{Kind: "FIXED_RATE", Timezone: "UTC", StartAt: start, EndAt: &end, IntervalMS: 1000},
|
||||
Policy: orbit.SchedulePolicy{Misfire: "COALESCE", Overlap: "SERIAL", CatchupLimit: 2, CatchupWindowMS: 30000, GraceMS: 1000},
|
||||
Task: orbit.SubmitTaskRequest{QueueID: queue, Handler: handler, HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{"source_ref":"objects/sample","sequence":9007199254740993}`)},
|
||||
}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := client.ResumeSchedule(ctx, created.Schedule.ID, "resume-"+operation, orbit.ControlScheduleRequest{ExpectedRevision: created.Schedule.Revision, Reason: "start bounded example"}); err != nil {
|
||||
return err
|
||||
}
|
||||
workerCtx, stop := context.WithCancel(ctx)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- worker.Run(workerCtx) }()
|
||||
waited := false
|
||||
defer func() {
|
||||
stop()
|
||||
if !waited {
|
||||
<-done
|
||||
}
|
||||
}()
|
||||
completed := map[string]bool{}
|
||||
for len(completed) < 2 {
|
||||
page, err := client.ListScheduleOccurrences(ctx, created.Schedule.ID, orbit.SchedulePageRequest{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range page.Items {
|
||||
if item.TaskID == nil || completed[*item.TaskID] {
|
||||
continue
|
||||
}
|
||||
task, err := client.GetTask(ctx, *item.TaskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if task.State == orbit.TaskSucceeded {
|
||||
completed[task.ID] = true
|
||||
fmt.Printf("schedule=%s occurrence=%s task=%s version=%d state=%s\n", created.Schedule.ID, item.ID, task.ID, item.ScheduleVersion, task.State)
|
||||
}
|
||||
if task.State == orbit.TaskFailed || task.State == orbit.TaskNeedsReview {
|
||||
return fmt.Errorf("scheduled task requires attention: %s", task.State)
|
||||
}
|
||||
}
|
||||
if len(completed) >= 2 {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case err := <-done:
|
||||
waited = true
|
||||
return fmt.Errorf("Worker stopped before completion: %v", err)
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
// 扫描也改变计划版本;遇到 CAS 冲突读取新状态,再以新操作身份重试暂停。
|
||||
for {
|
||||
current, err := client.GetSchedule(ctx, created.Schedule.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key, err := orbit.NewWorkerMessageID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
paused, err := client.PauseSchedule(ctx, current.ID, key, orbit.ControlScheduleRequest{ExpectedRevision: current.Revision, Reason: "example finished"})
|
||||
if err == nil {
|
||||
fmt.Printf("paused=%t completed_tasks=%d current_version=%d\n", paused.Schedule.Paused, len(completed), paused.Schedule.CurrentVersion)
|
||||
break
|
||||
}
|
||||
var apiErr *orbit.APIError
|
||||
if !errors.As(err, &apiErr) || apiErr.Code != "STATE_CONFLICT" {
|
||||
return err
|
||||
}
|
||||
}
|
||||
stop()
|
||||
err = <-done
|
||||
waited = true
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("worker_drained=true running=%d\n", worker.Stats().Running)
|
||||
return nil
|
||||
}
|
||||
134
sdk/go/schedule_integration_test.go
Normal file
134
sdk/go/schedule_integration_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package orbit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestIntegrationScheduleLifecycle(t *testing.T) {
|
||||
api, queue, _ := integrationWorkerClient(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
|
||||
defer cancel()
|
||||
name := "scheduled." + workerTestID(t)
|
||||
w, err := NewWorker(api, WorkerOptions{QueueID: queue, Capacity: 1, Handlers: []WorkerHandler{runtimeHandler(name, func(ctx context.Context, e WorkerExecution) (json.RawMessage, error) { return e.Task.Payload, nil })}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run, stop := context.WithCancel(ctx)
|
||||
defer stop()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- w.Run(run) }()
|
||||
ended := false
|
||||
defer func() {
|
||||
stop()
|
||||
if !ended {
|
||||
<-done
|
||||
}
|
||||
}()
|
||||
start := time.Now().UTC().Truncate(time.Microsecond).Add(1500 * time.Millisecond)
|
||||
in := CreateScheduleRequest{Name: name, Paused: true, Definition: ScheduleDefinition{Rule: ScheduleRule{Kind: "FIXED_RATE", Timezone: "UTC", StartAt: start, IntervalMS: 1000}, Policy: SchedulePolicy{Misfire: "COALESCE", Overlap: "SERIAL", CatchupLimit: 2, CatchupWindowMS: 60000, GraceMS: 1000}, Task: SubmitTaskRequest{QueueID: queue, Handler: name, HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{"n":9007199254740993}`)}}}
|
||||
key := "schedule-" + workerTestID(t)
|
||||
accepted, err := api.CreateSchedule(ctx, key, in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
duplicate, err := api.CreateSchedule(ctx, key, in)
|
||||
if err != nil || !duplicate.Deduplicated || duplicate.Schedule.ID != accepted.Schedule.ID {
|
||||
t.Fatal("schedule submit replay", err)
|
||||
}
|
||||
resumed, err := api.ResumeSchedule(ctx, accepted.Schedule.ID, workerTestID(t), ControlScheduleRequest{ExpectedRevision: accepted.Schedule.Revision, Reason: "run integration schedule"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var occurrence ScheduleOccurrence
|
||||
for occurrence.TaskID == nil {
|
||||
page, err := api.ListScheduleOccurrences(ctx, resumed.Schedule.ID, SchedulePageRequest{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, item := range page.Items {
|
||||
if item.TaskID != nil {
|
||||
occurrence = item
|
||||
break
|
||||
}
|
||||
}
|
||||
if occurrence.TaskID != nil {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatal(ctx.Err())
|
||||
case <-time.After(80 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
task := waitRuntimeState(t, ctx, api, *occurrence.TaskID, TaskSucceeded)
|
||||
if string(task.Result) != `{"n":9007199254740993}` || task.AttemptCount != 1 {
|
||||
t.Fatal("scheduled Worker result corrupted")
|
||||
}
|
||||
var paused ScheduleResult
|
||||
for {
|
||||
current, err := api.GetSchedule(ctx, resumed.Schedule.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
paused, err = api.PauseSchedule(ctx, current.ID, workerTestID(t), ControlScheduleRequest{ExpectedRevision: current.Revision, Reason: "publish next definition"})
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
var apiErr *APIError
|
||||
if !errors.As(err, &apiErr) || apiErr.Code != "STATE_CONFLICT" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
in.Definition.Rule = ScheduleRule{Kind: "CRON", Timezone: "Australia/Lord_Howe", StartAt: start, Cron: "*/5 * * * *"}
|
||||
publish := PublishScheduleRequest{ExpectedRevision: paused.Schedule.Revision, Reason: "test immutable cron version", Definition: in.Definition}
|
||||
publishKey := workerTestID(t)
|
||||
updated, err := api.PublishSchedule(ctx, paused.Schedule.ID, publishKey, publish)
|
||||
if err != nil || updated.Schedule.CurrentVersion != 2 || !updated.Schedule.Paused {
|
||||
t.Fatal("publish schedule", err)
|
||||
}
|
||||
old, err := api.GetScheduleVersion(ctx, paused.Schedule.ID, 1)
|
||||
if err != nil || old.Definition.Rule.Kind != "FIXED_RATE" {
|
||||
t.Fatal("old version lost", err)
|
||||
}
|
||||
current, err := api.GetScheduleVersion(ctx, paused.Schedule.ID, 2)
|
||||
if err != nil || current.Definition.Rule.Kind != "CRON" {
|
||||
t.Fatal("new cron version lost", err)
|
||||
}
|
||||
duplicate, err = api.PublishSchedule(ctx, paused.Schedule.ID, publishKey, publish)
|
||||
if err != nil || !duplicate.Deduplicated || duplicate.Schedule.Revision != updated.Schedule.Revision {
|
||||
t.Fatal("publish replay changed revision")
|
||||
}
|
||||
list, err := api.ListSchedules(ctx, queue, SchedulePageRequest{Limit: 100})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, v := range list.Items {
|
||||
found = found || v.ID == updated.Schedule.ID
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("schedule missing from list")
|
||||
}
|
||||
before, err := api.ListScheduleOccurrences(ctx, updated.Schedule.ID, SchedulePageRequest{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatal(ctx.Err())
|
||||
case <-time.After(1200 * time.Millisecond):
|
||||
}
|
||||
after, err := api.ListScheduleOccurrences(ctx, updated.Schedule.ID, SchedulePageRequest{})
|
||||
if err != nil || len(after.Items) != len(before.Items) {
|
||||
t.Fatal("paused version fired")
|
||||
}
|
||||
stop()
|
||||
if err := <-done; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ended = true
|
||||
}
|
||||
110
sdk/go/schedule_types.go
Normal file
110
sdk/go/schedule_types.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package orbit
|
||||
|
||||
import "time"
|
||||
|
||||
// ScheduleRule 是不可变计划版本中的时间规则。所有边界精确到 UTC 微秒。
|
||||
type ScheduleRule struct {
|
||||
CalendarVersion string `json:"calendar_version,omitempty"` // 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。
|
||||
}
|
||||
|
||||
// SchedulePolicy 显式规定漏跑与重叠;窗口、批量和持久化扫描间隔共同限制补跑。
|
||||
type SchedulePolicy 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,不超过窗口。
|
||||
}
|
||||
|
||||
// ScheduleDefinition 是每次发布冻结的完整定义,业务内容仍由独立 Worker 解释。
|
||||
type ScheduleDefinition struct {
|
||||
Rule ScheduleRule `json:"rule"` // Rule 时间规则;start_at 是基准,不允许追溯到版本生效前产生新任务。
|
||||
Policy SchedulePolicy `json:"policy"` // Policy 明确的漏跑、重叠和补跑边界。
|
||||
Task SubmitTaskRequest `json:"task"` // Task 每次触发的模板,禁止绝对 available_at/start_deadline_at。
|
||||
}
|
||||
|
||||
// CreateScheduleRequest 创建持久化计划,暂停计划也保存完整的触发进度。
|
||||
type CreateScheduleRequest struct {
|
||||
Name string `json:"name"` // Name 命名空间内唯一的 ASCII 名称。
|
||||
Definition ScheduleDefinition `json:"definition"` // Definition 首个不可变版本。
|
||||
Paused bool `json:"paused"` // Paused 是否从暂停状态开始,恢复时处理期间漏跑。
|
||||
}
|
||||
|
||||
// PublishScheduleRequest 发布立即生效的新版本;旧积压仍按当时版本处理。
|
||||
type PublishScheduleRequest struct {
|
||||
ExpectedRevision uint64 `json:"expected_revision,string"` // ExpectedRevision 当前控制版本,必须非零。
|
||||
Reason string `json:"reason"` // Reason 必填操作原因,写入审计。
|
||||
Definition ScheduleDefinition `json:"definition"` // Definition 完整替换定义;队列不能更换。
|
||||
}
|
||||
|
||||
// ControlScheduleRequest 暂停/恢复计划,不能取消已产生的任务。
|
||||
type ControlScheduleRequest struct {
|
||||
ExpectedRevision uint64 `json:"expected_revision,string"` // ExpectedRevision 乐观并发版本。
|
||||
Reason string `json:"reason"` // Reason 必填操作原因,最多 1024 字符。
|
||||
}
|
||||
|
||||
// ScheduleVersion 保留规则、模板和原生效时间,已生成任务不会采用后续版本。
|
||||
type ScheduleVersion struct {
|
||||
Version uint64 `json:"version,string"` // Version 发布序号,从一递增。
|
||||
EffectiveAt time.Time `json:"effective_at"` // EffectiveAt 数据库事务内取得的生效边界,包含此时间。
|
||||
Definition ScheduleDefinition `json:"definition"` // Definition 已冻结内容。
|
||||
}
|
||||
|
||||
// ScheduleSummary 用于计划列表,不包含大任务参数。
|
||||
type ScheduleSummary struct {
|
||||
ID string `json:"id"` // ID 计划稳定身份。
|
||||
NamespaceID string `json:"namespace_id"` // NamespaceID 隔离范围。
|
||||
QueueID string `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 最近变更时间。
|
||||
}
|
||||
|
||||
// Schedule 返回控制状态和最新版本,历史版本通过专用查询读取。
|
||||
type Schedule struct {
|
||||
ScheduleSummary
|
||||
Current ScheduleVersion `json:"current"` // Current 最新完整发布版本,不一定是当前积压所用版本。
|
||||
}
|
||||
|
||||
// ScheduleResult 是幂等命令的原始回执,当前状态应使用 Get 查询。
|
||||
type ScheduleResult struct {
|
||||
Schedule Schedule `json:"schedule"` // Schedule 本次命令提交时的快照。
|
||||
Deduplicated bool `json:"deduplicated"` // Deduplicated 是否取回原回执。
|
||||
}
|
||||
|
||||
// ScheduleOccurrence 描述一个已决定的触发点或不重叠范围。
|
||||
type ScheduleOccurrence struct {
|
||||
ID string `json:"id"` // ID 触发记录标识。
|
||||
ScheduleID string `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 *string `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 SchedulePage struct {
|
||||
Items []ScheduleSummary `json:"items"` // Items 当前页摘要,始终为数组。
|
||||
NextCursor string `json:"next_cursor,omitempty"` // NextCursor 不透明后续游标。
|
||||
}
|
||||
type ScheduleOccurrencePage struct {
|
||||
Items []ScheduleOccurrence `json:"items"` // Items 当前页触发历史。
|
||||
NextCursor string `json:"next_cursor,omitempty"` // NextCursor 原样用于相同父资源查询。
|
||||
}
|
||||
type SchedulePageRequest struct {
|
||||
Limit int // Limit 默认 20,最大 100。
|
||||
Cursor string // Cursor 最长 1024 字符,不能作为权限证明。
|
||||
}
|
||||
83
sdk/go/schedules.go
Normal file
83
sdk/go/schedules.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package orbit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// CreateSchedule 使用稳定幂等键受理一个计划;回执不代表任何一次触发已经执行。
|
||||
func (c *Client) CreateSchedule(ctx context.Context, key string, in CreateScheduleRequest) (ScheduleResult, error) {
|
||||
var out ScheduleResult
|
||||
err := c.call(ctx, "POST", "/v1/schedules", key, in, &out)
|
||||
return out, err
|
||||
}
|
||||
func (c *Client) GetSchedule(ctx context.Context, id string) (Schedule, error) {
|
||||
var out Schedule
|
||||
if !validID(id) {
|
||||
return out, errors.New("invalid schedule id")
|
||||
}
|
||||
err := c.call(ctx, "GET", "/v1/schedules/"+id, "", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// PublishSchedule 发布完整新版本。保留原 key/revision 重报,不能重置原版本的积压身份。
|
||||
func (c *Client) PublishSchedule(ctx context.Context, id, key string, in PublishScheduleRequest) (ScheduleResult, error) {
|
||||
var out ScheduleResult
|
||||
if !validID(id) {
|
||||
return out, errors.New("invalid schedule id")
|
||||
}
|
||||
err := c.call(ctx, "PATCH", "/v1/schedules/"+id, key, in, &out)
|
||||
return out, err
|
||||
}
|
||||
func (c *Client) PauseSchedule(ctx context.Context, id, key string, in ControlScheduleRequest) (ScheduleResult, error) {
|
||||
return c.controlSchedule(ctx, id, key, in, "pause")
|
||||
}
|
||||
func (c *Client) ResumeSchedule(ctx context.Context, id, key string, in ControlScheduleRequest) (ScheduleResult, error) {
|
||||
return c.controlSchedule(ctx, id, key, in, "resume")
|
||||
}
|
||||
func (c *Client) controlSchedule(ctx context.Context, id, key string, in ControlScheduleRequest, action string) (ScheduleResult, error) {
|
||||
var out ScheduleResult
|
||||
if !validID(id) {
|
||||
return out, errors.New("invalid schedule id")
|
||||
}
|
||||
err := c.call(ctx, "POST", "/v1/schedules/"+id+":"+action, key, in, &out)
|
||||
return out, err
|
||||
}
|
||||
func (c *Client) ListSchedules(ctx context.Context, queue string, in SchedulePageRequest) (SchedulePage, error) {
|
||||
var out SchedulePage
|
||||
if !validID(queue) {
|
||||
return out, errors.New("invalid queue id")
|
||||
}
|
||||
q := scheduleQuery(in)
|
||||
q.Set("queue_id", queue)
|
||||
err := c.call(ctx, "GET", "/v1/schedules?"+q.Encode(), "", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
func (c *Client) GetScheduleVersion(ctx context.Context, id string, version uint64) (ScheduleVersion, error) {
|
||||
var out ScheduleVersion
|
||||
if !validID(id) || version == 0 {
|
||||
return out, errors.New("invalid schedule id or version")
|
||||
}
|
||||
err := c.call(ctx, "GET", "/v1/schedules/"+id+"/versions/"+strconv.FormatUint(version, 10), "", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
func (c *Client) ListScheduleOccurrences(ctx context.Context, id string, in SchedulePageRequest) (ScheduleOccurrencePage, error) {
|
||||
var out ScheduleOccurrencePage
|
||||
if !validID(id) {
|
||||
return out, errors.New("invalid schedule id")
|
||||
}
|
||||
err := c.call(ctx, "GET", "/v1/schedules/"+id+"/occurrences?"+scheduleQuery(in).Encode(), "", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
func scheduleQuery(in SchedulePageRequest) url.Values {
|
||||
q := url.Values{}
|
||||
if in.Limit != 0 {
|
||||
q.Set("limit", strconv.Itoa(in.Limit))
|
||||
}
|
||||
if in.Cursor != "" {
|
||||
q.Set("cursor", in.Cursor)
|
||||
}
|
||||
return q
|
||||
}
|
||||
@@ -392,3 +392,83 @@ func TestIntegrationWorkerProcessCrashUnknown(t *testing.T) {
|
||||
t.Fatal("external work marker lost")
|
||||
}
|
||||
}
|
||||
|
||||
// 在没有其他 Orbit 节点的间隙真实强杀调度进程,再由两个新进程从数据库接续。
|
||||
func TestIntegrationWorkerProcessScheduleRecovery(t *testing.T) {
|
||||
a := startOrbitProcess(t)
|
||||
_, queue, token := integrationWorkerClient(t)
|
||||
apiA := processClient(t, a, token)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 35*time.Second)
|
||||
defer cancel()
|
||||
name := "schedule-crash." + workerTestID(t)
|
||||
start := time.Now().UTC().Truncate(time.Microsecond).Add(time.Second)
|
||||
end := start.Add(8 * time.Second)
|
||||
created, err := apiA.CreateSchedule(ctx, workerTestID(t), CreateScheduleRequest{Name: name, Definition: ScheduleDefinition{
|
||||
Rule: ScheduleRule{Kind: "FIXED_RATE", Timezone: "UTC", StartAt: start, EndAt: &end, IntervalMS: 1000},
|
||||
Policy: SchedulePolicy{Misfire: "CATCH_UP", Overlap: "PARALLEL", CatchupLimit: 2, CatchupWindowMS: 60000, GraceMS: 1000},
|
||||
Task: SubmitTaskRequest{QueueID: queue, Handler: name, HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{"source_ref":"objects/recovery"}`)},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for {
|
||||
page, err := apiA.ListScheduleOccurrences(ctx, created.Schedule.ID, SchedulePageRequest{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page.Items) > 0 {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatal(ctx.Err())
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
a.kill(t)
|
||||
// 三秒无服务形成真实漏跑,不改数据库时钟或游标。
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatal(ctx.Err())
|
||||
case <-time.After(3 * time.Second):
|
||||
}
|
||||
b := startOrbitProcess(t)
|
||||
c := startOrbitProcess(t)
|
||||
if a.nodeID == b.nodeID || a.nodeID == c.nodeID || b.nodeID == c.nodeID {
|
||||
t.Fatal("scheduler nodes reused identity")
|
||||
}
|
||||
apiB := processClient(t, b, token)
|
||||
for {
|
||||
current, err := apiB.GetSchedule(ctx, created.Schedule.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if current.CheckAt == nil && current.NextFireAt == nil {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatal(ctx.Err())
|
||||
case <-time.After(80 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
page, err := apiB.ListScheduleOccurrences(ctx, created.Schedule.ID, SchedulePageRequest{Limit: 100})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page.Items) != 8 {
|
||||
t.Fatalf("expected eight durable occurrences, got %d", len(page.Items))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for i, item := range page.Items {
|
||||
want := start.Add(time.Duration(7-i) * time.Second)
|
||||
if item.TaskID == nil || item.Decision != "FIRED" || item.ScheduleVersion != 1 || !item.ScheduledAt.Equal(want) || seen[*item.TaskID] {
|
||||
t.Fatal("recovery duplicated, skipped, or retimed occurrence")
|
||||
}
|
||||
seen[*item.TaskID] = true
|
||||
task, err := apiB.GetTask(ctx, *item.TaskID)
|
||||
if err != nil || task.State != TaskWaiting || string(task.Payload) != `{"source_ref":"objects/recovery"}` {
|
||||
t.Fatal("occurrence/task transaction lost", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user