feat: add idempotent batch task submission and recovery examples

This commit is contained in:
2026-09-17 21:21:18 +08:00
parent a8a8dcfe1e
commit 571107f96d
27 changed files with 1375 additions and 24 deletions

121
sdk/go/batch.go Normal file
View File

@@ -0,0 +1,121 @@
package orbit
import (
"context"
"errors"
"time"
)
// MaxBatchTasks 是单次批量受理的项数上限,总请求还受512 KiB限制。
const MaxBatchTasks = 32
type SubmitTasksRequest struct {
// Items 1至32项独立任务,总请求不超过512 KiB,不保证整批原子性。
Items []SubmitTasksItem `json:"items"`
}
type SubmitTasksItem struct {
// IdempotencyKey 调用方持久化的稳定键,与SubmitTask共用幂等身份;批内不得重复。
IdempotencyKey string `json:"idempotency_key"`
// Task 原始任务参数,重报必须保留原值。
Task SubmitTaskRequest `json:"task"`
}
type BatchItemStatus string
const (
BatchAccepted BatchItemStatus = "ACCEPTED"
BatchRejected BatchItemStatus = "REJECTED"
BatchUnknown BatchItemStatus = "UNKNOWN"
BatchNotAttempted BatchItemStatus = "NOT_ATTEMPTED"
)
type BatchTaskReceipt struct {
// TaskID 已受理任务标识,实际结果需要查询或订阅。
TaskID string `json:"task_id"`
// QueueID 任务所属队列。
QueueID string `json:"queue_id"`
// Revision 原始受理版本,JSON为十进制字符串,不代表当前版本。
Revision uint64 `json:"revision,string"`
// CreatedAt 原始受理时间,UTC微秒精度。
CreatedAt time.Time `json:"created_at"`
// Deduplicated 是否复用相同身份的已有回执。
Deduplicated bool `json:"deduplicated"`
}
type BatchItemError struct {
// Code 稳定错误码;UNKNOWN结果使用UNAVAILABLE,不证明回滚。
Code string `json:"code"`
// Message 不包含数据库错误或输入的对外说明。
Message string `json:"message"`
// RetryAfterMS 建议最小退避毫秒数,零为未指定,SDK不自动重试。
RetryAfterMS uint32 `json:"retry_after_ms,omitempty"`
}
type SubmitTasksItemResult struct {
// Index 本次请求内从0开始的索引,与Items输入同序。
Index int `json:"index"`
// Status 每项独立状态;HTTP成功不表示全部任务已受理。
Status BatchItemStatus `json:"status"`
// Receipt 仅ACCEPTED存在,省略大参数以保证回执大小有界。
Receipt *BatchTaskReceipt `json:"receipt,omitempty"`
// Error 仅非ACCEPTED存在,调用方需分别处理明确拒绝、未知及未尝试。
Error *BatchItemError `json:"error,omitempty"`
}
type SubmitTasksResult struct {
// Items 与输入数量和顺序一致的逐项结果。
Items []SubmitTasksItemResult `json:"items"`
}
// SubmitTasks 逐项提交独立任务。返回error时整批可能已部分或全部受理,必须保留原键及参数核对。
// error为nil时仍需检查每项Status。此方法不会自动重试、拆批、生成新键或启动并发请求。
func (c *Client) SubmitTasks(ctx context.Context, in SubmitTasksRequest) (SubmitTasksResult, error) {
if len(in.Items) < 1 || len(in.Items) > MaxBatchTasks {
return SubmitTasksResult{}, errors.New("items must contain 1 to 32 tasks")
}
keys := make(map[string]struct{}, len(in.Items))
for _, item := range in.Items {
if err := validateIdempotencyKey(item.IdempotencyKey); err != nil {
return SubmitTasksResult{}, err
}
if _, exists := keys[item.IdempotencyKey]; exists {
return SubmitTasksResult{}, errors.New("item idempotency keys must be distinct within a batch")
}
keys[item.IdempotencyKey] = struct{}{}
}
var out SubmitTasksResult
if err := c.callJSON(ctx, "POST", "/v1/tasks:batch", "", in, &out); err != nil {
return SubmitTasksResult{}, err
}
// 拒绝不完整或错误关联的回执,避免调用方将截断的成功响应误认为全部已受理。
if len(out.Items) != len(in.Items) {
return SubmitTasksResult{}, errors.New("invalid Orbit batch result count")
}
for i, item := range out.Items {
valid := item.Index == i
switch item.Status {
case BatchAccepted:
r := item.Receipt
valid = valid && item.Error == nil && r != nil && validID(r.TaskID) && r.QueueID == in.Items[i].Task.QueueID && validID(r.QueueID) && r.Revision > 0 && !r.CreatedAt.IsZero()
case BatchRejected, BatchUnknown, BatchNotAttempted:
e := item.Error
valid = valid && item.Receipt == nil && e != nil && len(e.Code) > 0 && len(e.Code) <= 64 && len(e.Message) > 0 && len(e.Message) <= 1024 && e.RetryAfterMS <= 3600000
if valid && item.Status == BatchUnknown {
valid = e.Code == "UNAVAILABLE"
}
if valid && item.Status == BatchNotAttempted {
valid = e.Code == "BATCH_STOPPED"
}
if valid && item.Status == BatchRejected {
valid = e.Code != "UNAVAILABLE" && e.Code != "BATCH_STOPPED"
}
default:
valid = false
}
if !valid {
return SubmitTasksResult{}, errors.New("invalid Orbit batch item result; retry using original identities")
}
}
return out, nil
}

