// 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 }