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
}