feat: add bounded workflow execution measurement
This commit is contained in:
153
sdk/go/cmd/orbit-workflow-load/main.go
Normal file
153
sdk/go/cmd/orbit-workflow-load/main.go
Normal file
@@ -0,0 +1,153 @@
|
||||
// orbit-workflow-load 在隔离环境测量真实流程发布、并行激活、Worker执行和持久化完成链路。
|
||||
// 流程只包含两个有限并行分支,处理器回显输入,不访问外部业务系统。
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
|
||||
)
|
||||
|
||||
type config struct {
|
||||
URL, Token, QueueID string
|
||||
Requests, Concurrency, Workers, Capacity int
|
||||
Timeout, RunTimeout, PollInterval time.Duration
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
cfg, err := parseConfig(os.Args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := orbit.NewClient(cfg.URL, orbit.Options{Token: cfg.Token, HTTPClient: &http.Client{Timeout: cfg.Timeout}})
|
||||
if err != nil {
|
||||
return errors.New("invalid client configuration")
|
||||
}
|
||||
runID, err := orbit.NewWorkerMessageID()
|
||||
if err != nil {
|
||||
return errors.New("cannot generate run identity")
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
report := measure(ctx, cfg, client, runID)
|
||||
if err := json.NewEncoder(os.Stdout).Encode(report); err != nil {
|
||||
return err
|
||||
}
|
||||
if !report.Complete {
|
||||
return errors.New("workflow sample incomplete; inspect report and original run identities")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseConfig(args []string) (config, error) {
|
||||
cfg := config{URL: os.Getenv("ORBIT_URL"), Token: os.Getenv("ORBIT_TOKEN"), QueueID: os.Getenv("ORBIT_QUEUE_ID"), Requests: 100, Concurrency: 8, Workers: 2, Capacity: 4, Timeout: 5 * time.Second, RunTimeout: 5 * time.Minute, PollInterval: 250 * time.Millisecond}
|
||||
flags := flag.NewFlagSet("orbit-workflow-load", flag.ContinueOnError)
|
||||
var credentials string
|
||||
flags.StringVar(&cfg.URL, "url", cfg.URL, "Orbit HTTP(S) URL")
|
||||
flags.StringVar(&credentials, "credentials", os.Getenv("ORBIT_CREDENTIALS_FILE"), "private credentials file for an isolated namespace")
|
||||
flags.StringVar(&cfg.QueueID, "queue", cfg.QueueID, "existing isolated queue; configuration is not changed")
|
||||
flags.IntVar(&cfg.Requests, "requests", cfg.Requests, "workflow instances, 1..2000")
|
||||
flags.IntVar(&cfg.Concurrency, "concurrency", cfg.Concurrency, "maximum concurrent start/read requests, 1..128")
|
||||
flags.IntVar(&cfg.Workers, "workers", cfg.Workers, "independent WSS Workers in this process, 1..32")
|
||||
flags.IntVar(&cfg.Capacity, "capacity", cfg.Capacity, "concurrent handlers per Worker, 1..256; total at most 1024")
|
||||
flags.DurationVar(&cfg.Timeout, "timeout", cfg.Timeout, "per HTTP request timeout, 1ms..1m")
|
||||
flags.DurationVar(&cfg.RunTimeout, "run-timeout", cfg.RunTimeout, "whole sample deadline, 2s..1h, plus at most 5s Worker shutdown")
|
||||
flags.DurationVar(&cfg.PollInterval, "poll-interval", cfg.PollInterval, "per active verifier polling interval, 100ms..10s")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return config{}, err
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
return config{}, errors.New("positional arguments are not supported")
|
||||
}
|
||||
if credentials != "" {
|
||||
f, err := os.Open(credentials)
|
||||
if err != nil {
|
||||
return config{}, errors.New("cannot open private credentials file")
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(f, (16<<10)+1))
|
||||
_ = f.Close()
|
||||
var saved struct {
|
||||
Token string `json:"token"`
|
||||
QueueID string `json:"queue_id"`
|
||||
}
|
||||
if err != nil || len(raw) > 16<<10 || json.Unmarshal(raw, &saved) != nil {
|
||||
return config{}, errors.New("invalid credentials file (maximum 16 KiB)")
|
||||
}
|
||||
if cfg.Token == "" {
|
||||
cfg.Token = saved.Token
|
||||
}
|
||||
if cfg.QueueID == "" {
|
||||
cfg.QueueID = saved.QueueID
|
||||
}
|
||||
}
|
||||
if cfg.URL == "" || cfg.Token == "" || !validID(cfg.QueueID) {
|
||||
return config{}, errors.New("URL, token and a 32-character lowercase hexadecimal queue ID are required")
|
||||
}
|
||||
if cfg.Requests < 1 || cfg.Requests > 2000 || cfg.Concurrency < 1 || cfg.Concurrency > 128 || cfg.Workers < 1 || cfg.Workers > 32 || cfg.Capacity < 1 || cfg.Capacity > 256 || cfg.Workers*cfg.Capacity > 1024 {
|
||||
return config{}, errors.New("request, concurrency or Worker count is out of bounds")
|
||||
}
|
||||
if cfg.Timeout < time.Millisecond || cfg.Timeout > time.Minute || cfg.RunTimeout < 2*time.Second || cfg.RunTimeout > time.Hour || cfg.PollInterval < 100*time.Millisecond || cfg.PollInterval > 10*time.Second {
|
||||
return config{}, errors.New("timeout or poll interval is out of bounds")
|
||||
}
|
||||
if cfg.Concurrency > cfg.Requests {
|
||||
cfg.Concurrency = cfg.Requests
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func validID(id string) bool {
|
||||
if len(id) != 32 {
|
||||
return false
|
||||
}
|
||||
for _, c := range id {
|
||||
if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func classify(err error) string {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return "TIMEOUT"
|
||||
}
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return "CANCELLED"
|
||||
}
|
||||
var api *orbit.APIError
|
||||
if errors.As(err, &api) {
|
||||
switch api.StatusCode {
|
||||
case 401, 403:
|
||||
return "AUTH"
|
||||
case 409:
|
||||
return "CONFLICT"
|
||||
case 429:
|
||||
return "BACKPRESSURE"
|
||||
case 400, 422:
|
||||
return "INVALID_REQUEST"
|
||||
}
|
||||
return "HTTP"
|
||||
}
|
||||
var h *orbit.HTTPError
|
||||
if errors.As(err, &h) {
|
||||
return "HTTP"
|
||||
}
|
||||
return "TRANSPORT"
|
||||
}
|
||||
424
sdk/go/cmd/orbit-workflow-load/measure.go
Normal file
424
sdk/go/cmd/orbit-workflow-load/measure.go
Normal file
@@ -0,0 +1,424 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
|
||||
)
|
||||
|
||||
// workflowPayload 是两个并行分支共享的固定输入;不含凭据或外部地址。
|
||||
type workflowPayload struct {
|
||||
RunID string `json:"run_id"` // RunID 隔离本次测量,不作为业务幂等保证。
|
||||
Sequence int `json:"sequence"` // Sequence 从0开始的实例序号,用于发现错配和重复调用。
|
||||
}
|
||||
|
||||
func decodePayload(raw json.RawMessage) (workflowPayload, bool) {
|
||||
var wire struct {
|
||||
RunID *string `json:"run_id"`
|
||||
Sequence *int `json:"sequence"`
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if decoder.Decode(&wire) != nil || wire.RunID == nil || wire.Sequence == nil {
|
||||
return workflowPayload{}, false
|
||||
}
|
||||
var trailing any
|
||||
if decoder.Decode(&trailing) != io.EOF {
|
||||
return workflowPayload{}, false
|
||||
}
|
||||
return workflowPayload{RunID: *wire.RunID, Sequence: *wire.Sequence}, true
|
||||
}
|
||||
|
||||
func handlerName(runID string) string { return "load.workflow." + runID }
|
||||
func definitionKey(runID string) string { return "orbit-workflow-load-" + runID }
|
||||
func requestKey(runID string, sequence int) string {
|
||||
return fmt.Sprintf("orbit-workflow-%s-%06d", runID, sequence)
|
||||
}
|
||||
func businessKey(runID string, sequence int) string {
|
||||
return fmt.Sprintf("orbit-workflow-%s-%06d", runID, sequence)
|
||||
}
|
||||
|
||||
type workflowAPI interface {
|
||||
GetQueue(context.Context, string) (orbit.Queue, error)
|
||||
CreateWorkflowDefinition(context.Context, string, orbit.CreateWorkflowDefinitionRequest) (orbit.WorkflowDefinitionResult, error)
|
||||
PublishWorkflowVersion(context.Context, string, string, orbit.PublishWorkflowVersionRequest) (orbit.WorkflowVersionResult, error)
|
||||
StartWorkflow(context.Context, string, orbit.StartWorkflowRequest) (orbit.WorkflowStartResult, error)
|
||||
GetWorkflow(context.Context, string) (orbit.WorkflowInstance, error)
|
||||
GetWorkflowSteps(context.Context, string) (orbit.WorkflowSteps, error)
|
||||
GetTask(context.Context, string) (orbit.Task, error)
|
||||
ListTaskAttempts(context.Context, string, orbit.HistoryPageRequest) (orbit.AttemptPage, error)
|
||||
GetTaskSource(context.Context, string) (orbit.TaskSource, error)
|
||||
}
|
||||
|
||||
type workflowAdmission struct {
|
||||
sequence int
|
||||
instance orbit.WorkflowInstance
|
||||
duration time.Duration
|
||||
code string
|
||||
}
|
||||
|
||||
type workflowVerification struct {
|
||||
sequence int
|
||||
duration time.Duration
|
||||
code string
|
||||
}
|
||||
|
||||
// invocationLog 有界保存每个流程的实际处理器调用,两个并行分支各应调用一次。
|
||||
type invocationLog struct {
|
||||
mu sync.Mutex
|
||||
calls []int
|
||||
invalid int
|
||||
}
|
||||
|
||||
func newInvocationLog(n int) *invocationLog { return &invocationLog{calls: make([]int, n)} }
|
||||
|
||||
func (log *invocationLog) handler(ctx context.Context, cfg config, runID string) orbit.WorkerHandler {
|
||||
return orbit.WorkerHandler{Capability: orbit.WorkerCapability{Handler: handlerName(runID), HandlerVersion: "v1", PayloadVersion: "v1"}, Handle: func(work context.Context, execution orbit.WorkerExecution) (json.RawMessage, error) {
|
||||
payload, valid := decodePayload(execution.Task.Payload)
|
||||
if !valid || payload.RunID != runID || payload.Sequence < 0 || payload.Sequence >= cfg.Requests || execution.Task.Handler != handlerName(runID) || execution.Task.HandlerVersion != "v1" || execution.Task.PayloadVersion != "v1" || execution.Task.QueueID != cfg.QueueID {
|
||||
log.mu.Lock()
|
||||
log.invalid++
|
||||
log.mu.Unlock()
|
||||
return nil, &orbit.WorkerFailure{Code: "WORKFLOW_INPUT_MISMATCH"}
|
||||
}
|
||||
log.mu.Lock()
|
||||
log.calls[payload.Sequence]++
|
||||
log.mu.Unlock()
|
||||
select {
|
||||
case <-work.Done():
|
||||
return nil, work.Err()
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
return execution.Task.Payload, nil
|
||||
}}
|
||||
}
|
||||
|
||||
func workflowSpec(queue, handler string, timeout time.Duration) orbit.WorkflowSpecification {
|
||||
typ := orbit.WorkflowType{Kind: "OBJECT", Fields: map[string]orbit.WorkflowType{"run_id": {Kind: "STRING"}, "sequence": {Kind: "NUMBER"}}}
|
||||
task := orbit.WorkflowTask{QueueID: queue, Handler: handler, HandlerVersion: "v1", PayloadVersion: "v1", InputType: typ, Input: orbit.WorkflowRef("input"), ResultType: typ, TimeoutMS: uint64(timeout.Milliseconds()), Retry: orbit.RetryPolicy{MaxAttempts: 1, AttemptTimeoutMS: uint64(timeout.Milliseconds())}, RecoverySafety: orbit.RecoveryReconcileOnly}
|
||||
parallel := orbit.WorkflowParallel{Branches: []orbit.WorkflowParallelBranch{
|
||||
{Name: "left", Body: orbit.WorkflowBlock{Steps: []orbit.WorkflowStep{{Key: "left", Kind: "TASK", Task: &task}}, OutputType: typ, Output: orbit.WorkflowRef("left")}},
|
||||
{Name: "right", Body: orbit.WorkflowBlock{Steps: []orbit.WorkflowStep{{Key: "right", Kind: "TASK", Task: &task}}, OutputType: typ, Output: orbit.WorkflowRef("right")}},
|
||||
}}
|
||||
output := orbit.WorkflowType{Kind: "OBJECT", Fields: map[string]orbit.WorkflowType{"left": typ, "right": typ}}
|
||||
return orbit.WorkflowSpecification{SchemaVersion: 1, InputType: typ, TimeoutMS: uint64(timeout.Milliseconds()), FailurePolicy: "REVIEW", Body: orbit.WorkflowBlock{Steps: []orbit.WorkflowStep{{Key: "pair", Kind: "PARALLEL", Parallel: ¶llel}}, OutputType: output, Output: orbit.WorkflowRef("pair")}}
|
||||
}
|
||||
|
||||
func measure(parent context.Context, cfg config, api *orbit.Client, runID string) workflowReport {
|
||||
return measureClient(parent, cfg, api, runID)
|
||||
}
|
||||
|
||||
func measureClient(parent context.Context, cfg config, api *orbit.Client, runID string) workflowReport {
|
||||
r := newReport(cfg, runID)
|
||||
ctx, cancel := context.WithTimeout(parent, cfg.RunTimeout)
|
||||
defer cancel()
|
||||
requestCtx, stop := context.WithTimeout(ctx, cfg.Timeout)
|
||||
queue, err := api.GetQueue(requestCtx, cfg.QueueID)
|
||||
stop()
|
||||
if err != nil {
|
||||
r.Errors["PREFLIGHT_"+classify(err)]++
|
||||
return r.finish()
|
||||
}
|
||||
r.Queue = &queue
|
||||
if queue.ID != cfg.QueueID || queue.Paused || queue.MaxConcurrency == 0 || queue.RatePerSecond == 0 {
|
||||
r.Errors["QUEUE_UNAVAILABLE"]++
|
||||
return r.finish()
|
||||
}
|
||||
handler := handlerName(runID)
|
||||
typicalTimeout := cfg.RunTimeout
|
||||
if typicalTimeout > 30*time.Second {
|
||||
typicalTimeout = 30 * time.Second
|
||||
}
|
||||
spec := workflowSpec(cfg.QueueID, handler, typicalTimeout)
|
||||
requestCtx, stop = context.WithTimeout(ctx, cfg.Timeout)
|
||||
definition, err := api.CreateWorkflowDefinition(requestCtx, definitionKey(runID), orbit.CreateWorkflowDefinitionRequest{Name: definitionKey(runID), Description: "bounded parallel workflow verification"})
|
||||
stop()
|
||||
if err != nil {
|
||||
r.Errors["CREATE_"+classify(err)]++
|
||||
return r.finish()
|
||||
}
|
||||
r.DefinitionID = definition.Definition.ID
|
||||
if !validID(r.DefinitionID) {
|
||||
r.Errors["INVALID_DEFINITION_RECEIPT"]++
|
||||
return r.finish()
|
||||
}
|
||||
requestCtx, stop = context.WithTimeout(ctx, cfg.Timeout)
|
||||
version, err := api.PublishWorkflowVersion(requestCtx, r.DefinitionID, runID, orbit.PublishWorkflowVersionRequest{Version: "v1", Specification: spec, Reason: "bounded parallel workflow verification"})
|
||||
stop()
|
||||
if err != nil {
|
||||
r.Errors["PUBLISH_"+classify(err)]++
|
||||
return r.finish()
|
||||
}
|
||||
r.VersionID = version.Version.ID
|
||||
if !validID(r.VersionID) || version.Version.Version != "v1" || version.Version.DefinitionID != r.DefinitionID {
|
||||
r.Errors["INVALID_VERSION_RECEIPT"]++
|
||||
return r.finish()
|
||||
}
|
||||
|
||||
log := newInvocationLog(cfg.Requests)
|
||||
workers := make([]*orbit.Worker, 0, cfg.Workers)
|
||||
for i := 0; i < cfg.Workers; i++ {
|
||||
worker, e := orbit.NewWorker(api, orbit.WorkerOptions{QueueID: cfg.QueueID, Capacity: uint32(cfg.Capacity), ShutdownTimeout: 5 * time.Second, Handlers: []orbit.WorkerHandler{log.handler(ctx, cfg, runID)}})
|
||||
if e != nil {
|
||||
r.Errors["WORKER_CONFIG"]++
|
||||
return r.finish()
|
||||
}
|
||||
workers = append(workers, worker)
|
||||
}
|
||||
workerCtx, stopWorkers := context.WithCancel(ctx)
|
||||
done := make(chan error, len(workers))
|
||||
for _, worker := range workers {
|
||||
go func(w *orbit.Worker) {
|
||||
e := w.Run(workerCtx)
|
||||
if workerCtx.Err() == nil {
|
||||
cancel()
|
||||
}
|
||||
done <- e
|
||||
}(worker)
|
||||
}
|
||||
|
||||
admissions := submitWorkflows(ctx, cfg, api, runID, r.DefinitionID)
|
||||
r.Attempted = len(admissions)
|
||||
r.NotSubmitted = cfg.Requests - r.Attempted
|
||||
var accepted []workflowAdmission
|
||||
var admissionDurations []time.Duration
|
||||
for _, a := range admissions {
|
||||
admissionDurations = append(admissionDurations, a.duration)
|
||||
if a.code != "" {
|
||||
r.AdmissionErrors++
|
||||
r.Errors["ADMISSION_"+a.code]++
|
||||
continue
|
||||
}
|
||||
accepted = append(accepted, a)
|
||||
}
|
||||
r.Accepted = len(accepted)
|
||||
r.Admission = distributionOf(admissionDurations)
|
||||
r.AdmissionFinishedAt = time.Now().UTC()
|
||||
verified := verifyWorkflows(ctx, cfg, api, runID, r.DefinitionID, accepted)
|
||||
var completions []time.Duration
|
||||
for _, v := range verified {
|
||||
if v.code != "" {
|
||||
r.Errors[v.code]++
|
||||
continue
|
||||
}
|
||||
r.VerifiedSuccess++
|
||||
completions = append(completions, v.duration)
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
r.Errors["RUN_"+classify(ctx.Err())]++
|
||||
}
|
||||
stopWorkers()
|
||||
for range workers {
|
||||
if e := <-done; e != nil {
|
||||
r.Errors["WORKER_STOP"]++
|
||||
}
|
||||
}
|
||||
for _, worker := range workers {
|
||||
stats := worker.Stats()
|
||||
r.WorkerStarted += stats.Started
|
||||
r.Reconnects += stats.Reconnects
|
||||
r.Renewals += stats.RenewalsConfirmed
|
||||
}
|
||||
log.mu.Lock()
|
||||
for _, calls := range log.calls {
|
||||
r.HandlerCalls += calls
|
||||
if calls > 2 {
|
||||
r.DuplicateInvocations += calls - 2
|
||||
}
|
||||
if calls != 2 {
|
||||
r.Errors["HANDLER_CALL_MISMATCH"]++
|
||||
}
|
||||
}
|
||||
r.Errors["INVALID_HANDLER_INPUT"] += log.invalid
|
||||
log.mu.Unlock()
|
||||
r.Completion = distributionOf(completions)
|
||||
return r.finish()
|
||||
}
|
||||
|
||||
func submitWorkflows(ctx context.Context, cfg config, api workflowAPI, runID, definitionID string) []workflowAdmission {
|
||||
jobs := make(chan int)
|
||||
out := make(chan workflowAdmission, cfg.Requests)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < cfg.Concurrency; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for sequence := range jobs {
|
||||
if ctx.Err() != nil {
|
||||
continue
|
||||
}
|
||||
payload, _ := json.Marshal(workflowPayload{RunID: runID, Sequence: sequence})
|
||||
started := time.Now()
|
||||
call, stop := context.WithTimeout(ctx, cfg.Timeout)
|
||||
result, err := api.StartWorkflow(call, requestKey(runID, sequence), orbit.StartWorkflowRequest{DefinitionID: definitionID, Version: "v1", Input: payload, BusinessKey: businessKey(runID, sequence)})
|
||||
stop()
|
||||
a := workflowAdmission{sequence: sequence, instance: result.Instance, duration: time.Since(started)}
|
||||
if err != nil {
|
||||
a.code = classify(err)
|
||||
} else if result.Deduplicated || !validID(result.Instance.ID) || result.Instance.DefinitionID != definitionID || result.Instance.Version != "v1" || result.Instance.BusinessKey != businessKey(runID, sequence) {
|
||||
a.code = "INVALID_RECEIPT"
|
||||
}
|
||||
out <- a
|
||||
}
|
||||
}()
|
||||
}
|
||||
feed:
|
||||
for sequence := 0; sequence < cfg.Requests; sequence++ {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
break feed
|
||||
case jobs <- sequence:
|
||||
}
|
||||
}
|
||||
close(jobs)
|
||||
wg.Wait()
|
||||
close(out)
|
||||
results := make([]workflowAdmission, 0, cfg.Requests)
|
||||
for result := range out {
|
||||
results = append(results, result)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func verifyWorkflows(ctx context.Context, cfg config, api workflowAPI, runID, definitionID string, admissions []workflowAdmission) []workflowVerification {
|
||||
jobs := make(chan workflowAdmission, len(admissions))
|
||||
for _, admission := range admissions {
|
||||
jobs <- admission
|
||||
}
|
||||
close(jobs)
|
||||
out := make(chan workflowVerification, len(admissions))
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < cfg.Concurrency; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for admission := range jobs {
|
||||
duration, code := verifyWorkflow(ctx, cfg, api, runID, definitionID, admission)
|
||||
out <- workflowVerification{sequence: admission.sequence, duration: duration, code: code}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(out)
|
||||
results := make([]workflowVerification, 0, len(admissions))
|
||||
for result := range out {
|
||||
results = append(results, result)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func verifyWorkflow(ctx context.Context, cfg config, api workflowAPI, runID, definitionID string, admission workflowAdmission) (time.Duration, string) {
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return 0, "VERIFY_" + classify(ctx.Err())
|
||||
}
|
||||
call, stop := context.WithTimeout(ctx, cfg.Timeout)
|
||||
instance, err := api.GetWorkflow(call, admission.instance.ID)
|
||||
stop()
|
||||
if err != nil {
|
||||
return 0, "READ_" + classify(err)
|
||||
}
|
||||
if instance.ID != admission.instance.ID || instance.DefinitionID != definitionID || instance.Version != "v1" || instance.BusinessKey != businessKey(runID, admission.sequence) {
|
||||
return 0, "WORKFLOW_MISMATCH"
|
||||
}
|
||||
switch instance.State {
|
||||
case "SUCCEEDED":
|
||||
if instance.CompletedAt == nil || instance.CreatedAt.IsZero() || instance.CompletedAt.Before(instance.CreatedAt) {
|
||||
return 0, "COMPLETION_MISMATCH"
|
||||
}
|
||||
call, stop = context.WithTimeout(ctx, cfg.Timeout)
|
||||
steps, e := api.GetWorkflowSteps(call, instance.ID)
|
||||
stop()
|
||||
if e != nil {
|
||||
return 0, "STEPS_" + classify(e)
|
||||
}
|
||||
if code := verifySteps(ctx, cfg, api, instance, runID, admission.sequence, steps); code != "" {
|
||||
return 0, code
|
||||
}
|
||||
if !matchesOutput(instance.Output, runID, admission.sequence) {
|
||||
return 0, "OUTPUT_MISMATCH"
|
||||
}
|
||||
return instance.CompletedAt.Sub(instance.CreatedAt), ""
|
||||
case "NEEDS_REVIEW", "FAILED", "CANCELLED":
|
||||
return 0, "WORKFLOW_" + instance.State
|
||||
case "RUNNING", "WAITING", "PAUSED", "PENDING":
|
||||
default:
|
||||
return 0, "UNKNOWN_WORKFLOW_STATE"
|
||||
}
|
||||
timer := time.NewTimer(cfg.PollInterval)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return 0, "VERIFY_" + classify(ctx.Err())
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func verifySteps(ctx context.Context, cfg config, api workflowAPI, instance orbit.WorkflowInstance, runID string, sequence int, steps orbit.WorkflowSteps) string {
|
||||
var taskSteps []orbit.WorkflowStepStatus
|
||||
for _, step := range steps.Steps {
|
||||
if step.Kind == "TASK" {
|
||||
taskSteps = append(taskSteps, step)
|
||||
}
|
||||
}
|
||||
if len(taskSteps) != 2 {
|
||||
return "STEP_COUNT_MISMATCH"
|
||||
}
|
||||
for _, step := range taskSteps {
|
||||
if step.State != "SUCCEEDED" || step.TaskID == nil || !validID(*step.TaskID) || step.ActivationSequence != 1 || step.ActivationID == nil {
|
||||
return "STEP_MISMATCH"
|
||||
}
|
||||
call, stop := context.WithTimeout(ctx, cfg.Timeout)
|
||||
task, err := api.GetTask(call, *step.TaskID)
|
||||
stop()
|
||||
if err != nil {
|
||||
return "TASK_" + classify(err)
|
||||
}
|
||||
payload, valid := decodePayload(task.Result)
|
||||
if task.ID != *step.TaskID || task.State != orbit.TaskSucceeded || task.EffectUncertain || task.AttemptCount != 1 || !valid || payload != (workflowPayload{RunID: runID, Sequence: sequence}) {
|
||||
return "TASK_MISMATCH"
|
||||
}
|
||||
call, stop = context.WithTimeout(ctx, cfg.Timeout)
|
||||
history, err := api.ListTaskAttempts(call, task.ID, orbit.HistoryPageRequest{Limit: 2})
|
||||
stop()
|
||||
if err != nil {
|
||||
return "HISTORY_" + classify(err)
|
||||
}
|
||||
if len(history.Items) != 1 || history.NextCursor != "" || history.Items[0].TaskID != task.ID || history.Items[0].State != orbit.AttemptSucceeded {
|
||||
return "ATTEMPT_MISMATCH"
|
||||
}
|
||||
call, stop = context.WithTimeout(ctx, cfg.Timeout)
|
||||
source, err := api.GetTaskSource(call, task.ID)
|
||||
stop()
|
||||
if err != nil || source.Kind != "WORKFLOW" || source.Workflow == nil || source.Workflow.WorkflowID != instance.ID || source.Workflow.DefinitionID != instance.DefinitionID || source.Workflow.DefinitionVersionID != instance.DefinitionVersionID || source.Workflow.Version != instance.Version || source.Workflow.StepID != step.ID || source.Workflow.ActivationID != *step.ActivationID || source.Workflow.ActivationSequence != 1 {
|
||||
return "SOURCE_MISMATCH"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func matchesOutput(raw json.RawMessage, runID string, sequence int) bool {
|
||||
var wire struct {
|
||||
Left workflowPayload `json:"left"`
|
||||
Right workflowPayload `json:"right"`
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if decoder.Decode(&wire) != nil {
|
||||
return false
|
||||
}
|
||||
var trailing any
|
||||
if decoder.Decode(&trailing) != io.EOF {
|
||||
return false
|
||||
}
|
||||
return wire.Left == (workflowPayload{RunID: runID, Sequence: sequence}) && wire.Right == wire.Left
|
||||
}
|
||||
61
sdk/go/cmd/orbit-workflow-load/measure_test.go
Normal file
61
sdk/go/cmd/orbit-workflow-load/measure_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDecodePayloadRejectsMissingNullUnknownAndTrailing(t *testing.T) {
|
||||
want := workflowPayload{RunID: "run", Sequence: 3}
|
||||
for _, raw := range []string{
|
||||
`{"run_id":"run","sequence":3}`,
|
||||
`{"run_id":"run","sequence":3} `,
|
||||
} {
|
||||
got, ok := decodePayload(json.RawMessage(raw))
|
||||
if !ok || !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("decode %s: %#v %t", raw, got, ok)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{
|
||||
`{"run_id":"run"}`,
|
||||
`{"run_id":null,"sequence":3}`,
|
||||
`{"run_id":"run","sequence":null}`,
|
||||
`{"run_id":"run","sequence":3,"extra":true}`,
|
||||
`{"run_id":"run","sequence":3}{"again":true}`,
|
||||
} {
|
||||
if _, ok := decodePayload(json.RawMessage(raw)); ok {
|
||||
t.Fatalf("accepted invalid payload %s", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflowSpecHasFiniteParallelBranchesAndFixedRetry(t *testing.T) {
|
||||
spec := workflowSpec("0123456789abcdef0123456789abcdef", "load.workflow.run", 5*time.Second)
|
||||
if len(spec.Body.Steps) != 1 || spec.Body.Steps[0].Kind != "PARALLEL" || spec.Body.Steps[0].Parallel == nil || len(spec.Body.Steps[0].Parallel.Branches) != 2 {
|
||||
t.Fatal("parallel shape changed")
|
||||
}
|
||||
for _, branch := range spec.Body.Steps[0].Parallel.Branches {
|
||||
if len(branch.Body.Steps) != 1 || branch.Body.Steps[0].Task == nil || branch.Body.Steps[0].Task.Retry.MaxAttempts != 1 || branch.Body.Steps[0].Task.RecoverySafety != "RECONCILE_ONLY" {
|
||||
t.Fatalf("unsafe branch %#v", branch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputVerificationRequiresBothBranches(t *testing.T) {
|
||||
runID := "run"
|
||||
good := `{"left":{"run_id":"run","sequence":3},"right":{"run_id":"run","sequence":3}}`
|
||||
if !matchesOutput(json.RawMessage(good), runID, 3) {
|
||||
t.Fatal("valid output rejected")
|
||||
}
|
||||
for _, raw := range []string{
|
||||
`{"left":{"run_id":"run","sequence":3}}`,
|
||||
`{"left":{"run_id":"run","sequence":3},"right":{"run_id":"other","sequence":3}}`,
|
||||
`{"left":{"run_id":"run","sequence":3},"right":{"run_id":"run","sequence":3},"extra":true}`,
|
||||
} {
|
||||
if matchesOutput(json.RawMessage(raw), runID, 3) {
|
||||
t.Fatalf("accepted invalid output %s", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
85
sdk/go/cmd/orbit-workflow-load/report.go
Normal file
85
sdk/go/cmd/orbit-workflow-load/report.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"math"
|
||||
"runtime"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
|
||||
)
|
||||
|
||||
// distribution 只统计完成核验的实例;空集合的百分位保持null。
|
||||
type distribution struct {
|
||||
Count int `json:"count"` // Count 纳入本项统计的样本数。
|
||||
P50 *float64 `json:"p50_ms"` // P50 最近秩中位数,毫秒。
|
||||
P95 *float64 `json:"p95_ms"` // P95 最近秩95百分位,毫秒。
|
||||
P99 *float64 `json:"p99_ms"` // P99 最近秩99百分位,毫秒。
|
||||
Max *float64 `json:"max_ms"` // Max 纳入样本的最大毫秒数。
|
||||
}
|
||||
|
||||
// workflowReport 是一次有界流程测量结果,不是容量承诺或生产高可用验收。
|
||||
type workflowReport struct {
|
||||
RunID string `json:"run_id"` // RunID 本次定义、实例和处理器身份的隔离前缀。
|
||||
GoVersion string `json:"go_version"` // GoVersion 实际编译工具链版本。
|
||||
GOOS string `json:"goos"` // GOOS 负载进程操作系统。
|
||||
GOARCH string `json:"goarch"` // GOARCH 负载进程架构。
|
||||
StartedAt time.Time `json:"started_at"` // StartedAt 从预检开始的本机时间。
|
||||
AdmissionFinishedAt time.Time `json:"admission_finished_at"` // AdmissionFinishedAt 实例受理组完成时间。
|
||||
FinishedAt time.Time `json:"finished_at"` // FinishedAt 核验及Worker收尾完成时间。
|
||||
Requests int `json:"requests"` // Requests 计划启动的流程实例数。
|
||||
Concurrency int `json:"concurrency"` // Concurrency 启动和核验请求的最大并发。
|
||||
Workers int `json:"workers"` // Workers 本进程独立WSS Worker数。
|
||||
Capacity int `json:"capacity_per_worker"` // Capacity 每个Worker的执行槽上限。
|
||||
RunTimeoutMS int64 `json:"run_timeout_ms"` // RunTimeoutMS 整次测量的截止时间。
|
||||
PollIntervalMS int64 `json:"poll_interval_ms"` // PollIntervalMS 流程状态轮询间隔。
|
||||
HTTPTimeoutMS int64 `json:"http_timeout_ms"` // HTTPTimeoutMS 单次HTTP交换截止时间。
|
||||
Queue *orbit.Queue `json:"queue,omitempty"` // Queue 预检时的队列快照,不修改配置。
|
||||
DefinitionID string `json:"definition_id,omitempty"` // DefinitionID 本次新建的稳定定义身份。
|
||||
VersionID string `json:"version_id,omitempty"` // VersionID 本次发布的不可变版本身份。
|
||||
Attempted int `json:"attempted"` // Attempted 实际发起的启动调用数。
|
||||
NotSubmitted int `json:"not_submitted"` // NotSubmitted 截止前未发起的实例数。
|
||||
Accepted int `json:"accepted"` // Accepted 收到并核验唯一启动回执的实例数。
|
||||
AdmissionErrors int `json:"admission_errors"` // AdmissionErrors 明确拒绝或回执不合法的实例数。
|
||||
VerifiedSuccess int `json:"verified_success"` // VerifiedSuccess 流程、步骤、任务、来源和结果均核验通过数。
|
||||
Unverified int `json:"unverified"` // Unverified 已受理但没有完成全部核验的实例数。
|
||||
HandlerCalls int `json:"handler_calls"` // HandlerCalls 有效处理器调用总数,两个分支各一次。
|
||||
DuplicateInvocations int `json:"duplicate_invocations"` // DuplicateInvocations 超过两个分支预期的额外调用数。
|
||||
WorkerStarted uint64 `json:"worker_started"` // WorkerStarted SDK本进程领取并启动的任务数。
|
||||
Reconnects uint64 `json:"worker_reconnects"` // Reconnects SDK重连次数。
|
||||
Renewals uint64 `json:"worker_renewals_confirmed"` // Renewals SDK确认的租约续期次数。
|
||||
Admission distribution `json:"admission"` // Admission 启动请求本机往返耗时。
|
||||
Completion distribution `json:"completion"` // Completion 数据库实例创建至完成的服务端耗时。
|
||||
Errors map[string]int `json:"errors"` // Errors 稳定错误分类,不写入凭据、SQL或请求正文。
|
||||
Complete bool `json:"complete"` // Complete 仅全部实例和两倍处理器调用逐项通过核验时为真。
|
||||
}
|
||||
|
||||
func newReport(cfg config, runID string) workflowReport {
|
||||
return workflowReport{RunID: runID, GoVersion: runtime.Version(), GOOS: runtime.GOOS, GOARCH: runtime.GOARCH, StartedAt: time.Now().UTC(), Requests: cfg.Requests, NotSubmitted: cfg.Requests, Concurrency: cfg.Concurrency, Workers: cfg.Workers, Capacity: cfg.Capacity, RunTimeoutMS: cfg.RunTimeout.Milliseconds(), PollIntervalMS: cfg.PollInterval.Milliseconds(), HTTPTimeoutMS: cfg.Timeout.Milliseconds(), Errors: map[string]int{}}
|
||||
}
|
||||
|
||||
func (r workflowReport) finish() workflowReport {
|
||||
for code, count := range r.Errors {
|
||||
if count == 0 {
|
||||
delete(r.Errors, code)
|
||||
}
|
||||
}
|
||||
r.FinishedAt = time.Now().UTC()
|
||||
r.Unverified = r.Accepted - r.VerifiedSuccess
|
||||
r.Complete = r.Attempted == r.Requests && r.NotSubmitted == 0 && r.AdmissionErrors == 0 && r.Accepted == r.Requests && r.VerifiedSuccess == r.Requests && r.HandlerCalls == 2*r.Requests && r.WorkerStarted == uint64(2*r.Requests) && r.DuplicateInvocations == 0 && len(r.Errors) == 0
|
||||
return r
|
||||
}
|
||||
|
||||
func distributionOf(values []time.Duration) distribution {
|
||||
d := distribution{Count: len(values)}
|
||||
if len(values) == 0 {
|
||||
return d
|
||||
}
|
||||
sort.Slice(values, func(i, j int) bool { return values[i] < values[j] })
|
||||
percentile := func(q float64) *float64 {
|
||||
v := float64(values[int(math.Ceil(float64(len(values))*q))-1]) / float64(time.Millisecond)
|
||||
return &v
|
||||
}
|
||||
d.P50, d.P95, d.P99, d.Max = percentile(.5), percentile(.95), percentile(.99), percentile(1)
|
||||
return d
|
||||
}
|
||||
Reference in New Issue
Block a user