feat: add durable safe retries with uncertainty tracking and SDK examples
This commit is contained in:
149
sdk/go/examples/retry/main.go
Normal file
149
sdk/go/examples/retry/main.go
Normal file
@@ -0,0 +1,149 @@
|
||||
// retry 演示明确失败的安全重试:同一任务两次尝试,退避由平台持久化管理。
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"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 := "text.normalize.retry." + operation
|
||||
var calls atomic.Uint32
|
||||
process := func(ctx context.Context, e orbit.WorkerExecution) (json.RawMessage, error) {
|
||||
// 第一次在产生业务效果前明确拒绝,可安全重试;计数仅用于制造示例故障。
|
||||
if calls.Add(1) == 1 {
|
||||
return nil, &orbit.WorkerFailure{Code: "TEMPORARY_REJECTION", Retryable: true}
|
||||
}
|
||||
return normalize(ctx, e)
|
||||
}
|
||||
w, 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: process}}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
// 示例先受理,再启动消费者;生产通常是独立的长驻 Worker 进程。
|
||||
var tasks []orbit.Task
|
||||
for i, text := range []string{" safe retry "} {
|
||||
payload, _ := json.Marshal(struct {
|
||||
Text string `json:"text"`
|
||||
}{text})
|
||||
accepted, err := client.SubmitTask(ctx, fmt.Sprintf("retry-%s-%d", operation, i), orbit.SubmitTaskRequest{QueueID: queue, Handler: handler, HandlerVersion: "v1", PayloadVersion: "v1", Payload: payload, TaskTimeoutMS: 60000, Retry: orbit.RetryPolicy{MaxAttempts: 3, AttemptTimeoutMS: 10000, InitialDelayMS: 500, MaxDelayMS: 2000}, RecoverySafety: orbit.RecoveryReconcileOnly})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tasks = append(tasks, accepted.Task)
|
||||
}
|
||||
workerCtx, stop := context.WithCancel(ctx)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- w.Run(workerCtx) }()
|
||||
// 任一调用失败时也等待有界排空,不在 main 返回时静默丢掉执行中的 Worker。
|
||||
waited := false
|
||||
defer func() {
|
||||
stop()
|
||||
if !waited {
|
||||
<-done
|
||||
}
|
||||
}()
|
||||
for _, task := range tasks {
|
||||
for {
|
||||
current, err := client.GetTask(ctx, task.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current.State == orbit.TaskSucceeded {
|
||||
history, err := client.ListTaskAttempts(ctx, current.ID, orbit.HistoryPageRequest{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current.AttemptCount != 2 || len(history.Items) != 2 || history.Items[1].State != orbit.AttemptFailed || !history.Items[1].Retryable || current.EffectUncertain {
|
||||
return errors.New("retry history mismatch")
|
||||
}
|
||||
fmt.Printf("task=%s state=%s attempts=%d first_retryable=%t result=%s\n", current.ID, current.State, current.AttemptCount, history.Items[1].Retryable, current.Result)
|
||||
break
|
||||
}
|
||||
if current.State == orbit.TaskFailed || current.State == orbit.TaskNeedsReview {
|
||||
return fmt.Errorf("task %s requires attention: %s", current.ID, current.State)
|
||||
}
|
||||
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):
|
||||
}
|
||||
}
|
||||
}
|
||||
stop()
|
||||
err = <-done
|
||||
waited = true
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stats := w.Stats()
|
||||
if stats.Started != 2 || stats.ReportsConfirmed != 2 || stats.Running != 0 {
|
||||
return errors.New("unexpected Worker completion counts")
|
||||
}
|
||||
fmt.Printf("worker_drained=true started=%d confirmed=%d running=%d\n", stats.Started, stats.ReportsConfirmed, stats.Running)
|
||||
return nil
|
||||
}
|
||||
func normalize(ctx context.Context, execution orbit.WorkerExecution) (json.RawMessage, error) {
|
||||
var in struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.Unmarshal(execution.Task.Payload, &in); err != nil {
|
||||
return nil, &orbit.WorkerFailure{Code: "INVALID_TEXT"}
|
||||
}
|
||||
// 模拟可取消的短工作;真实 I/O 也必须使用传入的 ctx。
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
return json.Marshal(struct {
|
||||
Text string `json:"text"`
|
||||
}{Text: strings.ToUpper(strings.TrimSpace(in.Text))})
|
||||
}
|
||||
@@ -21,6 +21,7 @@ type AttemptSummary struct {
|
||||
LeaseUntil time.Time `json:"lease_until"` // LeaseUntil 数据库记录的租约期限;查询不能授权业务执行。
|
||||
AuthorizedAt *time.Time `json:"authorized_at,omitempty"` // AuthorizedAt 开始批准时间;空表示未批准,不证明副作用已经发生。
|
||||
EndedAt *time.Time `json:"ended_at,omitempty"` // EndedAt 尝试结束时间;仍在执行时为空。
|
||||
Retryable bool `json:"retryable,omitempty"` // Retryable 该明确失败报告是否声明可安全重试;不代表平台必定安排重试。
|
||||
ErrorCode string `json:"error_code,omitempty"` // ErrorCode 稳定错误分类;无错误时为空。
|
||||
CreatedAt time.Time `json:"created_at"` // CreatedAt 创建时间,UTC 微秒精度。
|
||||
UpdatedAt time.Time `json:"updated_at"` // UpdatedAt 最新变更时间,不能代替版本校验。
|
||||
@@ -47,6 +48,7 @@ type AttemptEvidenceSummary struct {
|
||||
AttemptID string `json:"attempt_id"` // AttemptID 所属执行尝试。
|
||||
Outcome string `json:"outcome"` // Outcome Worker 声明的 SUCCEEDED、FAILED 或 UNKNOWN,不代表平台认可。
|
||||
ErrorCode string `json:"error_code,omitempty"` // ErrorCode 原报告的稳定分类,可为空。
|
||||
Retryable bool `json:"retryable,omitempty"` // Retryable 原报告的重试声明;证据不触发重试。
|
||||
Reason string `json:"reason"` // Reason LEASE_LOST、NOT_STARTED 或 RESULT_CONFLICT。
|
||||
Disposition string `json:"disposition"` // Disposition 固定为 EVIDENCE_ONLY,表示采集时仅保存证据。
|
||||
CreatedAt time.Time `json:"created_at"` // CreatedAt 首次收到时间,重复报告不改变它。
|
||||
|
||||
@@ -54,6 +54,8 @@ type Task struct {
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
// Result 业务 JSON 结果;未取得结果时为空并省略。
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
// EffectUncertain 曾有执行效果尚未核实;重试与预算耗尽不会把未知伪装成明确失败。
|
||||
EffectUncertain bool `json:"effect_uncertain,omitempty"`
|
||||
// BusinessKey 可选业务关联键,不自动提供业务效果幂等。
|
||||
BusinessKey string `json:"business_key,omitempty"`
|
||||
// AvailableAt 最早可执行时间,UTC。
|
||||
@@ -120,6 +122,8 @@ type TaskSummary struct {
|
||||
Handler string `json:"handler"`
|
||||
// HandlerVersion 受理时固定的处理器版本。
|
||||
HandlerVersion string `json:"handler_version"`
|
||||
// EffectUncertain 曾有执行效果尚未核实;重试与预算耗尽不会把未知伪装成明确失败。
|
||||
EffectUncertain bool `json:"effect_uncertain,omitempty"`
|
||||
// BusinessKey 可选业务关联键,不自动提供业务效果幂等。
|
||||
BusinessKey string `json:"business_key,omitempty"`
|
||||
// AvailableAt 最早可执行时间,UTC。
|
||||
|
||||
@@ -332,6 +332,9 @@ func (w *WorkerConnection) Renew(ctx context.Context, id string, ref WorkerAttem
|
||||
// Finish 保存业务结果/证据;迟到结果可返回 EVIDENCE_ONLY,不能视为任务成功终结。
|
||||
func (w *WorkerConnection) Finish(ctx context.Context, id string, in WorkerFinish) (WorkerCommandResult, error) {
|
||||
var out WorkerCommandResult
|
||||
if in.Retryable && in.Outcome != WorkerFailed {
|
||||
return out, errors.New("retryable is only valid for known failure")
|
||||
}
|
||||
if (in.Outcome != WorkerSucceeded && in.Outcome != WorkerFailed && in.Outcome != WorkerUnknown) || len(in.Result) > 256<<10 || bytes.Equal(bytes.TrimSpace(in.Result), []byte("null")) || validateWorkerJSON(in.Result) != nil {
|
||||
return out, errors.New("invalid Worker result outcome or JSON")
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ func (w *Worker) invoke(ctx context.Context, handler WorkerHandler, execution Wo
|
||||
details = json.RawMessage(`{}`)
|
||||
}
|
||||
if validHandlerResult(details) {
|
||||
out = WorkerFinish{Ref: ref, Outcome: WorkerFailed, ErrorCode: known.Code, Result: details}
|
||||
out = WorkerFinish{Ref: ref, Outcome: WorkerFailed, ErrorCode: known.Code, Result: details, Retryable: known.Retryable}
|
||||
} else {
|
||||
out = unknownHandlerResult(ref, "INVALID_HANDLER_RESULT")
|
||||
}
|
||||
|
||||
104
sdk/go/worker_retry_integration_test.go
Normal file
104
sdk/go/worker_retry_integration_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package orbit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestIntegrationWorkerRuntimeSafeRetry(t *testing.T) {
|
||||
api, queue, token := integrationWorkerClient(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 35*time.Second)
|
||||
defer cancel()
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
safety RecoverySafety
|
||||
final TaskState
|
||||
calls int32
|
||||
unknown bool
|
||||
}{
|
||||
{"known", RecoveryReconcileOnly, TaskSucceeded, 2, false},
|
||||
{"unknown_idempotent", RecoveryIdempotent, TaskSucceeded, 2, true},
|
||||
{"unknown_reconcile", RecoveryReconcileOnly, TaskNeedsReview, 1, true},
|
||||
{"exhausted", RecoveryReconcileOnly, TaskFailed, 2, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
g := newWorkerFaultGateway(t, api.baseURL)
|
||||
// 首次失败已落库并安排重试后丢确认,运行器必须核对同一回执而不是重置尝试。
|
||||
if tc.name == "known" {
|
||||
g.drop.Store("task.finish")
|
||||
}
|
||||
client, err := NewClient(g.server.URL, Options{Token: token, HTTPClient: g.server.Client()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name := "retry." + tc.name + "." + workerTestID(t)
|
||||
var calls atomic.Int32
|
||||
var epochs [2]uint64
|
||||
w, err := NewWorker(client, WorkerOptions{QueueID: queue, Capacity: 1, Handlers: []WorkerHandler{runtimeHandler(name, func(_ context.Context, e WorkerExecution) (json.RawMessage, error) {
|
||||
n := calls.Add(1)
|
||||
if n > 2 {
|
||||
return nil, errors.New("retry exceeded test budget")
|
||||
}
|
||||
epochs[n-1] = e.Epoch
|
||||
if n == 1 || tc.name == "exhausted" {
|
||||
if tc.unknown {
|
||||
return nil, errors.New("simulated unknown outcome")
|
||||
}
|
||||
return nil, &WorkerFailure{Code: "TEMPORARY_REJECTION", Retryable: true, Details: json.RawMessage(`{"reason_ref":"diagnostics/temporary"}`)}
|
||||
}
|
||||
return json.RawMessage(`{"n":9007199254740993}`), nil
|
||||
})}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
accepted, err := api.SubmitTask(ctx, "retry-"+workerTestID(t), SubmitTaskRequest{QueueID: queue, Handler: name, HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{}`), RecoverySafety: tc.safety, TaskTimeoutMS: 30000, Retry: RetryPolicy{MaxAttempts: 2, AttemptTimeoutMS: 5000, InitialDelayMS: 200, MaxDelayMS: 1000}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run, stop := context.WithCancel(ctx)
|
||||
defer stop()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- w.Run(run) }()
|
||||
current := waitRuntimeState(t, ctx, api, accepted.Task.ID, tc.final)
|
||||
stop()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("retry Worker did not drain")
|
||||
}
|
||||
if calls.Load() != tc.calls || current.AttemptCount != uint32(tc.calls) || w.Stats().Started != uint64(tc.calls) || epochs[0] != 1 || (tc.calls == 2 && epochs[1] != 2) {
|
||||
t.Fatal("retry bypassed attempt identity or budget")
|
||||
}
|
||||
if current.EffectUncertain != (tc.final == TaskNeedsReview) {
|
||||
t.Fatal("uncertainty does not match authoritative result")
|
||||
}
|
||||
attempts, err := api.ListTaskAttempts(ctx, current.ID, HistoryPageRequest{})
|
||||
if err != nil || len(attempts.Items) != int(tc.calls) {
|
||||
t.Fatal("SDK lost retry history")
|
||||
}
|
||||
oldest := attempts.Items[len(attempts.Items)-1]
|
||||
if oldest.Retryable == tc.unknown {
|
||||
t.Fatal("SDK lost retry classification")
|
||||
}
|
||||
if tc.calls == 2 {
|
||||
second := attempts.Items[0]
|
||||
if oldest.EndedAt == nil || second.CreatedAt.Before(oldest.EndedAt.Add(100*time.Millisecond)) {
|
||||
t.Fatal("dispatch ignored retry delay")
|
||||
}
|
||||
}
|
||||
if tc.name == "known" {
|
||||
g.expectDrop(t, "task.finish")
|
||||
if w.Stats().Reconnects == 0 {
|
||||
t.Fatal("retry receipt did not recover lost ACK")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -34,8 +34,9 @@ type WorkerHandler struct {
|
||||
|
||||
// WorkerFailure 仅用于接入方能够确认的业务失败,不用于网络超时或未知外部效果。
|
||||
type WorkerFailure struct {
|
||||
Code string // Code 1–64 位稳定 ASCII 分类,禁止放原始异常正文。
|
||||
Details json.RawMessage // Details 可选非 null JSON,最多 256 KiB;空值使用空对象。
|
||||
Code string // Code 1–64 位稳定 ASCII 分类,禁止放原始异常正文。
|
||||
Details json.RawMessage // Details 可选非 null JSON,最多 256 KiB;空值使用空对象。
|
||||
Retryable bool // Retryable 声明该明确失败可安全重试;默认假,不能用它掩盖网络超时或部分副作用未知。
|
||||
}
|
||||
|
||||
func (e *WorkerFailure) Error() string { return "Worker handler reported a known failure" }
|
||||
|
||||
@@ -97,9 +97,10 @@ type WorkerFinish struct {
|
||||
Outcome WorkerOutcome `json:"outcome"` // Outcome 明确成功、明确失败或效果未知。
|
||||
Result json.RawMessage `json:"result"` // Result 非 null JSON,最多 256 KiB;保留精确数字文本。
|
||||
ErrorCode string `json:"error_code,omitempty"` // ErrorCode 失败/未知时必填稳定分类,成功时为空;禁止原始异常正文。
|
||||
Retryable bool `json:"retryable,omitempty"` // Retryable 仅明确 FAILED 可声明安全重试;默认假,不适用于 UNKNOWN。
|
||||
}
|
||||
|
||||
// WorkerOutcome 描述业务效果;UNKNOWN 必须进入核查而不是盲目重跑。
|
||||
// WorkerOutcome 描述业务效果;UNKNOWN 仅在任务声明幂等恢复且预算足够时可重试,否则转核查。
|
||||
type WorkerOutcome string
|
||||
|
||||
const (
|
||||
|
||||
Reference in New Issue
Block a user