View File

@@ -0,0 +1,107 @@
package orbit
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/url"
"strings"
"sync/atomic"
"testing"
"time"
)
func TestIntegrationTaskBatchSDK(t *testing.T) {
api, _, _ := integrationWorkerClient(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
created, err := api.CreateQueue(ctx, workerTestID(t), CreateQueueRequest{Name: "batch." + workerTestID(t), MaxConcurrency: 2, RatePerSecond: 10, MaxUnfinishedTasks: 2})
if err != nil {
t.Fatal(err)
}
q := created.Queue
if _, err := api.ConfigureQueue(ctx, q.ID, workerTestID(t), ConfigureQueueRequest{ExpectedRevision: q.Revision, Reason: "batch acceptance regression", Paused: true, MaxConcurrency: 2, RatePerSecond: 10, MaxUnfinishedTasks: 2}); err != nil {
t.Fatal(err)
}
input := SubmitTaskRequest{QueueID: q.ID, Handler: "file.convert", HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{"n":9007199254740993}`)}
in := SubmitTasksRequest{Items: []SubmitTasksItem{{IdempotencyKey: workerTestID(t), Task: input}, {IdempotencyKey: workerTestID(t), Task: input}, {IdempotencyKey: workerTestID(t), Task: input}}}
// 真实HTTP代理先完整读到数据库受理回执,再强制断开客户端连接,丢弃整批响应。
target, _ := url.Parse(api.baseURL)
proxy := httputil.NewSingleHostReverseProxy(target)
var dropped atomic.Bool
var calls atomic.Int32
proxy.ModifyResponse = func(r *http.Response) error {
if r.Request.URL.Path != "/v1/tasks:batch" {
return nil
}
calls.Add(1)
if dropped.CompareAndSwap(false, true) {
raw, err := io.ReadAll(r.Body)
r.Body.Close()
if err != nil {
return err
}
if r.StatusCode != 200 || !strings.Contains(string(raw), `"ACCEPTED"`) || !strings.Contains(string(raw), `"QUEUE_FULL"`) {
t.Error("unexpected pre-drop server result", string(raw))
}
return io.ErrUnexpectedEOF
}
return nil
}
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
conn, _, hijackErr := w.(http.Hijacker).Hijack()
if hijackErr == nil {
conn.Close()
}
}
server := httptest.NewServer(proxy)
defer server.Close()
client, err := NewClient(server.URL, Options{Token: api.token})
if err != nil {
t.Fatal(err)
}
if _, err := client.SubmitTasks(ctx, in); err == nil || !dropped.Load() || calls.Load() != 1 {
t.Fatal("response loss not surfaced without silent retry", err)
}
// 原连接已关闭;用原键和原内容核对,前两项去重,满额项保持明确拒绝。
out, err := client.SubmitTasks(ctx, in)
if err != nil || len(out.Items) != 3 {
t.Fatal(out, err)
}
for i := 0; i < 2; i++ {
if out.Items[i].Status != BatchAccepted || !out.Items[i].Receipt.Deduplicated {
t.Fatal("lost response duplicated task", out)
}
}
if out.Items[2].Status != BatchRejected || out.Items[2].Error.Code != "QUEUE_FULL" || out.Items[2].Error.RetryAfterMS != 1000 {
t.Fatal(out)
}
page, err := api.ListTasks(ctx, ListTasksRequest{QueueID: q.ID})
if err != nil || len(page.Items) != 2 {
t.Fatal("duplicate durable rows", page, err)
}
single, err := api.SubmitTask(ctx, in.Items[0].IdempotencyKey, input)
if err != nil || !single.Deduplicated || single.Task.ID != out.Items[0].Receipt.TaskID || string(single.Task.Payload) != `{"n":9007199254740993}` {
t.Fatal("batch/single mismatch", err)
}
if _, err := api.CancelTask(ctx, single.Task.ID, workerTestID(t), CancelTaskRequest{ExpectedRevision: single.Task.Revision, Reason: "free slot for partial retry"}); err != nil {
t.Fatal(err)
}
suffix, err := api.SubmitTasks(ctx, SubmitTasksRequest{Items: in.Items[2:]})
if err != nil || suffix.Items[0].Status != BatchAccepted || suffix.Items[0].Receipt.Deduplicated || suffix.Items[0].Index != 0 {
t.Fatal(suffix, err)
}
for _, id := range []string{out.Items[1].Receipt.TaskID, suffix.Items[0].Receipt.TaskID} {
current, err := api.GetTask(ctx, id)
if err != nil {
t.Fatal(err)
}
if _, err := api.CancelTask(ctx, id, workerTestID(t), CancelTaskRequest{ExpectedRevision: current.Revision, Reason: "finish batch regression"}); err != nil {
t.Fatal(err)
}
}
t.Log("lost real HTTP response: two committed tasks recovered by identity, no duplicates; backpressured suffix independently accepted after capacity released")
}

75
sdk/go/batch_test.go Normal file
View File

@@ -0,0 +1,75 @@
package orbit
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
)
func TestBatchSDKWireValidationAndNoAutomaticRetry(t *testing.T) {
id := strings.Repeat("1", 32)
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
if r.URL.Path != "/v1/tasks:batch" || r.Method != "POST" || r.Header.Get("Authorization") != "Bearer test-token" || r.Header.Get("Idempotency-Key") != "" {
t.Error("invalid batch transport")
}
raw, _ := io.ReadAll(r.Body)
if !strings.Contains(string(raw), `"idempotency_key":"one"`) || !strings.Contains(string(raw), `9007199254740993`) {
t.Error("identity/precision lost")
}
if calls.Load() == 1 {
w.WriteHeader(503)
fmt.Fprintf(w, `{"request_id":%q,"error":{"code":"UNAVAILABLE","message":"retry original identities"}}`, id)
return
}
fmt.Fprintf(w, `{"request_id":%q,"data":{"items":[{"index":0,"status":"ACCEPTED","receipt":{"task_id":%q,"queue_id":%q,"revision":"9007199254740993","created_at":"2026-09-17T00:00:00Z","deduplicated":true}}]}}`, id, id, id)
}))
defer srv.Close()
c, _ := NewClient(srv.URL, Options{Token: "test-token"})
in := SubmitTasksRequest{Items: []SubmitTasksItem{{IdempotencyKey: "one", Task: SubmitTaskRequest{QueueID: id, Payload: json.RawMessage(`{"n":9007199254740993}`)}}}}
if _, err := c.SubmitTasks(context.Background(), in); err == nil || calls.Load() != 1 {
t.Fatal("request silently retried", err)
}
out, err := c.SubmitTasks(context.Background(), in)
if err != nil || out.Items[0].Receipt.Revision != 9007199254740993 || !out.Items[0].Receipt.Deduplicated {
t.Fatal(out, err)
}
for _, bad := range []SubmitTasksRequest{{}, {Items: make([]SubmitTasksItem, 33)}, {Items: []SubmitTasksItem{in.Items[0], in.Items[0]}}, {Items: []SubmitTasksItem{{IdempotencyKey: " "}}}} {
if _, err := c.SubmitTasks(context.Background(), bad); err == nil {
t.Fatal("accepted invalid batch")
}
}
in.Items[0].Task.Payload = json.RawMessage(`"` + strings.Repeat("x", 512<<10) + `"`)
if _, err := c.SubmitTasks(context.Background(), in); err == nil || calls.Load() != 2 {
t.Fatal("unbounded batch reached network")
}
}
func TestBatchSDKRejectsIncompleteAndContradictoryReceipts(t *testing.T) {
id := strings.Repeat("1", 32)
for _, data := range []string{
`{"items":[]}`,
`{"items":[{"index":1,"status":"REJECTED","error":{"code":"INVALID_ARGUMENT","message":"bad"}}]}`,
`{"items":[{"index":0,"status":"UNKNOWN","error":{"code":"INVALID_ARGUMENT","message":"bad"}}]}`,
`{"items":[{"index":0,"status":"REJECTED","error":{"code":"UNAVAILABLE","message":"unknown"}}]}`,
`{"items":[{"index":0,"status":"NOT_ATTEMPTED","error":{"code":"UNAVAILABLE","message":"bad"}}]}`,
`{"items":[{"index":0,"status":"ACCEPTED"}]}`,
`{"items":[{"index":0,"status":"REJECTED","receipt":{},"error":{"code":"QUEUE_FULL","message":"full"}}]}`,
`{"items":[{"index":0,"status":"NEW_STATUS"}]}`,
} {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, `{"request_id":%q,"data":%s}`, id, data) }))
c, _ := NewClient(srv.URL, Options{Token: "test-token"})
out, err := c.SubmitTasks(context.Background(), SubmitTasksRequest{Items: []SubmitTasksItem{{IdempotencyKey: "one", Task: SubmitTaskRequest{QueueID: id}}}})
srv.Close()
if err == nil || len(out.Items) != 0 {
t.Fatal("malformed response exposed as success", out, data)
}
}
}

View File

@@ -1,6 +1,7 @@
// Package orbit 是 Orbit 的独立 Go SDK,提供健康、任务、队列、计划及结果订阅。
// WorkerConnection 是底层接口;NewWorker/Run 自动管理处理器、续租、恢复与排空。
// EventConnection 是结果交付协议;EventConsumer 自动管理心跳、重连与确认,业务端负责事务去重。
// SubmitTasks 提供有界批量受理,每项沿用独立稳定身份并返回可核对的受理状态。
// 流程 SDK 等后续能力按阶段继续开发。
// SDK 不导入服务端模型、迁移或数据库依赖。
package orbit

View File

@@ -0,0 +1,122 @@
// batch 演示批量部分受理、稳定身份核对和释放配额后仅补交失败项。
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"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 := os.Getenv("ORBIT_TOKEN")
if token == "" && os.Getenv("ORBIT_CREDENTIALS_FILE") != "" {
raw, err := os.ReadFile(os.Getenv("ORBIT_CREDENTIALS_FILE"))
if err != nil {
return fmt.Errorf("read credentials: %w", err)
}
var c struct {
Token string `json:"token"`
}
if json.Unmarshal(raw, &c) != nil {
return fmt.Errorf("invalid credentials file")
}
token = c.Token
}
api, err := orbit.NewClient(os.Getenv("ORBIT_URL"), orbit.Options{Token: token})
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
var random [16]byte
if _, err := rand.Read(random[:]); err != nil {
return err
}
key := "batch-example-" + hex.EncodeToString(random[:])
q, err := api.CreateQueue(ctx, key+"-queue", orbit.CreateQueueRequest{Name: key, MaxConcurrency: 2, RatePerSecond: 10, MaxUnfinishedTasks: 2})
if err != nil {
return err
}
if _, err := api.ConfigureQueue(ctx, q.Queue.ID, key+"-pause", orbit.ConfigureQueueRequest{ExpectedRevision: q.Queue.Revision, Reason: "isolated batch acceptance example", Paused: true, MaxConcurrency: 2, RatePerSecond: 10, MaxUnfinishedTasks: 2}); err != nil {
return err
}
request := orbit.SubmitTasksRequest{}
// 演示运行生成独立身份;生产必须先将每项键与完整参数持久化到业务库,再发送。
for i := 0; i < 3; i++ {
payload := json.RawMessage(fmt.Sprintf(`{"source_ref":"objects/input-%d.txt","sequence":9007199254740993}`, i))
request.Items = append(request.Items, orbit.SubmitTasksItem{IdempotencyKey: fmt.Sprintf("%s-%d", key, i), Task: orbit.SubmitTaskRequest{QueueID: q.Queue.ID, Handler: "file.convert", HandlerVersion: "v1", PayloadVersion: "v1", Payload: payload, TaskTimeoutMS: 60000}})
}
first, err := api.SubmitTasks(ctx, request)
if err != nil {
return err
}
if first.Items[0].Status != orbit.BatchAccepted || first.Items[1].Status != orbit.BatchAccepted || first.Items[2].Status != orbit.BatchRejected || first.Items[2].Error.Code != "QUEUE_FULL" {
return fmt.Errorf("unexpected partial acceptance")
}
fmt.Println("accepted=2 rejected=1 code=QUEUE_FULL")
replay, err := api.SubmitTasks(ctx, request)
if err != nil {
return err
}
for i := 0; i < 2; i++ {
if replay.Items[i].Status != orbit.BatchAccepted || !replay.Items[i].Receipt.Deduplicated || replay.Items[i].Receipt.TaskID != first.Items[i].Receipt.TaskID {
return fmt.Errorf("batch replay changed identity")
}
}
if replay.Items[2].Status != orbit.BatchRejected || replay.Items[2].Error.Code != "QUEUE_FULL" {
return fmt.Errorf("backpressure changed unexpectedly")
}
fmt.Println("replayed=2 same_task_ids=true")
cancelTask := func(id string) error {
current, err := api.GetTask(ctx, id)
if err != nil {
return err
}
out, err := api.CancelTask(ctx, id, key+"-cancel-"+id, orbit.CancelTaskRequest{ExpectedRevision: current.Revision, Reason: "finish batch example"})
if err != nil {
return err
}
if out.Task.State != orbit.TaskCancelled {
return fmt.Errorf("expected confirmed cancellation")
}
return nil
}
if err := cancelTask(first.Items[0].Receipt.TaskID); err != nil {
return err
}
// 子批次索引重新从0开始;键和参数仍来自最初被拒项,不重新生成。
retry, err := api.SubmitTasks(ctx, orbit.SubmitTasksRequest{Items: request.Items[2:]})
if err != nil {
return err
}
if retry.Items[0].Status != orbit.BatchAccepted || retry.Items[0].Receipt.Deduplicated {
return fmt.Errorf("failed item was not independently accepted")
}
fmt.Println("retried_items=1 same_identity=true accepted=true")
for _, id := range []string{first.Items[1].Receipt.TaskID, retry.Items[0].Receipt.TaskID} {
if err := cancelTask(id); err != nil {
return err
}
}
final, err := api.GetQueue(ctx, q.Queue.ID)
if err != nil {
return err
}
if final.UnfinishedTasks != 0 {
return fmt.Errorf("unfinished tasks remain")
}
fmt.Printf("queue=%s paused=%t unfinished=%d\n", final.ID, final.Paused, final.UnfinishedTasks)
return nil
}

View File

@@ -93,19 +93,31 @@ func validID(id string) bool {
return true
}
func (c *Client) call(ctx context.Context, method, path, key string, in, out any) error {
if method != "GET" {
if err := validateIdempotencyKey(key); err != nil {
return err
}
}
return c.callJSON(ctx, method, path, key, in, out)
}
func validateIdempotencyKey(key string) error {
if len(key) < 1 || len(key) > 128 {
return errors.New("a stable Idempotency-Key of 1 to 128 ASCII characters is required")
}
for _, ch := range key {
if ch < 33 || ch > 126 {
return errors.New("Idempotency-Key must not contain spaces or control characters")
}
}
return nil
}
// callJSON 仅执行有界HTTP交换;写入调用者负责校验整项或批量内的独立幂等键。
func (c *Client) callJSON(ctx context.Context, method, path, key string, in, out any) error {
if c.token == "" {
return errors.New("Orbit token is required for business APIs")
}
if method != "GET" {
if len(key) < 1 || len(key) > 128 {
return errors.New("a stable Idempotency-Key of 1 to 128 ASCII characters is required")
}
for _, ch := range key {
if ch < 33 || ch > 126 {
return errors.New("Idempotency-Key must not contain spaces or control characters")
}
}
}
var body []byte
var err error
if in != nil {