feat: add execution history queries and preserve bounded JSON payloads

This commit is contained in:
2026-09-17 14:55:23 +08:00
parent da123bba69
commit 4d4b2e9f7d
32 changed files with 1461 additions and 28 deletions

View File

@@ -0,0 +1,151 @@
// history 创建一次受控的冲突报告,演示通过公开查询区分权威结果和追加证据。
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 c struct {
Token string `json:"token"`
QueueID string `json:"queue_id"`
}
if json.Unmarshal(raw, &c) != nil {
return errors.New("invalid credentials file")
}
if token == "" {
token = c.Token
}
if queue == "" {
queue = c.QueueID
}
}
client, err := orbit.NewClient(os.Getenv("ORBIT_URL"), orbit.Options{Token: token})
if err != nil {
return err
}
op, err := orbit.NewWorkerMessageID()
if err != nil {
return err
}
handler := "history.inspect." + op
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
accepted, err := client.SubmitTask(ctx, "history-"+op, orbit.SubmitTaskRequest{QueueID: queue, Handler: handler, HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{"object_ref":"examples/input"}`), TaskTimeoutMS: 60000, Retry: orbit.RetryPolicy{MaxAttempts: 1, AttemptTimeoutMS: 10000}})
if err != nil {
return err
}
registration, err := orbit.NewWorkerRegistration(queue, 1, []orbit.WorkerCapability{{Handler: handler, HandlerVersion: "v1", PayloadVersion: "v1"}})
if err != nil {
return err
}
w, err := client.DialWorker(ctx, registration)
if err != nil {
return err
}
defer w.Close()
nextID := func() (string, error) { return orbit.NewWorkerMessageID() }
id, err := nextID()
if err != nil {
return err
}
if _, err := w.Capacity(ctx, id, orbit.WorkerCapacity{Sequence: 1, Capacity: 1}); err != nil {
return err
}
a, err := w.NextAssignment(ctx)
if err != nil {
return err
}
if a.Task.ID != accepted.Task.ID {
return errors.New("unexpected task")
}
id, err = nextID()
if err != nil {
return err
}
started, err := w.Start(ctx, id, a.Attempt())
if err != nil {
return err
}
if _, active := started.Lease.ExecutionDeadline(); !active {
return errors.New("missing current execution authority")
}
// 这是故障演示,不对外部系统执行操作;先提交固定的模拟处理结果。
id, err = nextID()
if err != nil {
return err
}
original := json.RawMessage(`{"version":"v1","sequence":9007199254740993}`)
if _, err := w.Finish(ctx, id, orbit.WorkerFinish{Ref: a.Attempt(), Outcome: orbit.WorkerSucceeded, Result: original}); err != nil {
return err
}
// 使用另一消息身份模拟矛盾回报;只能追加证据,不能覆盖先前权威结果。
id, err = nextID()
if err != nil {
return err
}
conflict, err := w.Finish(ctx, id, orbit.WorkerFinish{Ref: a.Attempt(), Outcome: orbit.WorkerSucceeded, Result: json.RawMessage(`{"version":"v2"}`)})
if err != nil {
return err
}
if conflict.Receipt.Disposition != "EVIDENCE_ONLY" {
return errors.New("conflicting report was not isolated as evidence")
}
attempts, err := client.ListTaskAttempts(ctx, a.Task.ID, orbit.HistoryPageRequest{Limit: 20})
if err != nil {
return err
}
if len(attempts.Items) != 1 {
return errors.New("expected one attempt")
}
detail, err := client.GetTaskAttempt(ctx, a.Task.ID, attempts.Items[0].ID)
if err != nil {
return err
}
evidence, err := client.ListAttemptEvidence(ctx, a.Task.ID, detail.ID, orbit.HistoryPageRequest{Limit: 20})
if err != nil {
return err
}
if len(evidence.Items) != 1 {
return errors.New("expected one evidence record")
}
item, err := client.GetAttemptEvidence(ctx, a.Task.ID, detail.ID, evidence.Items[0].ID)
if err != nil {
return err
}
current, err := client.GetTask(ctx, a.Task.ID)
if err != nil {
return err
}
if current.State != orbit.TaskSucceeded || string(current.Result) != string(original) || item.Reason != "RESULT_CONFLICT" {
return errors.New("history did not preserve authoritative outcome")
}
id, err = nextID()
if err != nil {
return err
}
if _, err := w.Capacity(ctx, id, orbit.WorkerCapacity{Sequence: 2, Capacity: 0, Drain: true}); err != nil {
return err
}
fmt.Printf("task=%s state=%s attempts=%d evidence=%d reason=%s disposition=%s\n", current.ID, current.State, len(attempts.Items), len(evidence.Items), item.Reason, item.Disposition)
return nil
}

95
sdk/go/history.go Normal file
View File

@@ -0,0 +1,95 @@
package orbit
import (
"context"
"errors"
"net/url"
"strconv"
)
func historyResourcePath(ids ...string) (string, error) {
for _, id := range ids {
if !validID(id) {
return "", errors.New("invalid history resource id")
}
}
path := "/v1/tasks/" + ids[0] + "/attempts"
if len(ids) > 1 {
path += "/" + ids[1] + "/evidence"
}
if len(ids) > 2 {
path += "/" + ids[2]
}
return path, nil
}
func historyPageQuery(in HistoryPageRequest) (string, error) {
if in.Limit < 0 || in.Limit > 100 || len(in.Cursor) > 1024 {
return "", errors.New("invalid history limit or cursor")
}
q := url.Values{}
if in.Limit > 0 {
q.Set("limit", strconv.Itoa(in.Limit))
}
if in.Cursor != "" {
q.Set("cursor", in.Cursor)
}
if len(q) == 0 {
return "", nil
}
return "?" + q.Encode(), nil
}
// ListTaskAttempts 按执行代次降序读取摘要;当前查询不会授予执行权。
func (c *Client) ListTaskAttempts(ctx context.Context, taskID string, in HistoryPageRequest) (AttemptPage, error) {
var out AttemptPage
path, err := historyResourcePath(taskID)
if err != nil {
return out, err
}
query, err := historyPageQuery(in)
if err != nil {
return out, err
}
err = c.call(ctx, "GET", path+query, "", nil, &out)
return out, err
}
// GetTaskAttempt 获取当前归属及已保存结果,不提供执行秘密或执行授权。
func (c *Client) GetTaskAttempt(ctx context.Context, taskID, attemptID string) (Attempt, error) {
var out Attempt
if !validID(attemptID) {
return out, errors.New("invalid attempt id")
}
path, err := historyResourcePath(taskID)
if err != nil {
return out, err
}
err = c.call(ctx, "GET", path+"/"+attemptID, "", nil, &out)
return out, err
}
// ListAttemptEvidence 列出原先仅保存为证据的报告,不把声明结果提升为权威结果。
func (c *Client) ListAttemptEvidence(ctx context.Context, taskID, attemptID string, in HistoryPageRequest) (AttemptEvidencePage, error) {
var out AttemptEvidencePage
path, err := historyResourcePath(taskID, attemptID)
if err != nil {
return out, err
}
query, err := historyPageQuery(in)
if err != nil {
return out, err
}
err = c.call(ctx, "GET", path+query, "", nil, &out)
return out, err
}
// GetAttemptEvidence 读取单条原报告正文,不改变任务或尝试的权威状态。
func (c *Client) GetAttemptEvidence(ctx context.Context, taskID, attemptID, evidenceID string) (AttemptEvidence, error) {
var out AttemptEvidence
path, err := historyResourcePath(taskID, attemptID, evidenceID)
if err != nil {
return out, err
}
err = c.call(ctx, "GET", path, "", nil, &out)
return out, err
}

View File

@@ -0,0 +1,81 @@
package orbit
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
)
func TestIntegrationWorkerConnectionHistoryAndPayloadBounds(t *testing.T) {
client, queue, _ := integrationWorkerClient(t)
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
handler := "history.bounds." + workerTestID(t)
// 原先默认 HTML 转义会把这份约 240 KiB 文档扩大到约 1.4 MiB。
payload := json.RawMessage(`{"text":"` + strings.Repeat("<>&", 80000) + `","n":9007199254740993}`)
in := SubmitTaskRequest{QueueID: queue, Handler: handler, HandlerVersion: "v1", PayloadVersion: "v1", Payload: payload, TaskTimeoutMS: 60000, Retry: RetryPolicy{MaxAttempts: 1, AttemptTimeoutMS: 30000}}
key := "history-bounds-" + workerTestID(t)
first, err := client.SubmitTask(ctx, key, in)
if err != nil {
t.Fatal(err)
}
again, err := client.SubmitTask(ctx, key, in)
if err != nil {
t.Fatal(err)
}
if !again.Deduplicated || again.Task.ID != first.Task.ID || string(again.Task.Payload) != string(payload) {
t.Fatal("durable receipt inflated or altered payload")
}
r, err := NewWorkerRegistration(queue, 1, []WorkerCapability{{Handler: handler, HandlerVersion: "v1", PayloadVersion: "v1"}})
if err != nil {
t.Fatal(err)
}
w, err := client.DialWorker(ctx, r)
if err != nil {
t.Fatal(err)
}
defer w.Close()
if _, err := w.Capacity(ctx, workerTestID(t), WorkerCapacity{Sequence: 1, Capacity: 1}); err != nil {
t.Fatal(err)
}
assigned, err := w.NextAssignment(ctx)
if err != nil {
t.Fatal(err)
}
if assigned.Task.ID != first.Task.ID || string(assigned.Task.Payload) != string(payload) {
t.Fatal("assignment inflated or changed payload")
}
if _, err := w.Start(ctx, workerTestID(t), assigned.Attempt()); err != nil {
t.Fatal(err)
}
finish := WorkerFinish{Ref: assigned.Attempt(), Outcome: WorkerSucceeded, Result: payload}
if _, err := w.Finish(ctx, workerTestID(t), finish); err != nil {
t.Fatal(err)
}
page, err := client.ListTaskAttempts(ctx, first.Task.ID, HistoryPageRequest{Limit: 1})
if err != nil || len(page.Items) != 1 || page.Items[0].ID != assigned.AttemptID {
t.Fatalf("attempt list: %v", err)
}
detail, err := client.GetTaskAttempt(ctx, first.Task.ID, assigned.AttemptID)
if err != nil || detail.State != AttemptSucceeded || detail.WorkerInstanceID != r.InstanceID || string(detail.Result) != string(payload) {
t.Fatalf("attempt detail: %v", err)
}
finish.Result = json.RawMessage(strings.Replace(string(payload), "9007199254740993", "9007199254740995", 1))
if out, err := w.Finish(ctx, workerTestID(t), finish); err != nil || out.Receipt.Disposition != "EVIDENCE_ONLY" {
t.Fatalf("conflicting report: %v", err)
}
evidence, err := client.ListAttemptEvidence(ctx, first.Task.ID, assigned.AttemptID, HistoryPageRequest{Limit: 1})
if err != nil || len(evidence.Items) != 1 || evidence.Items[0].Reason != "RESULT_CONFLICT" {
t.Fatalf("evidence summary: %v", err)
}
e, err := client.GetAttemptEvidence(ctx, first.Task.ID, assigned.AttemptID, evidence.Items[0].ID)
if err != nil || string(e.Payload) != string(finish.Result) || e.Disposition != "EVIDENCE_ONLY" {
t.Fatalf("evidence detail: %v", err)
}
task, err := client.GetTask(ctx, first.Task.ID)
if err != nil || string(task.Result) != string(payload) || task.State != TaskSucceeded {
t.Fatalf("authoritative result changed: %v", err)
}
}

83
sdk/go/history_test.go Normal file
View File

@@ -0,0 +1,83 @@
package orbit
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
)
func TestHistoryClientPathsPrecisionAndBounds(t *testing.T) {
const task = "11111111111111111111111111111111"
const attempt = "22222222222222222222222222222222"
const evidence = "33333333333333333333333333333333"
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
if r.Method != "GET" || r.Header.Get("Authorization") != "Bearer example-token" || r.Header.Get("Idempotency-Key") != "" {
t.Error("invalid history request headers")
}
var data any
switch r.URL.Path {
case "/v1/tasks/" + task + "/attempts":
if r.URL.Query().Get("cursor") != "opaque+cursor/=" || r.URL.Query().Get("limit") != "2" {
t.Error("cursor encoding changed")
}
data = AttemptPage{Items: []AttemptSummary{{ID: attempt, TaskID: task, Epoch: 9007199254740993, Revision: 9007199254740995}}}
case "/v1/tasks/" + task + "/attempts/" + attempt:
data = Attempt{AttemptSummary: AttemptSummary{ID: attempt, TaskID: task}, Result: json.RawMessage(`{"n":9007199254740993}`)}
case "/v1/tasks/" + task + "/attempts/" + attempt + "/evidence":
data = AttemptEvidencePage{Items: []AttemptEvidenceSummary{{ID: evidence, AttemptID: attempt, TaskID: task, Disposition: "EVIDENCE_ONLY"}}}
case "/v1/tasks/" + task + "/attempts/" + attempt + "/evidence/" + evidence:
data = AttemptEvidence{AttemptEvidenceSummary: AttemptEvidenceSummary{ID: evidence}, Payload: json.RawMessage(`{"ratio":0.1234567890123456789012345}`)}
default:
t.Error("unexpected history path")
}
raw, _ := json.Marshal(map[string]any{"request_id": task, "data": data})
w.Write(raw)
}))
defer server.Close()
c, _ := NewClient(server.URL, Options{Token: "example-token"})
ctx := context.Background()
page, err := c.ListTaskAttempts(ctx, task, HistoryPageRequest{Limit: 2, Cursor: "opaque+cursor/="})
if err != nil || page.Items[0].Epoch != 9007199254740993 || page.Items[0].Revision != 9007199254740995 {
t.Fatal("attempt integers lost precision")
}
a, err := c.GetTaskAttempt(ctx, task, attempt)
if err != nil || string(a.Result) != `{"n":9007199254740993}` {
t.Fatal("attempt result changed")
}
ep, err := c.ListAttemptEvidence(ctx, task, attempt, HistoryPageRequest{})
if err != nil || len(ep.Items) != 1 || ep.Items[0].Disposition != "EVIDENCE_ONLY" {
t.Fatal("evidence classification changed")
}
e, err := c.GetAttemptEvidence(ctx, task, attempt, evidence)
if err != nil || string(e.Payload) != `{"ratio":0.1234567890123456789012345}` {
t.Fatal("evidence precision changed")
}
for _, id := range []string{"../queues", strings.Repeat("A", 32), "id?secret=x"} {
if _, err := c.ListTaskAttempts(ctx, id, HistoryPageRequest{}); err == nil {
t.Fatal("invalid parent id accepted")
}
if _, err := c.GetTaskAttempt(ctx, task, id); err == nil {
t.Fatal("invalid attempt id accepted")
}
if _, err := c.ListAttemptEvidence(ctx, task, id, HistoryPageRequest{}); err == nil {
t.Fatal("invalid evidence parent accepted")
}
if _, err := c.GetAttemptEvidence(ctx, task, attempt, id); err == nil {
t.Fatal("invalid evidence id accepted")
}
}
for _, page := range []HistoryPageRequest{{Limit: -1}, {Limit: 101}, {Cursor: strings.Repeat("x", 1025)}} {
if _, err := c.ListTaskAttempts(ctx, task, page); err == nil {
t.Fatal("invalid page accepted")
}
}
if calls.Load() != 4 {
t.Fatal("invalid history input reached server")
}
}

79
sdk/go/history_types.go Normal file
View File

@@ -0,0 +1,79 @@
package orbit
import (
"encoding/json"
"time"
)
// HistoryPageRequest 用于任务执行历史的有界分页;游标必须沿原父资源继续使用。
type HistoryPageRequest struct {
Limit int // Limit 默认 20,最大 100;列表不返回业务结果正文。
Cursor string // Cursor 服务端不透明游标,最长 1024 字节;为空从最新记录开始。
}
// AttemptSummary 是执行尝试的查询快照,不提供当前执行授权。
type AttemptSummary struct {
ID string `json:"id"` // ID 尝试标识,不是执行秘密。
TaskID string `json:"task_id"` // TaskID 所属任务;查询始终限定在该任务下。
Epoch uint64 `json:"epoch,string"` // Epoch 单任务执行代次,JSON 为十进制字符串。
State AttemptState `json:"state"` // State 尝试状态,与整个任务状态分开。
Revision uint64 `json:"revision,string"` // Revision 当前记录版本,可因续租/恢复变化。
LeaseUntil time.Time `json:"lease_until"` // LeaseUntil 数据库记录的租约期限;查询不能授权业务执行。
AuthorizedAt *time.Time `json:"authorized_at,omitempty"` // AuthorizedAt 开始批准时间;空表示未批准,不证明副作用已经发生。
EndedAt *time.Time `json:"ended_at,omitempty"` // EndedAt 尝试结束时间;仍在执行时为空。
ErrorCode string `json:"error_code,omitempty"` // ErrorCode 稳定错误分类;无错误时为空。
CreatedAt time.Time `json:"created_at"` // CreatedAt 创建时间,UTC 微秒精度。
UpdatedAt time.Time `json:"updated_at"` // UpdatedAt 最新变更时间,不能代替版本校验。
}
// Attempt 增加当前归属与已保存结果,不返回令牌、摘要或原始请求秘密。
type Attempt struct {
AttemptSummary // AttemptSummary 尝试状态、执行代次与时间快照。
SessionID string `json:"session_id"` // SessionID 当前或最后归属会话;重连迁移后可变化。
WorkerInstanceID string `json:"worker_instance_id"` // WorkerInstanceID 对应进程身份,不是认证凭据。
Result json.RawMessage `json:"result,omitempty"` // Result 已保存的尝试结果,未保存时省略;不包含迟到证据。
}
// AttemptPage 返回一页尝试摘要及可选的后续位置。
type AttemptPage struct {
Items []AttemptSummary `json:"items"` // Items 按 Epoch 降序排列的摘要,空列表返回 []。
NextCursor string `json:"next_cursor,omitempty"` // NextCursor 后续页游标,空表示本次已读到末尾。
}
// AttemptEvidenceSummary 描述追加证据,原报告并未改变权威任务结果。
type AttemptEvidenceSummary struct {
ID string `json:"id"` // ID 不可变证据标识。
TaskID string `json:"task_id"` // TaskID 所属任务,从已验证的父关联确定。
AttemptID string `json:"attempt_id"` // AttemptID 所属执行尝试。
Outcome string `json:"outcome"` // Outcome Worker 声明的 SUCCEEDED、FAILED 或 UNKNOWN,不代表平台认可。
ErrorCode string `json:"error_code,omitempty"` // ErrorCode 原报告的稳定分类,可为空。
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 首次收到时间,重复报告不改变它。
}
// AttemptEvidence 提供单条追加证据的原报告身份和正文。
type AttemptEvidence struct {
AttemptEvidenceSummary // AttemptEvidenceSummary 原报告分类及采集时间。
MessageID string `json:"message_id"` // MessageID 原结果报告身份,用于追踪同一次重报。
Payload json.RawMessage `json:"payload"` // Payload 原始结果/证据引用 JSON,保留数字文本;不提升为任务结果。
}
// AttemptEvidencePage 返回一页证据摘要,正文通过详情单独读取。
type AttemptEvidencePage struct {
Items []AttemptEvidenceSummary `json:"items"` // Items 按 created_at、ID 降序的证据摘要,不含大正文。
NextCursor string `json:"next_cursor,omitempty"` // NextCursor 必须沿同一任务及尝试继续使用。
}
// AttemptState 表示一次尝试的状态,与整个任务的状态分开。
type AttemptState string
const (
AttemptLeased AttemptState = "LEASED"
AttemptRunning AttemptState = "RUNNING"
AttemptSucceeded AttemptState = "SUCCEEDED"
AttemptFailed AttemptState = "FAILED"
AttemptCancelled AttemptState = "CANCELLED"
AttemptExpired AttemptState = "EXPIRED"
AttemptUnknown AttemptState = "UNKNOWN"
)

18
sdk/go/json_wire.go Normal file
View File

@@ -0,0 +1,18 @@
package orbit
import (
"bytes"
"encoding/json"
)
// marshalWire 不做 HTML 转义,保持 RawMessage 精确数字及受控载荷大小。
// 这些文档只用于 JSON/WS 通道,不能直接嵌入 HTML。
func marshalWire(value any) ([]byte, error) {
var buffer bytes.Buffer
encoder := json.NewEncoder(&buffer)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(value); err != nil {
return nil, err
}
return bytes.TrimSuffix(buffer.Bytes(), []byte{'\n'}), nil
}

View File

@@ -106,7 +106,7 @@ func (c *Client) call(ctx context.Context, method, path, key string, in, out any
var body []byte
var err error
if in != nil {
body, err = json.Marshal(in)
body, err = marshalWire(in)
if err != nil {
return err
}

View File

@@ -95,8 +95,8 @@ func (c *Client) DialWorker(ctx context.Context, registration WorkerRegistration
if err != nil {
return nil, err
}
body, _ := json.Marshal(r)
frame, _ := json.Marshal(workerFrame{Version: "1", Type: "worker.hello", MessageID: id, Body: body})
body, _ := marshalWire(r)
frame, _ := marshalWire(workerFrame{Version: "1", Type: "worker.hello", MessageID: id, Body: body})
if err = conn.Write(handshake, websocket.MessageText, frame); err != nil {
return nil, workerIOError(err)
}
@@ -249,11 +249,11 @@ func (w *WorkerConnection) call(ctx context.Context, kind, id string, in, out an
w.mu.Unlock()
defer func() { w.mu.Lock(); delete(w.pending, id); w.mu.Unlock() }()
// 先占用有界槽再分配编码缓冲,超额调用不先复制大结果。
body, err := json.Marshal(in)
body, err := marshalWire(in)
if err != nil {
return started, errors.New("invalid Worker request JSON")
}
raw, err := json.Marshal(workerFrame{Version: "1", Type: kind, MessageID: id, SessionID: w.welcome.SessionID, Body: body})
raw, err := marshalWire(workerFrame{Version: "1", Type: kind, MessageID: id, SessionID: w.welcome.SessionID, Body: body})
if err != nil || len(raw) > 512<<10 {
return started, errors.New("Worker request exceeds frame limit")
}