Files
orbit/examples/workflow-poll/main.go

250 lines
9.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}