docs: consolidate runnable examples and publish v0.1.1 guides

This commit is contained in:
2026-09-24 21:27:25 +08:00
parent e29dec717f
commit 81e56bd633
124 changed files with 1430 additions and 281 deletions

View File

@@ -0,0 +1,30 @@
# 有界查询
提交外部作业一次,区分处理中轮次与请求失败重试,并演示轮次耗尽。
## 准备
- Go 1.27.1、当前版本 Orbit;服务启动和初始化见[仓库 README](../../README.md#快速开始)。
- 使用完整流程凭据;程序内置受控 HTTP 渠道,无需接入外部业务系统。
- [示例总索引](../README.md)提供统一初始化和权限说明;完整字段、失败处理见[详细手册](../../docs/examples/workflow-poll.md)。
## 运行
以下命令从仓库根目录执行;凭据路径替换为实际初始化输出。
```sh
cd examples
export ORBIT_URL=http://127.0.0.1:8080
export ORBIT_CREDENTIALS_FILE="$PWD/../.local/development.credentials.json"
go run ./workflow-poll
```
## 结果核对
成功场景 SUCCEEDED、耗尽场景 NEEDS_REVIEW;deadline_preserved=true。具体 ID 和统计值以本次运行为准。程序会核对上述关键条件,失败时打印错误并以非零状态退出。
## 接入自己的项目
阅读[main.go](main.go)中的客户端创建、请求及错误处理。业务项目通过 `go get git.yuntuo.hk/k2safe/orbit/sdk/go@v0.1.1` 引入 SDK,无需导入本示例包。
示例运行会写入真实平台数据,应使用专用测试应用/队列。正常结束保留历史和审计;重复演示通常创建新身份,不能当作上次失败操作的恢复。真实业务应在第一次请求前持久化操作身份,网络结果不明时沿原身份核对。

View File

@@ -0,0 +1,249 @@
// workflow-poll 区分请求失败重试与成功PENDING后的业务轮次,并演示轮次耗尽进入核查。
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"sync/atomic"
"time"
"git.yuntuo.hk/k2safe/orbit/examples/internal/flowdemo"
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 {
api, queue, operation, err := flowdemo.Client()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
for _, exhausted := range []bool{false, true} {
if err := scenario(ctx, api, queue, operation, exhausted); err != nil {
return err
}
}
return nil
}
func scenario(ctx context.Context, api *orbit.Client, queue, operation string, exhausted bool) error {
mode, expected := "success", "SUCCEEDED"
if exhausted {
mode, expected = "exhausted", "NEEDS_REVIEW"
}
operation += "." + mode
var requests, submissions atomic.Int32
// 这是本地外部渠道夹具。真实业务应查询已存在的外部作业,不能在轮询里重新提交作业。
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/jobs/"+operation {
http.NotFound(w, r)
return
}
if r.Method == "POST" {
raw, err := io.ReadAll(io.LimitReader(r.Body, 1025))
var in struct {
OperationID string `json:"operation_id"`
}
if err != nil || len(raw) > 1024 || json.Unmarshal(raw, &in) != nil || in.OperationID != operation {
http.Error(w, "invalid operation", 400)
return
}
if submissions.Add(1) != 1 {
http.Error(w, "unexpected repeated submission", 409)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
_ = json.NewEncoder(w).Encode(in)
return
}
if r.Method != "GET" || submissions.Load() != 1 {
http.NotFound(w, r)
return
}
n := requests.Add(1)
if !exhausted && n == 1 {
http.Error(w, "temporary query failure", http.StatusServiceUnavailable)
return
}
state := "PENDING"
if !exhausted && n >= 3 {
state = "DONE"
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(state)
}))
defer remote.Close()
input, _ := json.Marshal(struct {
OperationID string `json:"operation_id"`
}{operation})
typ := orbit.WorkflowType{Kind: "OBJECT", Fields: map[string]orbit.WorkflowType{"operation_id": {Kind: "STRING"}}}
status := orbit.WorkflowType{Kind: "STRING", Enum: []string{"PENDING", "DONE"}}
handler := "job.query." + operation
task := orbit.WorkflowTask{QueueID: queue, Handler: handler, HandlerVersion: "v1", PayloadVersion: "v1", InputType: typ, Input: orbit.WorkflowRef("input"), ResultType: status, TimeoutMS: 20000, Retry: orbit.RetryPolicy{MaxAttempts: 2, InitialDelayMS: 1000, MaxDelayMS: 1000, AttemptTimeoutMS: 5000}, RecoverySafety: orbit.RecoveryIdempotent}
submit := task
submit.Handler, submit.ResultType, submit.RecoverySafety = "job.submit."+operation, typ, orbit.RecoveryReconcileOnly
submit.Retry = orbit.RetryPolicy{MaxAttempts: 1, AttemptTimeoutMS: 5000}
task.Input = orbit.WorkflowRef("submit")
spec := orbit.WorkflowSpecification{SchemaVersion: 1, InputType: typ, TimeoutMS: 45000, FailurePolicy: "REVIEW", Body: orbit.WorkflowBlock{Steps: []orbit.WorkflowStep{{Key: "submit", Kind: "TASK", Task: &submit}, {Key: "query", Kind: "POLL", Poll: &orbit.WorkflowPoll{Task: task, MaxRounds: 2, TimeoutMS: 30000, IntervalMS: 1000, Pending: []string{"PENDING"}, Succeeded: []string{"DONE"}}}}, OutputType: status, Output: orbit.WorkflowRef("query")}}
v, err := flowdemo.PublishStart(ctx, api, operation, "poll."+mode, spec, input)
if err != nil {
return err
}
httpClient := remote.Client()
httpClient.Timeout = 3 * time.Second
runner, err := flowdemo.StartWorker(ctx, api, queue, orbit.WorkerHandler{Capability: orbit.WorkerCapability{Handler: handler, HandlerVersion: "v1", PayloadVersion: "v1"}, Handle: func(ctx context.Context, e orbit.WorkerExecution) (json.RawMessage, error) {
var in struct {
OperationID string `json:"operation_id"`
}
if json.Unmarshal(e.Task.Payload, &in) != nil || in.OperationID != operation {
return nil, &orbit.WorkerFailure{Code: "INVALID_JOB"}
}
req, err := http.NewRequestWithContext(ctx, "GET", remote.URL+"/jobs/"+in.OperationID, nil)
if err != nil {
return nil, err
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
} // 未知传输错误保留UNKNOWN,由冻结安全策略处理。
defer resp.Body.Close()
if resp.StatusCode == http.StatusServiceUnavailable {
// 本例渠道明确拒绝只读查询,可以声明请求失败且允许Pulse重试。
return nil, &orbit.WorkerFailure{Code: "QUERY_TEMPORARY", Retryable: true}
}
if resp.StatusCode != http.StatusOK {
return nil, errors.New("unexpected query response")
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1025))
if err != nil {
return nil, err
}
var state string
if len(raw) > 1024 || json.Unmarshal(raw, &state) != nil || state != "PENDING" && state != "DONE" {
return nil, errors.New("invalid query result")
}
return json.RawMessage(raw), nil // PENDING是成功查询结果,不能转为可重试错误。
}}, orbit.WorkerHandler{Capability: orbit.WorkerCapability{Handler: submit.Handler, HandlerVersion: "v1", PayloadVersion: "v1"}, Handle: func(ctx context.Context, e orbit.WorkerExecution) (json.RawMessage, error) {
req, err := http.NewRequestWithContext(ctx, "POST", remote.URL+"/jobs/"+operation, bytes.NewReader(e.Task.Payload))
if err != nil {
return nil, err
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
} // 提交结果未知必须核查,不能交给POLL重新提交。
defer resp.Body.Close()
if resp.StatusCode != http.StatusAccepted {
return nil, errors.New("submission receipt unavailable")
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1025))
if err != nil {
return nil, err
}
var accepted struct {
OperationID string `json:"operation_id"`
}
if len(raw) > 1024 || json.Unmarshal(raw, &accepted) != nil || accepted.OperationID != operation {
return nil, errors.New("submission identity mismatch")
}
return json.RawMessage(raw), nil
}})
if err != nil {
return err
}
defer runner.Close()
initial, err := runner.Step(ctx, api, v.ID, "query", func(d orbit.WorkflowStepDetail) bool { return d.Poll != nil && d.Step.ActivationID != nil })
if err != nil {
return err
}
final, err := runner.Workflow(ctx, api, v.ID, expected)
if err != nil {
return err
}
step, err := api.GetWorkflowStep(ctx, v.ID, "query")
if err != nil {
return err
}
if step.Poll == nil || step.Step.ActivationID == nil || step.Poll.UsedRounds != 2 || step.Poll.MaxAttemptsPerRound != 2 || !step.Poll.DeadlineAt.Equal(initial.Poll.DeadlineAt) || !final.DeadlineAt.Equal(v.DeadlineAt) || *step.Step.ActivationID != *initial.Step.ActivationID {
return errors.New("poll budget, deadline or activation changed")
}
// 历史按业务轮倒序;每轮再独立读取Pulse尝试,二者不可混为同一个计数。
cursor, rounds, totalAttempts := "", 0, 0
for {
page, err := api.ListWorkflowPollRounds(ctx, v.ID, "query", *step.Step.ActivationID, orbit.HistoryPageRequest{Limit: 1, Cursor: cursor})
if err != nil {
return err
}
for _, round := range page.Items {
rounds++
source, err := api.GetTaskSource(ctx, round.TaskID)
if err != nil {
return err
}
if source.Workflow == nil || source.Workflow.WorkflowID != v.ID || source.Workflow.PollRound != round.Round {
return errors.New("query round source mismatch")
}
attempts, err := api.ListTaskAttempts(ctx, round.TaskID, orbit.HistoryPageRequest{Limit: 10})
if err != nil {
return err
}
want := 1
if !exhausted && round.Round == 1 {
want = 2
}
if len(attempts.Items) != want || attempts.NextCursor != "" {
return errors.New("request retries consumed wrong business rounds")
}
for i, a := range attempts.Items {
expect := "SUCCEEDED"
if want == 2 && i == 1 {
expect = "FAILED"
}
if string(a.State) != expect || a.EndedAt == nil {
return errors.New("unexpected request attempt history")
}
}
totalAttempts += len(attempts.Items)
}
if page.NextCursor == "" {
break
}
cursor = page.NextCursor
}
expectedRequests := 3
if exhausted {
expectedRequests = 2
}
if rounds != 2 || totalAttempts != expectedRequests || int(requests.Load()) != expectedRequests || submissions.Load() != 1 {
return errors.New("unexpected query count")
}
if !exhausted && string(final.Output) != `"DONE"` {
return errors.New("final business result missing")
}
if exhausted && (step.Step.State != "NEEDS_REVIEW" || step.Poll.CurrentTaskID != nil || step.Poll.NextPollAt != nil) {
return errors.New("exhausted poll remained runnable")
}
if err := runner.Close(); err != nil {
return err
}
stats := runner.Worker.Stats()
if int(stats.Started) != expectedRequests+1 || int(stats.ReportsConfirmed) != expectedRequests+1 || stats.Running != 0 {
return errors.New("Worker did not drain all requests")
}
fmt.Printf("poll_scenario=%s state=%s submissions=%d rounds=%d attempts=%d requests=%d deadline_preserved=true worker_drained=true\n", mode, final.State, submissions.Load(), rounds, totalAttempts, requests.Load())
return nil
}