feat: add scoped task source tracing and SDK examples
This commit is contained in:
@@ -177,6 +177,14 @@ func run() error {
|
||||
if _, err := w.Capacity(ctx, id, orbit.WorkerCapacity{Sequence: 2, Drain: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
lineage, err := client.GetTaskSource(ctx, child.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if lineage.Kind != "MANUAL_RETRY" || lineage.RetryOf == nil || *lineage.RetryOf != original.ID {
|
||||
return errors.New("manual retry source mismatch")
|
||||
}
|
||||
fmt.Printf("source_kind=%s parent=%s\n", lineage.Kind, *lineage.RetryOf)
|
||||
fmt.Printf("source=%s state=%s retry=%s state=%s audit=%s operations=%d\n", original.ID, original.State, child.ID, child.State, audit.ID, len(page.Items))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -99,6 +99,14 @@ func run() error {
|
||||
return err
|
||||
}
|
||||
if task.State == orbit.TaskSucceeded {
|
||||
origin, err := client.GetTaskSource(ctx, task.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if origin.Kind != "SCHEDULE" || origin.Schedule == nil || origin.Schedule.ScheduleID != created.Schedule.ID || origin.Schedule.OccurrenceID != item.ID || origin.Schedule.ScheduleVersion != item.ScheduleVersion {
|
||||
return errors.New("task source did not match frozen occurrence")
|
||||
}
|
||||
fmt.Printf("source_kind=%s fixed_version=%d\n", origin.Kind, origin.Schedule.ScheduleVersion)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -79,6 +79,14 @@ func run() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
origin, err := client.GetTaskSource(ctx, current.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if origin.Kind != "DIRECT" {
|
||||
return fmt.Errorf("expected direct task source")
|
||||
}
|
||||
fmt.Printf("source_kind=%s\n", origin.Kind)
|
||||
fmt.Printf("task=%s duplicate=%t listed=%d final_state=%s revision=%d\n", current.ID, again.Deduplicated, len(page.Items), cancelled.Task.State, cancelled.Task.Revision)
|
||||
if cancelled.Task.State != orbit.TaskCancelled {
|
||||
return fmt.Errorf("expected CANCELLED")
|
||||
|
||||
@@ -126,6 +126,14 @@ func TestIntegrationTaskRecoverySDK(t *testing.T) {
|
||||
if err != nil || !child.Deduplicated || child.Task.ID != retryEnvelope.Data.Task.ID || child.AuditID != retryEnvelope.Data.AuditID || child.Task.RetryOf == nil || *child.Task.RetryOf != unknown.ID {
|
||||
t.Fatal(child, err)
|
||||
}
|
||||
lineage, err := api.GetTaskSource(ctx, child.Task.ID)
|
||||
if err != nil || lineage.Kind != "MANUAL_RETRY" || lineage.RetryOf == nil || *lineage.RetryOf != unknown.ID || lineage.Schedule != nil {
|
||||
t.Fatal("manual retry source", lineage, err)
|
||||
}
|
||||
originalSource, err := api.GetTaskSource(ctx, unknown.ID)
|
||||
if err != nil || originalSource.Kind != "DIRECT" || originalSource.RetryOf != nil {
|
||||
t.Fatal("direct source", originalSource, err)
|
||||
}
|
||||
assigned, err := w.NextAssignment(ctx)
|
||||
if err != nil || assigned.Task.ID != child.Task.ID || assigned.Task.RetryOf == nil || *assigned.Task.RetryOf != unknown.ID {
|
||||
t.Fatal("wire lost retry lineage", assigned, err)
|
||||
|
||||
@@ -90,6 +90,10 @@ func TestIntegrationScheduleLifecycle(t *testing.T) {
|
||||
if err != nil || updated.Schedule.CurrentVersion != 2 || !updated.Schedule.Paused {
|
||||
t.Fatal("publish schedule", err)
|
||||
}
|
||||
source, err := api.GetTaskSource(ctx, task.ID)
|
||||
if err != nil || source.Kind != "SCHEDULE" || source.Schedule == nil || source.Schedule.ScheduleID != paused.Schedule.ID || source.Schedule.OccurrenceID != occurrence.ID || source.Schedule.ScheduleVersion != 1 || !source.Schedule.ScheduledAt.Equal(occurrence.ScheduledAt) {
|
||||
t.Fatal("published version changed task source", source, 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)
|
||||
|
||||
68
sdk/go/task_source.go
Normal file
68
sdk/go/task_source.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package orbit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TaskSource 只读的直接来源,不遍历整条重试链,也不授予来源资源管理权限。
|
||||
type TaskSource struct {
|
||||
// TaskID 本次已授权查询的任务标识。
|
||||
TaskID string `json:"task_id"`
|
||||
// Kind 当前支持DIRECT、MANUAL_RETRY、SCHEDULE;分别是普通受理、人工派生、计划触发。
|
||||
Kind string `json:"kind"`
|
||||
// RetryOf 人工重试的直接原任务,限同一命名空间及队列;其他种类省略。
|
||||
RetryOf *string `json:"retry_of,omitempty"`
|
||||
// Schedule 计划触发的固定来源,仅SCHEDULE返回,不包含任务模板或计划配置。
|
||||
Schedule *TaskScheduleSource `json:"schedule,omitempty"`
|
||||
}
|
||||
|
||||
// TaskScheduleSource 关联已生成任务的触发记录及当时的不可变计划版本。
|
||||
type TaskScheduleSource struct {
|
||||
// ScheduleID 原计划标识,不因发布新版本而变化。
|
||||
ScheduleID string `json:"schedule_id"`
|
||||
// ScheduleVersion 触发时使用的固定版本,JSON字符串避免精度丢失。
|
||||
ScheduleVersion uint64 `json:"schedule_version,string"`
|
||||
// OccurrenceID 唯一关联该任务的触发记录标识。
|
||||
OccurrenceID string `json:"occurrence_id"`
|
||||
// ScheduledAt 原计划时间,UTC微秒精度,不等于实际受理/执行时间。
|
||||
ScheduledAt time.Time `json:"scheduled_at"`
|
||||
// Decision FIRED为单点触发,COALESCED为合并触发;跳过不会产生任务。
|
||||
Decision string `json:"decision"`
|
||||
// CoveredUntil 合并范围的排除边界,仅COALESCED返回。
|
||||
CoveredUntil *time.Time `json:"covered_until,omitempty"`
|
||||
}
|
||||
|
||||
// GetTaskSource 查询直接来源;不会授予来源计划的读取/管理或任务执行权限。
|
||||
func (c *Client) GetTaskSource(ctx context.Context, id string) (TaskSource, error) {
|
||||
var out TaskSource
|
||||
if !validID(id) {
|
||||
return out, errors.New("invalid task id")
|
||||
}
|
||||
if err := c.call(ctx, "GET", "/v1/tasks/"+id+"/source", "", nil, &out); err != nil {
|
||||
return TaskSource{}, err
|
||||
}
|
||||
if out.TaskID != id {
|
||||
return TaskSource{}, errors.New("source does not match requested task")
|
||||
}
|
||||
valid := false
|
||||
switch out.Kind {
|
||||
case "DIRECT":
|
||||
valid = out.RetryOf == nil && out.Schedule == nil
|
||||
case "MANUAL_RETRY":
|
||||
valid = out.RetryOf != nil && validID(*out.RetryOf) && *out.RetryOf != id && out.Schedule == nil
|
||||
case "SCHEDULE":
|
||||
s := out.Schedule
|
||||
if s != nil && out.RetryOf == nil && validID(s.ScheduleID) && validID(s.OccurrenceID) && s.ScheduleVersion > 0 && validSourceTime(s.ScheduledAt) {
|
||||
valid = s.Decision == "FIRED" && s.CoveredUntil == nil || s.Decision == "COALESCED" && s.CoveredUntil != nil && validSourceTime(*s.CoveredUntil) && s.CoveredUntil.After(s.ScheduledAt)
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
return TaskSource{}, errors.New("invalid or unsupported task source response")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func validSourceTime(t time.Time) bool {
|
||||
return t.Year() >= 2000 && t.Year() <= 9998 && t.Nanosecond()%1000 == 0
|
||||
}
|
||||
61
sdk/go/task_source_test.go
Normal file
61
sdk/go/task_source_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package orbit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTaskSourceValidationAndPrecision(t *testing.T) {
|
||||
const task = "11111111111111111111111111111111"
|
||||
const parent = "22222222222222222222222222222222"
|
||||
const occurrence = "33333333333333333333333333333333"
|
||||
at := time.Date(2026, 9, 17, 0, 0, 0, 123456000, time.UTC)
|
||||
until := at.Add(time.Minute)
|
||||
var reply atomic.Value
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
if r.Method != "GET" || r.URL.Path != "/v1/tasks/"+task+"/source" || r.Header.Get("Authorization") != "Bearer example-token" || r.Header.Get("Idempotency-Key") != "" {
|
||||
t.Error("invalid source request")
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"request_id": task, "data": reply.Load()})
|
||||
}))
|
||||
defer server.Close()
|
||||
client, _ := NewClient(server.URL, Options{Token: "example-token"})
|
||||
p := parent
|
||||
valid := []TaskSource{{TaskID: task, Kind: "DIRECT"}, {TaskID: task, Kind: "MANUAL_RETRY", RetryOf: &p}, {TaskID: task, Kind: "SCHEDULE", Schedule: &TaskScheduleSource{ScheduleID: parent, ScheduleVersion: 9007199254740993, OccurrenceID: occurrence, ScheduledAt: at, Decision: "FIRED"}}, {TaskID: task, Kind: "SCHEDULE", Schedule: &TaskScheduleSource{ScheduleID: parent, ScheduleVersion: 1, OccurrenceID: occurrence, ScheduledAt: at, Decision: "COALESCED", CoveredUntil: &until}}}
|
||||
for _, v := range valid {
|
||||
reply.Store(v)
|
||||
out, err := client.GetTaskSource(context.Background(), task)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.Schedule != nil && out.Schedule.ScheduleVersion != v.Schedule.ScheduleVersion {
|
||||
t.Fatal("version precision lost")
|
||||
}
|
||||
}
|
||||
self := task
|
||||
invalid := []TaskSource{{TaskID: parent, Kind: "DIRECT"}, {TaskID: task, Kind: "unknown"}, {TaskID: task, Kind: "DIRECT", RetryOf: &p}, {TaskID: task, Kind: "MANUAL_RETRY"}, {TaskID: task, Kind: "MANUAL_RETRY", RetryOf: &self}, {TaskID: task, Kind: "SCHEDULE"}}
|
||||
for _, change := range []func(*TaskScheduleSource){func(v *TaskScheduleSource) { v.ScheduleVersion = 0 }, func(v *TaskScheduleSource) { v.Decision = "SKIPPED" }, func(v *TaskScheduleSource) { v.Decision = "COALESCED" }, func(v *TaskScheduleSource) { v.CoveredUntil = &until }, func(v *TaskScheduleSource) { v.ScheduledAt = time.Time{} }, func(v *TaskScheduleSource) { v.OccurrenceID = "invalid" }} {
|
||||
v := valid[2]
|
||||
s := *v.Schedule
|
||||
change(&s)
|
||||
v.Schedule = &s
|
||||
invalid = append(invalid, v)
|
||||
}
|
||||
for _, v := range invalid {
|
||||
reply.Store(v)
|
||||
if _, err := client.GetTaskSource(context.Background(), task); err == nil {
|
||||
t.Fatal("invalid source accepted", v)
|
||||
}
|
||||
}
|
||||
before := calls.Load()
|
||||
if _, err := client.GetTaskSource(context.Background(), "../tasks"); err == nil || calls.Load() != before {
|
||||
t.Fatal("unsafe source path sent")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user