feat: expose workflow runtime through HTTP and Go SDK
This commit is contained in:
174
sdk/go/examples/workflow/main.go
Normal file
174
sdk/go/examples/workflow/main.go
Normal file
@@ -0,0 +1,174 @@
|
||||
// workflow 通过公共SDK运行并行处理、计时等待、外部回执与后续发布;业务处理器在Worker中执行。
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
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 {
|
||||
raw, err := os.ReadFile(os.Getenv("ORBIT_CREDENTIALS_FILE"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var credentials struct {
|
||||
Token string `json:"token"`
|
||||
QueueID string `json:"queue_id"`
|
||||
}
|
||||
if json.Unmarshal(raw, &credentials) != nil || credentials.Token == "" || credentials.QueueID == "" {
|
||||
return errors.New("invalid private credentials file")
|
||||
}
|
||||
client, err := orbit.NewClient(os.Getenv("ORBIT_URL"), orbit.Options{Token: credentials.Token})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
operation, err := orbit.NewWorkerMessageID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
handler := "artifact.normalize." + operation
|
||||
typ := orbit.WorkflowType{Kind: "OBJECT", Fields: map[string]orbit.WorkflowType{"text": {Kind: "STRING"}}}
|
||||
task := orbit.WorkflowTask{QueueID: credentials.QueueID, Handler: handler, HandlerVersion: "v1", PayloadVersion: "v1", InputType: typ, Input: orbit.WorkflowRef("input"), ResultType: typ, TimeoutMS: 20000, Retry: orbit.RetryPolicy{MaxAttempts: 1, AttemptTimeoutMS: 5000}, RecoverySafety: orbit.RecoveryIdempotent}
|
||||
pair := 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")}},
|
||||
}}
|
||||
publish := task
|
||||
publish.Input = orbit.WorkflowRef("pair", "left")
|
||||
spec := orbit.WorkflowSpecification{SchemaVersion: 1, TimeoutMS: 60000, FailurePolicy: "REVIEW", InputType: typ, Body: orbit.WorkflowBlock{Steps: []orbit.WorkflowStep{
|
||||
{Key: "pair", Kind: "PARALLEL", Parallel: &pair},
|
||||
{Key: "cooldown", Kind: "TIMER", Timer: &orbit.WorkflowTimer{DelayMS: 500}},
|
||||
{Key: "receipt", Kind: "SIGNAL", Signal: &orbit.WorkflowSignal{Name: "artifact.confirmed", PayloadVersion: "v1", Correlation: orbit.WorkflowValue{Kind: "LITERAL", Literal: json.RawMessage(`"` + operation + `"`)}, PayloadType: orbit.WorkflowType{Kind: "BOOLEAN"}, TimeoutMS: 30000}},
|
||||
{Key: "publish", Kind: "TASK", Task: &publish},
|
||||
}, OutputType: typ, Output: orbit.WorkflowRef("publish")}}
|
||||
definition, err := client.CreateWorkflowDefinition(ctx, operation, orbit.CreateWorkflowDefinitionRequest{Name: "artifact." + operation, Description: "并行结果生成与模拟外部回执"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = client.PublishWorkflowVersion(ctx, definition.Definition.ID, operation, orbit.PublishWorkflowVersionRequest{Version: "v1", Specification: spec, Reason: "publish runnable workflow example"}); err != nil {
|
||||
return err
|
||||
}
|
||||
start := orbit.StartWorkflowRequest{DefinitionID: definition.Definition.ID, Version: "v1", Input: json.RawMessage(`{"text":" hello orbit "}`), BusinessKey: operation}
|
||||
accepted, err := client.StartWorkflow(ctx, operation, start)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
repeated, err := client.StartWorkflow(ctx, operation, start)
|
||||
if err != nil || !repeated.Deduplicated || repeated.Instance.ID != accepted.Instance.ID {
|
||||
return errors.New("start identity was not preserved")
|
||||
}
|
||||
worker, err := orbit.NewWorker(client, orbit.WorkerOptions{QueueID: credentials.QueueID, Capacity: 2, ShutdownTimeout: 5 * time.Second, Handlers: []orbit.WorkerHandler{{Capability: orbit.WorkerCapability{Handler: handler, HandlerVersion: "v1", PayloadVersion: "v1"}, Handle: normalize}}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
workerCtx, stop := context.WithCancel(ctx)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- worker.Run(workerCtx) }()
|
||||
waited := false
|
||||
defer func() {
|
||||
stop()
|
||||
if !waited {
|
||||
<-done
|
||||
}
|
||||
}()
|
||||
var wait orbit.WorkflowStepDetail
|
||||
for {
|
||||
wait, err = client.GetWorkflowStep(ctx, accepted.Instance.ID, "receipt")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if wait.Step.State == "WAITING" {
|
||||
break
|
||||
}
|
||||
current, e := client.GetWorkflow(ctx, accepted.Instance.ID)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if current.State == "NEEDS_REVIEW" || current.State == "FAILED" {
|
||||
return fmt.Errorf("workflow requires attention: %s", current.State)
|
||||
}
|
||||
select {
|
||||
case e := <-done:
|
||||
waited = true
|
||||
return fmt.Errorf("Worker stopped: %v", e)
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
if wait.Wait == nil || wait.Step.ActivationSequence == 0 {
|
||||
return errors.New("wait contract missing")
|
||||
}
|
||||
// 示例模拟外部系统的已验证回执。实际系统由独立、具备信号权限的调用者确认业务事实。
|
||||
signal := orbit.SendWorkflowSignalRequest{Name: *wait.Wait.Name, PayloadVersion: *wait.Wait.PayloadVersion, TargetStepKey: wait.Step.Key, TargetActivationSequence: wait.Step.ActivationSequence, CorrelationKey: wait.Wait.CorrelationKey, Payload: json.RawMessage(`true`), Reason: "simulated verified external receipt"}
|
||||
sent, err := client.SendWorkflowSignal(ctx, accepted.Instance.ID, operation, signal)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
again, err := client.SendWorkflowSignal(ctx, accepted.Instance.ID, operation, signal)
|
||||
if err != nil || !again.Deduplicated || again.Signal.ID != sent.Signal.ID {
|
||||
return errors.New("signal identity was not preserved")
|
||||
}
|
||||
for {
|
||||
current, e := client.GetWorkflow(ctx, accepted.Instance.ID)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if current.State == "SUCCEEDED" {
|
||||
fmt.Printf("workflow=%s state=%s result=%s start_deduplicated=%t signal_deduplicated=%t\n", current.ID, current.State, current.Output, repeated.Deduplicated, again.Deduplicated)
|
||||
break
|
||||
}
|
||||
if current.State == "NEEDS_REVIEW" || current.State == "FAILED" || current.State == "CANCELLED" {
|
||||
return fmt.Errorf("workflow requires attention: %s", current.State)
|
||||
}
|
||||
select {
|
||||
case e := <-done:
|
||||
waited = true
|
||||
return fmt.Errorf("Worker stopped: %v", e)
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
stop()
|
||||
err = <-done
|
||||
waited = true
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stats := worker.Stats()
|
||||
if stats.Started != 3 || stats.ReportsConfirmed != 3 || stats.Running != 0 {
|
||||
return errors.New("unexpected task count or incomplete drain")
|
||||
}
|
||||
fmt.Printf("worker_drained=true started=%d confirmed=%d\n", stats.Started, stats.ReportsConfirmed)
|
||||
return nil
|
||||
}
|
||||
func normalize(ctx context.Context, execution orbit.WorkerExecution) (json.RawMessage, error) {
|
||||
var in struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if json.Unmarshal(execution.Task.Payload, &in) != nil {
|
||||
return nil, &orbit.WorkerFailure{Code: "INVALID_TEXT"}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(struct {
|
||||
Text string `json:"text"`
|
||||
}{strings.ToUpper(strings.TrimSpace(in.Text))})
|
||||
}
|
||||
234
sdk/go/workflow_instance_types.go
Normal file
234
sdk/go/workflow_instance_types.go
Normal file
@@ -0,0 +1,234 @@
|
||||
package orbit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 以下协议类型属于独立SDK,不引用服务端模型或存储模块。
|
||||
// StartWorkflowRequest 使用显式版本;业务追踪键不能替代请求幂等键。
|
||||
type StartWorkflowRequest struct {
|
||||
DefinitionID string `json:"definition_id"` // DefinitionID 同命名空间的稳定定义身份。
|
||||
Version string `json:"version"` // Version 已发布的固定版本名,不解析latest别名。
|
||||
Input json.RawMessage `json:"input"` // Input 按固定版本验证的输入,显式null与未提供不同。
|
||||
BusinessKey string `json:"business_key,omitempty"` // BusinessKey 最多191个Unicode字符,保留尾部空格。
|
||||
}
|
||||
|
||||
// WorkflowInstance 是实例当前权威视图;StartResult中的View冻结在受理事务,不随重报更新。
|
||||
type WorkflowInstance struct {
|
||||
ID string `json:"id"` // ID 实例身份。
|
||||
NamespaceID string `json:"namespace_id"` // NamespaceID 授权及数据隔离范围。
|
||||
DefinitionID string `json:"definition_id"` // DefinitionID 稳定定义身份。
|
||||
DefinitionVersionID string `json:"definition_version_id"` // DefinitionVersionID 固定版本记录身份。
|
||||
Version string `json:"version"` // Version 启动时固定的发布版本名。
|
||||
State string `json:"state"` // State 执行状态,受理不代表业务成功。
|
||||
Revision uint64 `json:"revision,string"` // Revision 实例并发版本。
|
||||
Paused bool `json:"paused"` // Paused 独立暂停标志,不表示在途工作已停止。
|
||||
ControlRevision uint64 `json:"control_revision,string"` // ControlRevision 控制操作并发版本。
|
||||
Input json.RawMessage `json:"input"` // Input 不变的受理输入,保留数字文本。
|
||||
Output json.RawMessage `json:"output,omitempty"` // Output 成功输出;缺失与显式null不同。
|
||||
BusinessKey string `json:"business_key"` // BusinessKey 原始追踪键。
|
||||
DeadlineAt time.Time `json:"deadline_at"` // DeadlineAt 数据库时间确定的固定总截止。
|
||||
CancelRequestedAt *time.Time `json:"cancel_requested_at,omitempty"` // CancelRequestedAt 已保存的取消意图时间。
|
||||
StopKind *string `json:"stop_kind,omitempty"` // StopKind 首次停止原因CANCEL或FAILURE;核查不会清除。
|
||||
StopRequestedAt *time.Time `json:"stop_requested_at,omitempty"` // StopRequestedAt 首次停止意图时间,后续操作不会延长。
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"` // CompletedAt 终结时间,非终态为空。
|
||||
CreatedAt time.Time `json:"created_at"` // CreatedAt UTC受理时间。
|
||||
UpdatedAt time.Time `json:"updated_at"` // UpdatedAt 最近状态更新时间。
|
||||
}
|
||||
type WorkflowStartResult struct {
|
||||
Instance WorkflowInstance `json:"instance"` // Instance 原受理回执的实例快照。
|
||||
Deduplicated bool `json:"deduplicated"` // Deduplicated 原键恢复,不新建工作或续期。
|
||||
}
|
||||
|
||||
// WorkflowStepStatus 只列元数据,避免把最多128个大输入输出聚合为无界响应。
|
||||
type WorkflowStepStatus struct {
|
||||
ActivationSequence uint32 `json:"activation_sequence,omitempty"` // ActivationSequence 当前激活代次;未激活时省略,信号必须显式指定此代次。
|
||||
ID string `json:"id"` // ID 实例内逻辑步骤身份。
|
||||
Key string `json:"key"` // Key 固定定义步骤名称。
|
||||
Kind string `json:"kind"` // Kind 固定节点类型。
|
||||
State string `json:"state"` // State 当前步骤状态。
|
||||
Revision uint64 `json:"revision,string"` // Revision 步骤并发版本。
|
||||
ActivationID *string `json:"activation_id,omitempty"` // ActivationID 当前激活,未激活时为空。
|
||||
TaskID *string `json:"task_id,omitempty"` // TaskID TASK的直接任务;POLL任务通过轮次查询。
|
||||
}
|
||||
type WorkflowSteps struct {
|
||||
InstanceID string `json:"instance_id"` // InstanceID 所属实例。
|
||||
Revision uint64 `json:"revision,string"` // Revision 与步骤读取一致的实例版本。
|
||||
Steps []WorkflowStepStatus `json:"steps"` // Steps 按键排序,最多128个,不含内部根检查点。
|
||||
}
|
||||
|
||||
// WorkflowStepDetail 只读取一个当前激活的输入输出,避免步骤列表聚合大载荷。
|
||||
type WorkflowStepDetail struct {
|
||||
InstanceID string `json:"instance_id"` // InstanceID 授权实例。
|
||||
Revision uint64 `json:"revision,string"` // Revision 与本次步骤详情同一快照的实例版本。
|
||||
Step WorkflowStepStatus `json:"step"` // Step 当前步骤与激活身份。
|
||||
Input json.RawMessage `json:"input,omitempty"` // Input 当前激活输入,未激活时省略,显式null仍保留。
|
||||
Output json.RawMessage `json:"output,omitempty"` // Output 当前激活已有结果,尚无结果时省略。
|
||||
ActivatedAt *time.Time `json:"activated_at,omitempty"` // ActivatedAt 当前激活创建时间,不代表等待预算重新开始。
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"` // CompletedAt 当前激活结束时间;核查或进行中可能为空。
|
||||
Poll *WorkflowPollStatus `json:"poll,omitempty"` // Poll 当前激活的查询预算及最后任务,仅POLL有值。
|
||||
Wait *WorkflowWaitStatus `json:"wait,omitempty"` // Wait 当前信号/定时/最终信号等待,原截止不会因换代延长。
|
||||
}
|
||||
type WorkflowPollStatus struct {
|
||||
UsedRounds uint32 `json:"used_rounds"` // UsedRounds 已用业务轮编号,跨恢复激活累计。
|
||||
FirstRound uint32 `json:"first_round"` // FirstRound 本激活继承的首个业务轮。
|
||||
FirstRoundAttemptsUsed uint32 `json:"first_round_attempts_used"` // FirstRoundAttemptsUsed 该轮此前激活实际已用请求尝试。
|
||||
MaxRounds uint32 `json:"max_rounds"` // MaxRounds 固定业务轮上限。
|
||||
MaxAttemptsPerRound uint32 `json:"max_attempts_per_round"` // MaxAttemptsPerRound 原单轮请求尝试上限。
|
||||
IntervalMS uint64 `json:"interval_ms"` // IntervalMS 成功PENDING后的原查询间隔,毫秒。
|
||||
DeadlineAt time.Time `json:"deadline_at"` // DeadlineAt 第一次查询激活确定的绝对截止。
|
||||
NextPollAt *time.Time `json:"next_poll_at,omitempty"` // NextPollAt 下一业务轮可开始时间;非轮间等待为空。
|
||||
CurrentTaskID *string `json:"current_task_id,omitempty"` // CurrentTaskID 当前尚待收集结果的查询任务。
|
||||
LastTaskID string `json:"last_task_id"` // LastTaskID 本激活最后一轮任务,可据此读取执行详情并核查。
|
||||
ResolvedAt *time.Time `json:"resolved_at,omitempty"` // ResolvedAt 当前查询预算结清时间。
|
||||
}
|
||||
type WorkflowWaitStatus struct {
|
||||
ID string `json:"id"` // ID 当前激活等待身份。
|
||||
Kind string `json:"kind"` // Kind SIGNAL、TIMER或POLL。
|
||||
State string `json:"state"` // State 等待、满足、过期或取消状态。
|
||||
Name *string `json:"name,omitempty"` // Name 信号名称;TIMER及未核验的旧等待为空。
|
||||
PayloadVersion *string `json:"payload_version,omitempty"` // PayloadVersion 冻结信号内容契约。
|
||||
CorrelationKey string `json:"correlation_key"` // CorrelationKey 原精确关联;TIMER为空字符串。
|
||||
DeadlineAt time.Time `json:"deadline_at"` // DeadlineAt 首次等待的绝对截止。
|
||||
WinnerSignalID *string `json:"winner_signal_id,omitempty"` // WinnerSignalID 已选中信号,不表示在途执行已结束。
|
||||
ResolvedAt *time.Time `json:"resolved_at,omitempty"` // ResolvedAt 等待结清时间。
|
||||
}
|
||||
|
||||
// Input 的稳定操作身份由方法参数传入;并发控制只使用control_revision,不与后台结果版本竞争。
|
||||
type WorkflowControlRequest struct {
|
||||
Operation string `json:"operation"` // Operation 为PAUSE、RESUME或CANCEL;终结实例不接受新控制操作。
|
||||
ExpectedControlRevision uint64 `json:"expected_control_revision,string"` // ExpectedControlRevision 调用者观察到的控制版本,必须大于0。
|
||||
Reason string `json:"reason"` // Reason 必填操作原因,最多1024个Unicode字符,进入不可变审计。
|
||||
}
|
||||
type WorkflowControlResult struct {
|
||||
Instance WorkflowInstance `json:"instance"` // Instance 首次操作提交后的冻结回执;当前结果用实例Get读取。
|
||||
Deduplicated bool `json:"deduplicated"` // Deduplicated 原操作恢复,不重复修改控制版本或计时。
|
||||
}
|
||||
type SendWorkflowSignalRequest struct {
|
||||
Name string `json:"name"` // Name 固定定义信号名称,最多128个ASCII字符。
|
||||
PayloadVersion string `json:"payload_version"` // PayloadVersion 固定内容契约版本,最多32个ASCII字符。
|
||||
TargetStepKey string `json:"target_step_key"` // TargetStepKey 固定定义的目标步骤,最多64个ASCII字符。
|
||||
TargetActivationSequence uint32 `json:"target_activation_sequence"` // TargetActivationSequence 显式目标激活代次,首次及早到信号为1,不能自动跟随人工恢复。
|
||||
CorrelationKey string `json:"correlation_key"` // CorrelationKey 1至512字节精确UTF8操作关联,保留尾部空格。
|
||||
Payload json.RawMessage `json:"payload"` // Payload 固定契约内容,最多256KiB,显式null与缺失不同。
|
||||
Reason string `json:"reason"` // Reason 受理原因,1至1024个Unicode字符,进入审计。
|
||||
EvidenceRef string `json:"evidence_ref,omitempty"` // EvidenceRef 可选受控证据引用,最多512个无空白/控制字符,不携带秘密。
|
||||
}
|
||||
|
||||
type WorkflowSignalStatus struct {
|
||||
ID string `json:"id"` // ID 信号持久身份。
|
||||
NamespaceID string `json:"namespace_id"` // NamespaceID 授权和数据隔离范围。
|
||||
WorkflowID string `json:"workflow_id"` // WorkflowID 固定目标实例。
|
||||
Revision uint64 `json:"revision,string"` // Revision 当前信号记录版本。
|
||||
Name string `json:"name"` // Name 固定信号类型。
|
||||
PayloadVersion string `json:"payload_version"` // PayloadVersion 固定契约版本。
|
||||
TargetStepKey string `json:"target_step_key"` // TargetStepKey 目标步骤名称。
|
||||
TargetActivationSequence uint32 `json:"target_activation_sequence"` // TargetActivationSequence 固定激活序号。
|
||||
CorrelationKey string `json:"correlation_key"` // CorrelationKey 原始操作关联。
|
||||
Payload json.RawMessage `json:"payload"` // Payload 原始精确内容。
|
||||
ActorID string `json:"actor_id"` // ActorID 平台认证主体,不接受请求自报身份。
|
||||
EvidenceRef string `json:"evidence_ref,omitempty"` // EvidenceRef 原证据引用。
|
||||
State string `json:"state"` // State PENDING/CONSUMED/EXPIRED/REJECTED。
|
||||
Disposition string `json:"disposition,omitempty"` // Disposition 稳定消费或拒绝原因。
|
||||
AcceptedAt time.Time `json:"accepted_at"` // AcceptedAt 数据库可靠受理时间,截止不含等号。
|
||||
ExpiresAt time.Time `json:"expires_at"` // ExpiresAt 固定实例总截止,不因重报延长。
|
||||
ConsumedWaitID *string `json:"consumed_wait_id,omitempty"` // ConsumedWaitID 消费它的唯一等待身份。
|
||||
}
|
||||
type WorkflowSignalResult struct {
|
||||
Signal WorkflowSignalStatus `json:"signal"` // Signal 首次受理的冻结视图,当前状态通过Get查询。
|
||||
Deduplicated bool `json:"deduplicated"` // Deduplicated 原身份恢复,不重报新事件或重新计时。
|
||||
}
|
||||
|
||||
// WorkflowRecoveryDecision 核查基于整个实例的最新证据版本;后台新事实会使旧决定失效,原键仍可恢复回执。
|
||||
type WorkflowRecoveryDecision struct {
|
||||
ExpectedRevision uint64 `json:"expected_revision,string"` // ExpectedRevision 当前实例版本,不能使用control_revision代替。
|
||||
Reason string `json:"reason"` // Reason 必填核查原因,最多1024个Unicode字符。
|
||||
EvidenceRef string `json:"evidence_ref"` // EvidenceRef 受控证据引用,最多512个无空白或控制字符的Unicode字符;平台不请求地址。
|
||||
}
|
||||
|
||||
type ReviewWorkflowTaskRequest struct {
|
||||
WorkflowRecoveryDecision
|
||||
TaskID string `json:"task_id"` // TaskID 本实例任一历史激活拥有的待核查Pulse任务。
|
||||
ExpectedTaskRevision uint64 `json:"expected_task_revision,string"` // ExpectedTaskRevision 任务当前状态版本。
|
||||
ExecutionSettled bool `json:"execution_settled"` // ExecutionSettled 显式确认该任务全部历史外部执行已结束且不会继续产生效果。
|
||||
Outcome TaskState `json:"outcome"` // Outcome SUCCEEDED或FAILED,表示该次请求的核查结论,不代替POLL业务结果。
|
||||
Result json.RawMessage `json:"result"` // Result 非null的核查结果,最多256 KiB,保留精确数字。
|
||||
}
|
||||
|
||||
type WorkflowStepDecision struct {
|
||||
WorkflowRecoveryDecision
|
||||
StepKey string `json:"step_key"` // StepKey 固定定义中的当前步骤,不接受内部根步骤。
|
||||
ActivationID string `json:"activation_id"` // ActivationID 调用者核查的当前激活,禁止旧激活作用于新工作。
|
||||
ExpectedStepRevision uint64 `json:"expected_step_revision,string"` // ExpectedStepRevision 步骤版本,与实例版本共同校验。
|
||||
}
|
||||
|
||||
type ResolveWorkflowStepRequest struct {
|
||||
WorkflowStepDecision
|
||||
ExecutionSettled bool `json:"execution_settled"` // ExecutionSettled 确认本步骤关联外部执行与业务结果已核实。
|
||||
Outcome string `json:"outcome"` // Outcome SUCCEEDED或FAILED;成功必须符合固定节点输出契约。
|
||||
Result json.RawMessage `json:"result"` // Result 核查后的逻辑输出;TIMER成功输出为显式null,失败结论保留在审计摘要。
|
||||
}
|
||||
|
||||
type ContinueWorkflowRequest struct {
|
||||
WorkflowRecoveryDecision
|
||||
ReviewResolved bool `json:"review_resolved"` // ReviewResolved 确认全部核查事项已处理;服务端仍检查未决步骤、历史任务与Inbox。
|
||||
}
|
||||
|
||||
type RetryWorkflowStepRequest struct {
|
||||
WorkflowStepDecision
|
||||
SafeToRetry bool `json:"safe_to_retry"` // SafeToRetry 确认旧执行已结束,并按原业务身份再次执行安全;不能只依据未收到响应。
|
||||
}
|
||||
|
||||
type ContinueWorkflowPollRequest struct {
|
||||
WorkflowStepDecision
|
||||
PendingConfirmed bool `json:"pending_confirmed"` // PendingConfirmed 确认权威查询结果为处理中,按原间隔与剩余业务轮次接续。
|
||||
}
|
||||
|
||||
type RearmWorkflowWaitRequest struct {
|
||||
WorkflowStepDecision
|
||||
WaitVerified bool `json:"wait_verified"` // WaitVerified 已核验当前等待及旧回执,按原契约/截止开启新等待代次。
|
||||
}
|
||||
|
||||
type WorkflowRecoveryResult struct {
|
||||
Instance WorkflowInstance `json:"instance"` // Instance 操作提交时的冻结实例回执;核查步骤不自动解除实例核查。
|
||||
AuditID string `json:"audit_id"` // AuditID 本次流程管理操作的不可变审计标识。
|
||||
Step *WorkflowStepStatus `json:"step,omitempty"` // Step 被核查或重新激活步骤的回执。
|
||||
Task *Task `json:"task,omitempty"` // Task 被核查或新创建的执行任务。
|
||||
TaskAuditID *string `json:"task_audit_id,omitempty"` // TaskAuditID 独立的执行结果核查审计,和流程审计同事务。
|
||||
Deduplicated bool `json:"deduplicated"` // Deduplicated 原操作回执恢复,不再次修改状态或预算。
|
||||
}
|
||||
|
||||
type WorkflowOperationSummary struct {
|
||||
// ID 不可改写的审计记录标识。
|
||||
ID string `json:"id"`
|
||||
// WorkflowID 本页授权流程标识。
|
||||
WorkflowID string `json:"workflow_id"`
|
||||
// OperationID 服务端管理操作身份,不是调用方原始幂等键。
|
||||
OperationID string `json:"operation_id"`
|
||||
// ActorID 实际认证主体标识,不接受请求体自报。
|
||||
ActorID string `json:"actor_id"`
|
||||
// Action 管理动作,例如workflow.task.review、workflow.step.retry、workflow.control、workflow.advanced。
|
||||
Action string `json:"action"`
|
||||
// CreatedAt 不可变审计创建时间,UTC微秒精度。
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type WorkflowOperation struct {
|
||||
WorkflowOperationSummary
|
||||
// Reason 实际提交的操作原因。
|
||||
Reason string `json:"reason"`
|
||||
// EvidenceRef 操作证据引用;非核查类操作可能为空。
|
||||
EvidenceRef string `json:"evidence_ref,omitempty"`
|
||||
// Before 操作前的受控状态摘要,不包含完整业务输入。
|
||||
Before json.RawMessage `json:"before"`
|
||||
// After 操作后摘要、核查声明或关联新激活和任务,不包含凭据秘密。
|
||||
After json.RawMessage `json:"after"`
|
||||
}
|
||||
|
||||
type WorkflowOperationPage struct {
|
||||
// Items 按创建时间及审计ID倒序排列,不返回原因与摘要正文。
|
||||
Items []WorkflowOperationSummary `json:"items"`
|
||||
// NextCursor 必须沿相同流程继续使用,空表示本次已无下一页。
|
||||
NextCursor string `json:"next_cursor,omitempty"`
|
||||
}
|
||||
144
sdk/go/workflow_instances.go
Normal file
144
sdk/go/workflow_instances.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package orbit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// StartWorkflow 固定发布版本;响应不明时保留原键及完整请求核对,不自动创建新身份。
|
||||
func (c *Client) StartWorkflow(ctx context.Context, key string, in StartWorkflowRequest) (WorkflowStartResult, error) {
|
||||
var out WorkflowStartResult
|
||||
if !validID(in.DefinitionID) || !workflowVersionName(in.Version) {
|
||||
return out, errors.New("invalid workflow definition or version")
|
||||
}
|
||||
err := c.call(ctx, "POST", "/v1/workflows", key, in, &out)
|
||||
if err == nil && (!validID(out.Instance.ID) || out.Instance.DefinitionID != in.DefinitionID || out.Instance.Version != in.Version || out.Instance.Revision == 0) {
|
||||
return WorkflowStartResult{}, errors.New("invalid workflow receipt; reconcile original identity")
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
func (c *Client) GetWorkflow(ctx context.Context, id string) (WorkflowInstance, error) {
|
||||
var out WorkflowInstance
|
||||
if !validID(id) {
|
||||
return out, errors.New("invalid workflow id")
|
||||
}
|
||||
err := c.call(ctx, "GET", "/v1/workflows/"+id, "", nil, &out)
|
||||
if err == nil && out.ID != id {
|
||||
return WorkflowInstance{}, errors.New("workflow identity mismatch")
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
func (c *Client) GetWorkflowSteps(ctx context.Context, id string) (WorkflowSteps, error) {
|
||||
var out WorkflowSteps
|
||||
if !validID(id) {
|
||||
return out, errors.New("invalid workflow id")
|
||||
}
|
||||
err := c.call(ctx, "GET", "/v1/workflows/"+id+"/steps", "", nil, &out)
|
||||
if err == nil && (out.InstanceID != id || out.Steps == nil || len(out.Steps) > 128) {
|
||||
return WorkflowSteps{}, errors.New("invalid workflow steps response")
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
func (c *Client) GetWorkflowStep(ctx context.Context, id, key string) (WorkflowStepDetail, error) {
|
||||
var out WorkflowStepDetail
|
||||
if !validID(id) || !workflowVersionName(key) {
|
||||
return out, errors.New("invalid workflow or step identity")
|
||||
}
|
||||
err := c.call(ctx, "GET", "/v1/workflows/"+id+"/steps/"+key, "", nil, &out)
|
||||
if err == nil && (out.InstanceID != id || out.Step.Key != key) {
|
||||
return WorkflowStepDetail{}, errors.New("workflow step identity mismatch")
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ControlWorkflow 使用control_revision;RESUME不会解除人工核查,也不重置截止。
|
||||
func (c *Client) ControlWorkflow(ctx context.Context, id, key string, in WorkflowControlRequest) (WorkflowControlResult, error) {
|
||||
var out WorkflowControlResult
|
||||
if !validID(id) {
|
||||
return out, errors.New("invalid workflow id")
|
||||
}
|
||||
err := c.call(ctx, "POST", "/v1/workflows/"+id+"/control", key, in, &out)
|
||||
if err == nil && out.Instance.ID != id {
|
||||
return WorkflowControlResult{}, errors.New("invalid workflow control receipt; reconcile original identity")
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
// SendWorkflowSignal 受理不代表消费;必须显式指定步骤及等待代次,原键重报不会跟随新激活。
|
||||
func (c *Client) SendWorkflowSignal(ctx context.Context, id, key string, in SendWorkflowSignalRequest) (WorkflowSignalResult, error) {
|
||||
var out WorkflowSignalResult
|
||||
if !validID(id) {
|
||||
return out, errors.New("invalid workflow id")
|
||||
}
|
||||
err := c.call(ctx, "POST", "/v1/workflows/"+id+"/signals", key, in, &out)
|
||||
if err == nil && (!validID(out.Signal.ID) || out.Signal.WorkflowID != id || out.Signal.TargetStepKey != in.TargetStepKey || out.Signal.TargetActivationSequence != in.TargetActivationSequence) {
|
||||
return WorkflowSignalResult{}, errors.New("invalid signal receipt; reconcile original identity")
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
func (c *Client) GetWorkflowSignal(ctx context.Context, id, signal string) (WorkflowSignalStatus, error) {
|
||||
var out WorkflowSignalStatus
|
||||
if !validID(id) || !validID(signal) {
|
||||
return out, errors.New("invalid workflow signal identity")
|
||||
}
|
||||
err := c.call(ctx, "GET", "/v1/workflows/"+id+"/signals/"+signal, "", nil, &out)
|
||||
if err == nil && (out.ID != signal || out.WorkflowID != id) {
|
||||
return WorkflowSignalStatus{}, errors.New("workflow signal identity mismatch")
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *Client) workflowRecovery(ctx context.Context, id, key, operation string, in any) (WorkflowRecoveryResult, error) {
|
||||
var out WorkflowRecoveryResult
|
||||
if !validID(id) {
|
||||
return out, errors.New("invalid workflow id")
|
||||
}
|
||||
err := c.call(ctx, "POST", "/v1/workflows/"+id+"/recovery/"+operation, key, in, &out)
|
||||
if err == nil && (out.Instance.ID != id || !validID(out.AuditID)) {
|
||||
return WorkflowRecoveryResult{}, errors.New("invalid workflow recovery receipt; reconcile original identity")
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ReviewWorkflowTask 仅核查执行事实;结果须经内部消费,再决定步骤结论或等待接续。
|
||||
func (c *Client) ReviewWorkflowTask(ctx context.Context, id, key string, in ReviewWorkflowTaskRequest) (WorkflowRecoveryResult, error) {
|
||||
return c.workflowRecovery(ctx, id, key, "task-review", in)
|
||||
}
|
||||
func (c *Client) ResolveWorkflowStep(ctx context.Context, id, key string, in ResolveWorkflowStepRequest) (WorkflowRecoveryResult, error) {
|
||||
return c.workflowRecovery(ctx, id, key, "step-resolve", in)
|
||||
}
|
||||
|
||||
// ContinueWorkflow 解除核查;保留原paused及停止意图。
|
||||
func (c *Client) ContinueWorkflow(ctx context.Context, id, key string, in ContinueWorkflowRequest) (WorkflowRecoveryResult, error) {
|
||||
return c.workflowRecovery(ctx, id, key, "continue", in)
|
||||
}
|
||||
func (c *Client) RetryWorkflowStep(ctx context.Context, id, key string, in RetryWorkflowStepRequest) (WorkflowRecoveryResult, error) {
|
||||
return c.workflowRecovery(ctx, id, key, "step-retry", in)
|
||||
}
|
||||
func (c *Client) ContinueWorkflowPoll(ctx context.Context, id, key string, in ContinueWorkflowPollRequest) (WorkflowRecoveryResult, error) {
|
||||
return c.workflowRecovery(ctx, id, key, "poll-continue", in)
|
||||
}
|
||||
func (c *Client) RearmWorkflowWait(ctx context.Context, id, key string, in RearmWorkflowWaitRequest) (WorkflowRecoveryResult, error) {
|
||||
return c.workflowRecovery(ctx, id, key, "wait-rearm", in)
|
||||
}
|
||||
|
||||
func (c *Client) ListWorkflowOperations(ctx context.Context, id string, in HistoryPageRequest) (WorkflowOperationPage, error) {
|
||||
var out WorkflowOperationPage
|
||||
if !validID(id) {
|
||||
return out, errors.New("invalid workflow id")
|
||||
}
|
||||
q, err := historyPageQuery(in)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
err = c.call(ctx, "GET", "/v1/workflows/"+id+"/operations"+q, "", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
func (c *Client) GetWorkflowOperation(ctx context.Context, id, operation string) (WorkflowOperation, error) {
|
||||
var out WorkflowOperation
|
||||
if !validID(id) || !validID(operation) {
|
||||
return out, errors.New("invalid workflow operation identity")
|
||||
}
|
||||
err := c.call(ctx, "GET", "/v1/workflows/"+id+"/operations/"+operation, "", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
403
sdk/go/workflow_instances_integration_test.go
Normal file
403
sdk/go/workflow_instances_integration_test.go
Normal file
@@ -0,0 +1,403 @@
|
||||
package orbit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var workflowExact = json.RawMessage(`{"n":9007199254740993}`)
|
||||
|
||||
func workflowSDKSpec(queue, handler string) WorkflowSpecification {
|
||||
typ := WorkflowType{Kind: "OBJECT", Fields: map[string]WorkflowType{"n": {Kind: "NUMBER"}}}
|
||||
task := WorkflowTask{QueueID: queue, Handler: handler, HandlerVersion: "v1", PayloadVersion: "v1", InputType: typ, Input: WorkflowRef("input"), ResultType: typ, TimeoutMS: 60000, Retry: RetryPolicy{MaxAttempts: 2, AttemptTimeoutMS: 10000, InitialDelayMS: 100, MaxDelayMS: 100}, RecoverySafety: RecoveryReconcileOnly}
|
||||
return WorkflowSpecification{SchemaVersion: 1, InputType: typ, TimeoutMS: 120000, FailurePolicy: "REVIEW", Body: WorkflowBlock{Steps: []WorkflowStep{{Key: "work", Kind: "TASK", Task: &task}}, OutputType: typ, Output: WorkflowRef("work")}}
|
||||
}
|
||||
func publishWorkflowSDK(t *testing.T, ctx context.Context, api *Client, spec WorkflowSpecification) StartWorkflowRequest {
|
||||
t.Helper()
|
||||
key := workerTestID(t)
|
||||
d, err := api.CreateWorkflowDefinition(ctx, key, CreateWorkflowDefinitionRequest{Name: "runtime." + key})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = api.PublishWorkflowVersion(ctx, d.Definition.ID, key, PublishWorkflowVersionRequest{Version: "v1", Specification: spec, Reason: "verify public workflow chain"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return StartWorkflowRequest{DefinitionID: d.Definition.ID, Version: "v1", Input: workflowExact, BusinessKey: "作业关联 "}
|
||||
}
|
||||
func startWorkflowSDK(t *testing.T, ctx context.Context, api *Client, spec WorkflowSpecification) WorkflowInstance {
|
||||
t.Helper()
|
||||
out, err := api.StartWorkflow(ctx, workerTestID(t), publishWorkflowSDK(t, ctx, api, spec))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out.Instance
|
||||
}
|
||||
func waitWorkflowSDK(t *testing.T, ctx context.Context, api *Client, id, state string) WorkflowInstance {
|
||||
t.Helper()
|
||||
for {
|
||||
v, err := api.GetWorkflow(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.State == state {
|
||||
return v
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("workflow wanted %s, got %s", state, v.State)
|
||||
case <-time.After(40 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
func waitWorkflowStepSDK(t *testing.T, ctx context.Context, api *Client, id, key string, predicate func(WorkflowStepDetail) bool) WorkflowStepDetail {
|
||||
t.Helper()
|
||||
for {
|
||||
v, err := api.GetWorkflowStep(ctx, id, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if predicate(v) {
|
||||
return v
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("workflow step timed out: %s %s", key, v.Step.State)
|
||||
case <-time.After(40 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
func workflowWorkerSDK(t *testing.T, ctx context.Context, api *Client, queue, handler string) *WorkerConnection {
|
||||
t.Helper()
|
||||
reg, err := NewWorkerRegistration(queue, 1, []WorkerCapability{{Handler: handler, HandlerVersion: "v1", PayloadVersion: "v1"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w, err := api.DialWorker(ctx, reg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { w.Close() })
|
||||
if _, err = w.Capacity(ctx, workerTestID(t), WorkerCapacity{Sequence: 1, Capacity: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return w
|
||||
}
|
||||
func finishWorkflowSDK(t *testing.T, ctx context.Context, w *WorkerConnection, outcome WorkerOutcome, result json.RawMessage) WorkerAssignment {
|
||||
t.Helper()
|
||||
a, err := w.NextAssignment(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = w.Start(ctx, workerTestID(t), a.Attempt()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code := ""
|
||||
if outcome != WorkerSucceeded {
|
||||
code = "EXAMPLE_EXECUTION_RESULT"
|
||||
}
|
||||
if _, err = w.Finish(ctx, workerTestID(t), WorkerFinish{Ref: a.Attempt(), Outcome: outcome, Result: result, ErrorCode: code}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
func workflowDecision(v WorkflowStepDetail) WorkflowStepDecision {
|
||||
return WorkflowStepDecision{WorkflowRecoveryDecision: WorkflowRecoveryDecision{ExpectedRevision: v.Revision, Reason: "已核实模拟执行及等待", EvidenceRef: "evidence:workflow-sdk"}, StepKey: v.Step.Key, ActivationID: *v.Step.ActivationID, ExpectedStepRevision: v.Step.Revision}
|
||||
}
|
||||
func continueWorkflowSDK(t *testing.T, ctx context.Context, api *Client, id string) {
|
||||
t.Helper()
|
||||
v, err := api.GetWorkflow(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := workerTestID(t)
|
||||
in := ContinueWorkflowRequest{WorkflowRecoveryDecision: WorkflowRecoveryDecision{ExpectedRevision: v.Revision, Reason: "核查事项已结清", EvidenceRef: "evidence:workflow-sdk"}, ReviewResolved: true}
|
||||
first, err := api.ContinueWorkflow(ctx, id, key, in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := api.ContinueWorkflow(ctx, id, key, in)
|
||||
if err != nil || !again.Deduplicated || again.AuditID != first.AuditID {
|
||||
t.Fatal("continue receipt", err)
|
||||
}
|
||||
}
|
||||
func workflowAPIError(t *testing.T, err error, code string) {
|
||||
t.Helper()
|
||||
var v *APIError
|
||||
if !errors.As(err, &v) || v.Code != code {
|
||||
t.Fatalf("want %s got %v", code, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationWorkflowInstancesSDK(t *testing.T) {
|
||||
api, queue, token := integrationWorkerClient(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
handler := "workflow." + workerTestID(t)
|
||||
t.Run("start_receipt_control_timer_signal_and_audit", func(t *testing.T) {
|
||||
spec := workflowSDKSpec(queue, handler)
|
||||
task := *spec.Body.Steps[0].Task
|
||||
spec.Body.Steps = append(spec.Body.Steps, WorkflowStep{Key: "delay", Kind: "TIMER", Timer: &WorkflowTimer{DelayMS: 500}}, WorkflowStep{Key: "receipt", Kind: "SIGNAL", Signal: &WorkflowSignal{Name: "done", PayloadVersion: "v1", Correlation: WorkflowValue{Kind: "LITERAL", Literal: json.RawMessage(`"operation "`)}, PayloadType: spec.InputType, TimeoutMS: 60000}}, WorkflowStep{Key: "tail", Kind: "TASK", Task: &task})
|
||||
spec.Body.Output = WorkflowRef("tail")
|
||||
in := publishWorkflowSDK(t, ctx, api, spec)
|
||||
target, _ := url.Parse(api.baseURL)
|
||||
proxy := httputil.NewSingleHostReverseProxy(target)
|
||||
var dropped atomic.Bool
|
||||
var calls atomic.Int32
|
||||
proxy.ModifyResponse = func(r *http.Response) error {
|
||||
calls.Add(1)
|
||||
if dropped.CompareAndSwap(false, true) {
|
||||
raw, e := io.ReadAll(r.Body)
|
||||
r.Body.Close()
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if r.StatusCode != 201 || !json.Valid(raw) {
|
||||
t.Error("start did not commit")
|
||||
}
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
return nil
|
||||
}
|
||||
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, e error) {
|
||||
conn, _, err := w.(http.Hijacker).Hijack()
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
gateway := httptest.NewServer(proxy)
|
||||
defer gateway.Close()
|
||||
transport := &http.Transport{DisableKeepAlives: true}
|
||||
defer transport.CloseIdleConnections()
|
||||
lost, err := NewClient(gateway.URL, Options{Token: token, HTTPClient: &http.Client{Transport: transport}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := workerTestID(t)
|
||||
if _, err = lost.StartWorkflow(ctx, key, in); err == nil || calls.Load() != 1 {
|
||||
t.Fatal("start loss hidden", err)
|
||||
}
|
||||
started, err := api.StartWorkflow(ctx, key, in)
|
||||
if err != nil || !started.Deduplicated {
|
||||
t.Fatal("start receipt", err)
|
||||
}
|
||||
id := started.Instance.ID
|
||||
before, err := api.GetWorkflowSteps(ctx, id)
|
||||
if err != nil || len(before.Steps) != 4 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pause := WorkflowControlRequest{Operation: "PAUSE", ExpectedControlRevision: started.Instance.ControlRevision, Reason: "接收事实但暂停新执行"}
|
||||
pauseKey := workerTestID(t)
|
||||
paused, err := api.ControlWorkflow(ctx, id, pauseKey, pause)
|
||||
if err != nil || !paused.Instance.Paused {
|
||||
t.Fatal(err)
|
||||
}
|
||||
same, err := api.ControlWorkflow(ctx, id, pauseKey, pause)
|
||||
if err != nil || !same.Deduplicated {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = api.ControlWorkflow(ctx, id, workerTestID(t), pause)
|
||||
workflowAPIError(t, err, "STATE_CONFLICT")
|
||||
if _, err = api.ControlWorkflow(ctx, id, workerTestID(t), WorkflowControlRequest{Operation: "RESUME", ExpectedControlRevision: paused.Instance.ControlRevision, Reason: "恢复执行"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := workflowWorkerSDK(t, ctx, api, queue, handler)
|
||||
defer w.Close()
|
||||
first := finishWorkflowSDK(t, ctx, w, WorkerSucceeded, workflowExact)
|
||||
work, err := api.GetWorkflowStep(ctx, id, "work")
|
||||
if err != nil || work.Step.TaskID == nil || *work.Step.TaskID != first.Task.ID || work.Step.ActivationSequence != 1 {
|
||||
t.Fatal("step metadata", err)
|
||||
}
|
||||
wait := waitWorkflowStepSDK(t, ctx, api, id, "receipt", func(v WorkflowStepDetail) bool { return v.Step.State == "WAITING" })
|
||||
if wait.Wait == nil || wait.Wait.CorrelationKey != "operation " {
|
||||
t.Fatal("wait metadata")
|
||||
}
|
||||
signal := SendWorkflowSignalRequest{Name: "done", PayloadVersion: "v1", TargetStepKey: "receipt", TargetActivationSequence: wait.Step.ActivationSequence, CorrelationKey: wait.Wait.CorrelationKey, Payload: workflowExact, Reason: "external receipt"}
|
||||
future := signal
|
||||
future.TargetActivationSequence++
|
||||
_, err = api.SendWorkflowSignal(ctx, id, workerTestID(t), future)
|
||||
workflowAPIError(t, err, "STATE_CONFLICT")
|
||||
signalKey := workerTestID(t)
|
||||
accepted, err := api.SendWorkflowSignal(ctx, id, signalKey, signal)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repeated, err := api.SendWorkflowSignal(ctx, id, signalKey, signal)
|
||||
if err != nil || !repeated.Deduplicated || repeated.Signal.ID != accepted.Signal.ID {
|
||||
t.Fatal("signal dedupe", err)
|
||||
}
|
||||
finishWorkflowSDK(t, ctx, w, WorkerSucceeded, workflowExact)
|
||||
done := waitWorkflowSDK(t, ctx, api, id, "SUCCEEDED")
|
||||
if string(done.Output) != string(workflowExact) {
|
||||
t.Fatal("precision lost", string(done.Output))
|
||||
}
|
||||
receipt, err := api.GetWorkflowSignal(ctx, id, accepted.Signal.ID)
|
||||
if err != nil || receipt.State != "CONSUMED" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old, err := api.StartWorkflow(ctx, key, in)
|
||||
if err != nil || old.Instance.Revision != started.Instance.Revision || old.Instance.DeadlineAt != started.Instance.DeadlineAt {
|
||||
t.Fatal("start receipt changed", err)
|
||||
}
|
||||
page, err := api.ListWorkflowOperations(ctx, id, HistoryPageRequest{Limit: 1})
|
||||
if err != nil || len(page.Items) != 1 || page.NextCursor == "" {
|
||||
t.Fatal("audit pagination", err)
|
||||
}
|
||||
audit, err := api.GetWorkflowOperation(ctx, id, page.Items[0].ID)
|
||||
if err != nil || audit.WorkflowID != id {
|
||||
t.Fatal(err)
|
||||
}
|
||||
timer := workflowSDKSpec(queue, handler)
|
||||
timer.Body = WorkflowBlock{Steps: []WorkflowStep{{Key: "wait", Kind: "TIMER", Timer: &WorkflowTimer{DelayMS: 30000}}}, OutputType: WorkflowType{Kind: "NULL"}, Output: WorkflowValue{Kind: "LITERAL", Literal: json.RawMessage(`null`)}}
|
||||
other := startWorkflowSDK(t, ctx, api, timer)
|
||||
_, err = api.ListWorkflowOperations(ctx, other.ID, HistoryPageRequest{Limit: 1, Cursor: page.NextCursor})
|
||||
workflowAPIError(t, err, "INVALID_ARGUMENT")
|
||||
_, err = api.ControlWorkflow(ctx, other.ID, workerTestID(t), WorkflowControlRequest{Operation: "CANCEL", ExpectedControlRevision: other.ControlRevision, Reason: "stop unused wait"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitWorkflowSDK(t, ctx, api, other.ID, "CANCELLED")
|
||||
})
|
||||
t.Run("unknown_review_resolve_and_continue", func(t *testing.T) {
|
||||
v := startWorkflowSDK(t, ctx, api, workflowSDKSpec(queue, handler))
|
||||
w := workflowWorkerSDK(t, ctx, api, queue, handler)
|
||||
defer w.Close()
|
||||
a := finishWorkflowSDK(t, ctx, w, WorkerUnknown, json.RawMessage(`{"unknown":true}`))
|
||||
step := waitWorkflowStepSDK(t, ctx, api, v.ID, "work", func(v WorkflowStepDetail) bool { return v.Step.State == "NEEDS_REVIEW" })
|
||||
task, err := api.GetTask(ctx, a.Task.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in := ReviewWorkflowTaskRequest{WorkflowRecoveryDecision: workflowDecision(step).WorkflowRecoveryDecision, TaskID: task.ID, ExpectedTaskRevision: task.Revision, ExecutionSettled: true, Outcome: TaskSucceeded, Result: workflowExact}
|
||||
key := workerTestID(t)
|
||||
reviewed, err := api.ReviewWorkflowTask(ctx, v.ID, key, in)
|
||||
if err != nil || reviewed.Task == nil || reviewed.TaskAuditID == nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
duplicate, err := api.ReviewWorkflowTask(ctx, v.ID, key, in)
|
||||
if err != nil || !duplicate.Deduplicated || duplicate.AuditID != reviewed.AuditID {
|
||||
t.Fatal(err)
|
||||
}
|
||||
step = waitWorkflowStepSDK(t, ctx, api, v.ID, "work", func(v WorkflowStepDetail) bool { return v.Revision > reviewed.Instance.Revision })
|
||||
resolved, err := api.ResolveWorkflowStep(ctx, v.ID, workerTestID(t), ResolveWorkflowStepRequest{WorkflowStepDecision: workflowDecision(step), ExecutionSettled: true, Outcome: "SUCCEEDED", Result: workflowExact})
|
||||
if err != nil || resolved.Step.State != "SUCCEEDED" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
audit, err := api.GetWorkflowOperation(ctx, v.ID, resolved.AuditID)
|
||||
if err != nil || audit.Action != "workflow.step.resolve" || audit.EvidenceRef != "evidence:workflow-sdk" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
continueWorkflowSDK(t, ctx, api, v.ID)
|
||||
waitWorkflowSDK(t, ctx, api, v.ID, "SUCCEEDED")
|
||||
attempt, err := api.GetTaskAttempt(ctx, task.ID, a.AttemptID)
|
||||
if err != nil || attempt.State != "UNKNOWN" {
|
||||
t.Fatal("old evidence rewritten", err)
|
||||
}
|
||||
})
|
||||
t.Run("retry_task_and_rearm_signal", func(t *testing.T) {
|
||||
spec := workflowSDKSpec(queue, handler)
|
||||
task := spec.Body.Steps[0]
|
||||
wait := WorkflowStep{Key: "receipt", Kind: "SIGNAL", Signal: &WorkflowSignal{Name: "done", PayloadVersion: "v1", Correlation: WorkflowValue{Kind: "LITERAL", Literal: json.RawMessage(`"shared"`)}, PayloadType: spec.InputType, TimeoutMS: 60000}}
|
||||
typ := WorkflowType{Kind: "OBJECT", Fields: map[string]WorkflowType{"left": spec.InputType, "right": spec.InputType}}
|
||||
spec.Body = WorkflowBlock{Steps: []WorkflowStep{{Key: "pair", Kind: "PARALLEL", Parallel: &WorkflowParallel{Branches: []WorkflowParallelBranch{{Name: "left", Body: WorkflowBlock{Steps: []WorkflowStep{task}, OutputType: spec.InputType, Output: WorkflowRef("work")}}, {Name: "right", Body: WorkflowBlock{Steps: []WorkflowStep{wait}, OutputType: spec.InputType, Output: WorkflowRef("receipt")}}}}}}, OutputType: typ, Output: WorkflowRef("pair")}
|
||||
v := startWorkflowSDK(t, ctx, api, spec)
|
||||
w := workflowWorkerSDK(t, ctx, api, queue, handler)
|
||||
defer w.Close()
|
||||
finishWorkflowSDK(t, ctx, w, WorkerFailed, json.RawMessage(`{"known_failure":true}`))
|
||||
waitWorkflowStepSDK(t, ctx, api, v.ID, "work", func(v WorkflowStepDetail) bool { return v.Step.State == "FAILED" })
|
||||
old, err := api.GetWorkflowStep(ctx, v.ID, "receipt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := workerTestID(t)
|
||||
rearm := RearmWorkflowWaitRequest{WorkflowStepDecision: workflowDecision(old), WaitVerified: true}
|
||||
restored, err := api.RearmWorkflowWait(ctx, v.ID, key, rearm)
|
||||
if err != nil || restored.Step.ActivationSequence != 2 {
|
||||
t.Fatal("new wait generation", err)
|
||||
}
|
||||
same, err := api.RearmWorkflowWait(ctx, v.ID, key, rearm)
|
||||
if err != nil || !same.Deduplicated || same.AuditID != restored.AuditID {
|
||||
t.Fatal(err)
|
||||
}
|
||||
current, err := api.GetWorkflowStep(ctx, v.ID, "receipt")
|
||||
if err != nil || current.Wait.DeadlineAt != old.Wait.DeadlineAt {
|
||||
t.Fatal("wait extended", err)
|
||||
}
|
||||
stale, err := api.SendWorkflowSignal(ctx, v.ID, workerTestID(t), SendWorkflowSignalRequest{Name: "done", PayloadVersion: "v1", TargetStepKey: "receipt", TargetActivationSequence: 1, CorrelationKey: "shared", Payload: workflowExact, Reason: "late prior generation"})
|
||||
if err != nil || stale.Signal.State != "REJECTED" {
|
||||
t.Fatal("old signal not fenced", err)
|
||||
}
|
||||
step, err := api.GetWorkflowStep(ctx, v.ID, "work")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
retryKey := workerTestID(t)
|
||||
retry := RetryWorkflowStepRequest{WorkflowStepDecision: workflowDecision(step), SafeToRetry: true}
|
||||
child, err := api.RetryWorkflowStep(ctx, v.ID, retryKey, retry)
|
||||
if err != nil || child.Step.ActivationSequence != 2 || child.Task.Retry.MaxAttempts != 1 {
|
||||
t.Fatal("task budget restored incorrectly", err)
|
||||
}
|
||||
repeated, err := api.RetryWorkflowStep(ctx, v.ID, retryKey, retry)
|
||||
if err != nil || !repeated.Deduplicated || repeated.Task.ID != child.Task.ID {
|
||||
t.Fatal(err)
|
||||
}
|
||||
continueWorkflowSDK(t, ctx, api, v.ID)
|
||||
a := finishWorkflowSDK(t, ctx, w, WorkerSucceeded, workflowExact)
|
||||
if a.Task.ID != child.Task.ID {
|
||||
t.Fatal("wrong retry task")
|
||||
}
|
||||
_, err = api.SendWorkflowSignal(ctx, v.ID, workerTestID(t), SendWorkflowSignalRequest{Name: "done", PayloadVersion: "v1", TargetStepKey: "receipt", TargetActivationSequence: 2, CorrelationKey: "shared", Payload: workflowExact, Reason: "verified new wait"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitWorkflowSDK(t, ctx, api, v.ID, "SUCCEEDED")
|
||||
})
|
||||
t.Run("review_pending_then_continue_original_poll", func(t *testing.T) {
|
||||
spec := workflowSDKSpec(queue, handler)
|
||||
task := *spec.Body.Steps[0].Task
|
||||
task.ResultType = WorkflowType{Kind: "STRING", Enum: []string{"PENDING", "DONE"}}
|
||||
spec.Body = WorkflowBlock{Steps: []WorkflowStep{{Key: "query", Kind: "POLL", Poll: &WorkflowPoll{Task: task, MaxRounds: 3, TimeoutMS: 60000, IntervalMS: 1000, Pending: []string{"PENDING"}, Succeeded: []string{"DONE"}}}}, OutputType: task.ResultType, Output: WorkflowRef("query")}
|
||||
v := startWorkflowSDK(t, ctx, api, spec)
|
||||
w := workflowWorkerSDK(t, ctx, api, queue, handler)
|
||||
defer w.Close()
|
||||
finishWorkflowSDK(t, ctx, w, WorkerUnknown, json.RawMessage(`"unknown"`))
|
||||
step := waitWorkflowStepSDK(t, ctx, api, v.ID, "query", func(v WorkflowStepDetail) bool { return v.Step.State == "NEEDS_REVIEW" })
|
||||
source, err := api.GetTask(ctx, step.Poll.LastTaskID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reviewed, err := api.ReviewWorkflowTask(ctx, v.ID, workerTestID(t), ReviewWorkflowTaskRequest{WorkflowRecoveryDecision: workflowDecision(step).WorkflowRecoveryDecision, TaskID: source.ID, ExpectedTaskRevision: source.Revision, ExecutionSettled: true, Outcome: TaskSucceeded, Result: json.RawMessage(`"PENDING"`)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
step = waitWorkflowStepSDK(t, ctx, api, v.ID, "query", func(v WorkflowStepDetail) bool { return v.Revision > reviewed.Instance.Revision })
|
||||
key := workerTestID(t)
|
||||
in := ContinueWorkflowPollRequest{WorkflowStepDecision: workflowDecision(step), PendingConfirmed: true}
|
||||
out, err := api.ContinueWorkflowPoll(ctx, v.ID, key, in)
|
||||
if err != nil || out.Step.ActivationSequence != 1 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
same, err := api.ContinueWorkflowPoll(ctx, v.ID, key, in)
|
||||
if err != nil || !same.Deduplicated || same.AuditID != out.AuditID {
|
||||
t.Fatal(err)
|
||||
}
|
||||
continueWorkflowSDK(t, ctx, api, v.ID)
|
||||
finishWorkflowSDK(t, ctx, w, WorkerSucceeded, json.RawMessage(`"DONE"`))
|
||||
waitWorkflowSDK(t, ctx, api, v.ID, "SUCCEEDED")
|
||||
final, err := api.GetWorkflowStep(ctx, v.ID, "query")
|
||||
if err != nil || final.Poll.UsedRounds != 2 || final.Poll.DeadlineAt != step.Poll.DeadlineAt {
|
||||
t.Fatal("poll budget changed", err)
|
||||
}
|
||||
})
|
||||
if !t.Failed() {
|
||||
t.Log("public workflow start/query/control/signal, all six recovery commands, audit and real Worker execution verified without server imports")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user