feat: add durable schedule management and multi-node crash recovery

This commit is contained in:
2026-09-17 16:28:58 +08:00
parent d65cf96ea7
commit 6805506450
43 changed files with 2952 additions and 29 deletions

View 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
}

View 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
View 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
View 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
}

View File

@@ -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)
}
}
}