feat: add bounded workflow decisions and durable result inbox
This commit is contained in:
208
weave/engine/evaluate.go
Normal file
208
weave/engine/evaluate.go
Normal file
@@ -0,0 +1,208 @@
|
||||
// Package engine 依据已发布规则和持久化快照计算有限推进命令;不调用业务处理器或外部系统。
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math/big"
|
||||
"strings"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/jsonwire"
|
||||
"git.yuntuo.hk/k2safe/orbit/weave/definition"
|
||||
)
|
||||
|
||||
type values map[string]json.RawMessage
|
||||
|
||||
func copyJSON(raw json.RawMessage) json.RawMessage { return append(json.RawMessage(nil), raw...) }
|
||||
func copyValues(in values) values {
|
||||
out := make(values, len(in)+8)
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 表达式来自已验证且由Program持有的不可变定义;每个值仍按运行数据上限检查。
|
||||
func resolve(v definition.Value, expected definition.Type, env values) (json.RawMessage, error) {
|
||||
raw, err := evaluate(v, env, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := definition.ValidateData(expected, raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
func evaluate(v definition.Value, env values, depth int) (json.RawMessage, error) {
|
||||
if depth > 8 {
|
||||
return nil, errors.New("expression depth exceeded")
|
||||
}
|
||||
var out json.RawMessage
|
||||
switch v.Kind {
|
||||
case "LITERAL":
|
||||
out = copyJSON(v.Literal)
|
||||
case "REFERENCE":
|
||||
if v.Reference == nil {
|
||||
return nil, errors.New("reference required")
|
||||
}
|
||||
raw, ok := env[v.Reference.Source]
|
||||
if !ok {
|
||||
return nil, errors.New("reference unavailable")
|
||||
}
|
||||
for _, key := range v.Reference.Path {
|
||||
var object map[string]json.RawMessage
|
||||
if json.Unmarshal(raw, &object) != nil {
|
||||
return nil, errors.New("reference is not an object")
|
||||
}
|
||||
raw, ok = object[key]
|
||||
if !ok {
|
||||
return nil, errors.New("reference field missing")
|
||||
}
|
||||
}
|
||||
out = copyJSON(raw)
|
||||
case "OBJECT":
|
||||
object := make(map[string]json.RawMessage, len(v.Fields))
|
||||
size := 0
|
||||
for k, child := range v.Fields {
|
||||
raw, err := evaluate(child, env, depth+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
size += len(raw)
|
||||
if size > definition.MaxSpecificationBytes {
|
||||
return nil, errors.New("resolved object exceeds bounds")
|
||||
}
|
||||
object[k] = raw
|
||||
}
|
||||
raw, err := jsonwire.Marshal(object)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = raw
|
||||
case "ARRAY":
|
||||
array := make([]json.RawMessage, len(v.Items))
|
||||
size := 0
|
||||
for i, child := range v.Items {
|
||||
raw, err := evaluate(child, env, depth+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
size += len(raw)
|
||||
if size > definition.MaxSpecificationBytes {
|
||||
return nil, errors.New("resolved array exceeds bounds")
|
||||
}
|
||||
array[i] = raw
|
||||
}
|
||||
raw, err := jsonwire.Marshal(array)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = raw
|
||||
default:
|
||||
return nil, errors.New("unknown expression")
|
||||
}
|
||||
if len(out) == 0 || len(out) > definition.MaxSpecificationBytes {
|
||||
return nil, errors.New("resolved value exceeds bounds")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func compare(p definition.Predicate, env values) (bool, error) {
|
||||
switch p.Op {
|
||||
case "AND", "OR", "NOT":
|
||||
for _, child := range p.Arguments {
|
||||
ok, err := compare(child, env)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if p.Op == "NOT" {
|
||||
return !ok, nil
|
||||
}
|
||||
if p.Op == "AND" && !ok {
|
||||
return false, nil
|
||||
}
|
||||
if p.Op == "OR" && ok {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return p.Op == "AND", nil
|
||||
}
|
||||
if p.Left == nil || p.Right == nil {
|
||||
return false, errors.New("comparison operands missing")
|
||||
}
|
||||
left, err := resolve(*p.Left, definition.Type{Kind: "JSON"}, env)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
right, err := resolve(*p.Right, definition.Type{Kind: "JSON"}, env)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
decode := func(raw []byte) (any, error) {
|
||||
var v any
|
||||
d := json.NewDecoder(bytes.NewReader(raw))
|
||||
d.UseNumber()
|
||||
err := d.Decode(&v)
|
||||
return v, err
|
||||
}
|
||||
a, err := decode(left)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
b, err := decode(right)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
var order int
|
||||
switch v := a.(type) {
|
||||
case string:
|
||||
w, ok := b.(string)
|
||||
if !ok {
|
||||
return false, errors.New("comparison type mismatch")
|
||||
}
|
||||
order = strings.Compare(v, w)
|
||||
case json.Number:
|
||||
w, ok := b.(json.Number)
|
||||
if !ok {
|
||||
return false, errors.New("comparison type mismatch")
|
||||
}
|
||||
x, ok := new(big.Rat).SetString(string(v))
|
||||
if !ok {
|
||||
return false, errors.New("invalid exact number")
|
||||
}
|
||||
y, ok := new(big.Rat).SetString(string(w))
|
||||
if !ok {
|
||||
return false, errors.New("invalid exact number")
|
||||
}
|
||||
order = x.Cmp(y)
|
||||
case bool:
|
||||
w, ok := b.(bool)
|
||||
if !ok || p.Op != "EQ" && p.Op != "NE" {
|
||||
return false, errors.New("comparison type mismatch")
|
||||
}
|
||||
if v != w {
|
||||
order = 1
|
||||
}
|
||||
case nil:
|
||||
if b != nil || p.Op != "EQ" && p.Op != "NE" {
|
||||
return false, errors.New("comparison type mismatch")
|
||||
}
|
||||
default:
|
||||
return false, errors.New("comparison requires scalars")
|
||||
}
|
||||
switch p.Op {
|
||||
case "EQ":
|
||||
return order == 0, nil
|
||||
case "NE":
|
||||
return order != 0, nil
|
||||
case "LT":
|
||||
return order < 0, nil
|
||||
case "LE":
|
||||
return order <= 0, nil
|
||||
case "GT":
|
||||
return order > 0, nil
|
||||
case "GE":
|
||||
return order >= 0, nil
|
||||
}
|
||||
return false, errors.New("unknown comparison")
|
||||
}
|
||||
352
weave/engine/plan.go
Normal file
352
weave/engine/plan.go
Normal file
@@ -0,0 +1,352 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/jsonvalue"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/jsonwire"
|
||||
"git.yuntuo.hk/k2safe/orbit/weave/definition"
|
||||
weave "git.yuntuo.hk/k2safe/orbit/weave/model"
|
||||
)
|
||||
|
||||
type planner struct {
|
||||
program *Program
|
||||
states map[string]State
|
||||
decision Decision
|
||||
limit int
|
||||
allowNew bool
|
||||
verifying int
|
||||
visited map[string]bool
|
||||
}
|
||||
|
||||
// Plan 只计算本轮最多limit个步骤状态变更(1至32),不修改快照或重新执行已完成业务。
|
||||
// 错误时丢弃全部本轮决策,存储适配层须保留原快照并记录核查原因。
|
||||
func (p *Program) Plan(snapshot Snapshot, limit int) (Decision, error) {
|
||||
if limit < 1 || limit > 32 || snapshot.WorkflowID.Validate() != nil || !validBusinessKey(snapshot.BusinessKey) {
|
||||
return Decision{}, problem("", "INVALID_SNAPSHOT")
|
||||
}
|
||||
if err := definition.ValidateData(p.spec.InputType, snapshot.Input); err != nil {
|
||||
return Decision{}, problem("", "WORKFLOW_INPUT_INVALID")
|
||||
}
|
||||
if len(snapshot.Steps) > len(p.nodes) {
|
||||
return Decision{}, problem("", "INVALID_SNAPSHOT")
|
||||
}
|
||||
state := &planner{program: p, states: make(map[string]State, len(p.nodes)), visited: map[string]bool{}, limit: limit, allowNew: snapshot.AllowNew, decision: Decision{Changes: []Change{}}}
|
||||
for key, row := range snapshot.Steps {
|
||||
node, ok := p.nodes[key]
|
||||
if !ok {
|
||||
return Decision{}, problem(key, "UNKNOWN_STEP")
|
||||
}
|
||||
if row.Status == weave.StepFailed || row.Status == weave.StepNeedsReview || row.Status == weave.StepCancelled {
|
||||
return Decision{}, problem(key, "STEP_REQUIRES_REVIEW")
|
||||
}
|
||||
switch row.Status {
|
||||
case weave.StepBlocked, weave.StepSkipped:
|
||||
if len(row.Input) != 0 || len(row.Output) != 0 {
|
||||
return Decision{}, problem(key, "INVALID_STEP_STATE")
|
||||
}
|
||||
case weave.StepActive, weave.StepWaiting, weave.StepSucceeded:
|
||||
if err := definition.ValidateData(definition.Type{Kind: "JSON"}, row.Input); err != nil {
|
||||
return Decision{}, problem(key, "INVALID_STEP_INPUT")
|
||||
}
|
||||
if node.Kind == "TASK" || node.Kind == "POLL" {
|
||||
task := node.Task
|
||||
if node.Poll != nil {
|
||||
task = &node.Poll.Task
|
||||
}
|
||||
if null(row.Input) || definition.ValidateData(task.InputType, row.Input) != nil {
|
||||
return Decision{}, problem(key, "INVALID_STEP_INPUT")
|
||||
}
|
||||
}
|
||||
if row.Status == weave.StepSucceeded {
|
||||
if err := validateOutput(node, row.Output); err != nil {
|
||||
return Decision{}, problem(key, "STEP_OUTPUT_INVALID")
|
||||
}
|
||||
} else if len(row.Output) != 0 {
|
||||
return Decision{}, problem(key, "INVALID_STEP_STATE")
|
||||
}
|
||||
default:
|
||||
return Decision{}, problem(key, "INVALID_STEP_STATE")
|
||||
}
|
||||
state.states[key] = row
|
||||
}
|
||||
workflow, err := jsonwire.Marshal(map[string]string{"id": string(snapshot.WorkflowID), "business_key": snapshot.BusinessKey})
|
||||
if err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
complete, output, err := state.block(p.spec.Body, values{"input": copyJSON(snapshot.Input), "workflow": workflow})
|
||||
if err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
for key, row := range snapshot.Steps {
|
||||
if (row.Status == weave.StepActive || row.Status == weave.StepWaiting || row.Status == weave.StepSucceeded) && !state.visited[key] {
|
||||
return Decision{}, problem(key, "UNREACHABLE_EXECUTION")
|
||||
}
|
||||
}
|
||||
state.decision.Complete = complete
|
||||
state.decision.Output = copyJSON(output)
|
||||
return state.decision, nil
|
||||
}
|
||||
func null(raw []byte) bool { return bytes.Equal(bytes.TrimSpace(raw), []byte("null")) }
|
||||
func outputType(node definition.Step) definition.Type {
|
||||
switch node.Kind {
|
||||
case "TASK":
|
||||
return node.Task.ResultType
|
||||
case "POLL":
|
||||
return node.Poll.Task.ResultType
|
||||
case "SIGNAL":
|
||||
return node.Signal.PayloadType
|
||||
case "TIMER":
|
||||
return definition.Type{Kind: "NULL"}
|
||||
case "BRANCH":
|
||||
return node.Branch.Default.OutputType
|
||||
case "PARALLEL":
|
||||
t := definition.Type{Kind: "OBJECT", Fields: map[string]definition.Type{}}
|
||||
for _, b := range node.Parallel.Branches {
|
||||
t.Fields[b.Name] = b.Body.OutputType
|
||||
}
|
||||
return t
|
||||
}
|
||||
return definition.Type{}
|
||||
}
|
||||
func validateOutput(node definition.Step, raw json.RawMessage) error {
|
||||
if (node.Kind == "TASK" || node.Kind == "POLL") && null(raw) {
|
||||
return problem(node.Key, "STEP_OUTPUT_INVALID")
|
||||
}
|
||||
if node.Poll != nil {
|
||||
outcome, err := classifyPoll(*node.Poll, raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outcome != PollSucceeded {
|
||||
return problem(node.Key, "POLL_RESULT_NOT_SUCCESS")
|
||||
}
|
||||
}
|
||||
return definition.ValidateData(outputType(node), raw)
|
||||
}
|
||||
func (s *planner) add(change Change) bool {
|
||||
if len(s.decision.Changes) >= s.limit {
|
||||
s.decision.More = true
|
||||
return false
|
||||
}
|
||||
s.decision.Changes = append(s.decision.Changes, change)
|
||||
s.states[change.Key] = change.State
|
||||
return true
|
||||
}
|
||||
func (s *planner) block(block definition.Block, parent values) (bool, json.RawMessage, error) {
|
||||
env := copyValues(parent)
|
||||
for _, node := range block.Steps {
|
||||
complete, output, err := s.step(node, env)
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
if !complete {
|
||||
return false, nil, nil
|
||||
}
|
||||
env[node.Key] = output
|
||||
}
|
||||
output, err := resolve(block.Output, block.OutputType, env)
|
||||
if err != nil {
|
||||
return false, nil, problem("", "BLOCK_OUTPUT_INVALID")
|
||||
}
|
||||
return true, output, nil
|
||||
}
|
||||
func (s *planner) step(node definition.Step, env values) (bool, json.RawMessage, error) {
|
||||
s.visited[node.Key] = true
|
||||
row, ok := s.states[node.Key]
|
||||
if !ok {
|
||||
row.Status = weave.StepBlocked
|
||||
}
|
||||
completed := row.Status == weave.StepSucceeded
|
||||
if s.verifying > 0 && !completed {
|
||||
return false, nil, problem(node.Key, "INCOMPLETE_COMPOSITE_CHILD")
|
||||
}
|
||||
if completed {
|
||||
if node.Kind != "BRANCH" && node.Kind != "PARALLEL" {
|
||||
return true, copyJSON(row.Output), nil
|
||||
}
|
||||
s.verifying++
|
||||
defer func() { s.verifying-- }()
|
||||
}
|
||||
if row.Status == weave.StepSkipped {
|
||||
return false, nil, problem(node.Key, "SELECTED_STEP_WAS_SKIPPED")
|
||||
}
|
||||
if row.Status == weave.StepBlocked {
|
||||
if !s.allowNew || s.decision.More {
|
||||
return false, nil, nil
|
||||
}
|
||||
change := Change{Key: node.Key, Kind: node.Kind, Operation: "ACTIVATE", State: State{Status: weave.StepActive}}
|
||||
switch node.Kind {
|
||||
case "TASK", "POLL":
|
||||
task := node.Task
|
||||
if node.Poll != nil {
|
||||
task = &node.Poll.Task
|
||||
}
|
||||
prepared, err := prepareTask(*task, env)
|
||||
if err != nil {
|
||||
return false, nil, problem(node.Key, "TASK_INPUT_INVALID")
|
||||
}
|
||||
change.State.Input = copyJSON(prepared.Payload)
|
||||
change.Task = &prepared
|
||||
if node.Poll != nil && node.Poll.FinalSignal != nil {
|
||||
wait, err := prepareSignal(*node.Poll.FinalSignal, "POLL", env)
|
||||
if err != nil {
|
||||
return false, nil, problem(node.Key, "SIGNAL_CORRELATION_INVALID")
|
||||
}
|
||||
change.Wait = wait
|
||||
}
|
||||
case "SIGNAL":
|
||||
wait, err := prepareSignal(*node.Signal, "SIGNAL", env)
|
||||
if err != nil {
|
||||
return false, nil, problem(node.Key, "SIGNAL_CORRELATION_INVALID")
|
||||
}
|
||||
change.Wait = wait
|
||||
change.State.Status = weave.StepWaiting
|
||||
change.State.Input, _ = jsonwire.Marshal(map[string]string{"name": wait.Name, "payload_version": wait.PayloadVersion, "correlation": wait.Correlation})
|
||||
case "TIMER":
|
||||
change.Wait = &WaitCommand{Kind: "TIMER", TimeoutMS: node.Timer.DelayMS}
|
||||
change.State.Status = weave.StepWaiting
|
||||
change.State.Input, _ = jsonwire.Marshal(map[string]uint64{"delay_ms": node.Timer.DelayMS})
|
||||
case "BRANCH":
|
||||
selected := -1
|
||||
for i, path := range node.Branch.Cases {
|
||||
matches, err := compare(path.When, env)
|
||||
if err != nil {
|
||||
return false, nil, problem(node.Key, "BRANCH_CONDITION_INVALID")
|
||||
}
|
||||
if matches {
|
||||
selected = i
|
||||
break
|
||||
}
|
||||
}
|
||||
change.State.Input, _ = jsonwire.Marshal(map[string]int{"case": selected})
|
||||
case "PARALLEL":
|
||||
change.State.Input = json.RawMessage(`{}`)
|
||||
default:
|
||||
return false, nil, problem(node.Key, "UNSUPPORTED_NODE")
|
||||
}
|
||||
if !s.add(change) {
|
||||
return false, nil, nil
|
||||
}
|
||||
row = change.State
|
||||
}
|
||||
if node.Kind != "BRANCH" && node.Kind != "PARALLEL" {
|
||||
return false, nil, nil
|
||||
}
|
||||
var output json.RawMessage
|
||||
switch node.Kind {
|
||||
case "BRANCH":
|
||||
contract := definition.Type{Kind: "OBJECT", Fields: map[string]definition.Type{"case": {Kind: "NUMBER"}}}
|
||||
var selection struct {
|
||||
Case int `json:"case"`
|
||||
}
|
||||
if definition.ValidateData(contract, row.Input) != nil || json.Unmarshal(row.Input, &selection) != nil || selection.Case < -1 || selection.Case >= len(node.Branch.Cases) {
|
||||
return false, nil, problem(node.Key, "BRANCH_DECISION_INVALID")
|
||||
}
|
||||
// 已冻结的选择不重算;未选择路径分批标记SKIPPED,不创建业务激活。
|
||||
for i, path := range node.Branch.Cases {
|
||||
if i != selection.Case {
|
||||
if err := s.skip(path.Body); err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
body := *node.Branch.Default
|
||||
if selection.Case >= 0 {
|
||||
if err := s.skip(*node.Branch.Default); err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
body = node.Branch.Cases[selection.Case].Body
|
||||
}
|
||||
// 预算只阻止新增变更;已有选中路径仍须遍历,避免遗漏并行分支的在途工作。
|
||||
done, raw, err := s.block(body, env)
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
if !done {
|
||||
return false, nil, nil
|
||||
}
|
||||
output = raw
|
||||
case "PARALLEL":
|
||||
if definition.ValidateData(definition.Type{Kind: "OBJECT"}, row.Input) != nil {
|
||||
return false, nil, problem(node.Key, "INVALID_PARALLEL_STATE")
|
||||
}
|
||||
result := make(map[string]json.RawMessage, len(node.Parallel.Branches))
|
||||
all := true
|
||||
for _, branch := range node.Parallel.Branches {
|
||||
done, raw, err := s.block(branch.Body, env)
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
if !done {
|
||||
all = false
|
||||
} else {
|
||||
result[branch.Name] = raw
|
||||
}
|
||||
}
|
||||
if !all {
|
||||
return false, nil, nil
|
||||
}
|
||||
var err error
|
||||
output, err = jsonwire.Marshal(result)
|
||||
if err != nil {
|
||||
return false, nil, problem(node.Key, "STEP_OUTPUT_INVALID")
|
||||
}
|
||||
}
|
||||
if validateOutput(node, output) != nil {
|
||||
return false, nil, problem(node.Key, "STEP_OUTPUT_INVALID")
|
||||
}
|
||||
if completed {
|
||||
original, err := jsonvalue.Canonical(row.Output)
|
||||
if err != nil {
|
||||
return false, nil, problem(node.Key, "STEP_OUTPUT_INVALID")
|
||||
}
|
||||
current, err := jsonvalue.Canonical(output)
|
||||
if err != nil || !bytes.Equal(original, current) {
|
||||
return false, nil, problem(node.Key, "COMPOSITE_OUTPUT_MISMATCH")
|
||||
}
|
||||
return true, copyJSON(row.Output), nil
|
||||
}
|
||||
row.Status = weave.StepSucceeded
|
||||
row.Output = copyJSON(output)
|
||||
if !s.add(Change{Key: node.Key, Kind: node.Kind, Operation: "COMPLETE", State: row}) {
|
||||
return false, nil, nil
|
||||
}
|
||||
return true, output, nil
|
||||
}
|
||||
func (s *planner) skip(block definition.Block) error {
|
||||
for _, node := range block.Steps {
|
||||
s.visited[node.Key] = true
|
||||
row, ok := s.states[node.Key]
|
||||
if ok && row.Status != weave.StepBlocked && row.Status != weave.StepSkipped {
|
||||
return problem(node.Key, "UNSELECTED_STEP_HAS_EXECUTION")
|
||||
}
|
||||
if !ok || row.Status == weave.StepBlocked {
|
||||
if s.verifying > 0 {
|
||||
return problem(node.Key, "INCOMPLETE_COMPOSITE_CHILD")
|
||||
}
|
||||
if !s.add(Change{Key: node.Key, Kind: node.Kind, Operation: "SKIP", State: State{Status: weave.StepSkipped}}) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if node.Branch != nil {
|
||||
for _, c := range node.Branch.Cases {
|
||||
if err := s.skip(c.Body); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := s.skip(*node.Branch.Default); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if node.Parallel != nil {
|
||||
for _, b := range node.Parallel.Branches {
|
||||
if err := s.skip(b.Body); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
449
weave/engine/plan_test.go
Normal file
449
weave/engine/plan_test.go
Normal file
@@ -0,0 +1,449 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/weave/definition"
|
||||
weave "git.yuntuo.hk/k2safe/orbit/weave/model"
|
||||
)
|
||||
|
||||
func val(raw string) definition.Value {
|
||||
return definition.Value{Kind: "LITERAL", Literal: json.RawMessage(raw)}
|
||||
}
|
||||
func ref(source string, path ...string) definition.Value {
|
||||
return definition.Value{Kind: "REFERENCE", Reference: &definition.Reference{Source: source, Path: path}}
|
||||
}
|
||||
func block(typ definition.Type, value definition.Value, steps ...definition.Step) definition.Block {
|
||||
return definition.Block{OutputType: typ, Output: value, Steps: steps}
|
||||
}
|
||||
func task(queue core.ID, input definition.Value) definition.Task {
|
||||
return definition.Task{QueueID: queue, Handler: "object.prepare", HandlerVersion: "v1", PayloadVersion: "v1", InputType: definition.Type{Kind: "NUMBER"}, Input: input, ResultType: definition.Type{Kind: "NUMBER"}, TimeoutMS: 5000, Retry: pulse.RetryPolicy{MaxAttempts: 1, AttemptTimeoutMS: 1000}, RecoverySafety: pulse.RecoveryReconcileOnly}
|
||||
}
|
||||
func programSpec() definition.Specification {
|
||||
number := definition.Type{Kind: "NUMBER"}
|
||||
left, right := ref("input", "n"), val(`9007199254740992`)
|
||||
work := task("11111111111111111111111111111111", ref("input", "n"))
|
||||
a, b := work, work
|
||||
fallback := block(number, val(`0`), definition.Step{Key: "unused", Kind: "TASK", Task: &work})
|
||||
choose := definition.Step{Key: "choice", Kind: "BRANCH", Branch: &definition.Branch{Cases: []definition.BranchCase{{When: definition.Predicate{Op: "GT", Left: &left, Right: &right}, Body: block(number, ref("selected"), definition.Step{Key: "selected", Kind: "TASK", Task: &work})}}, Default: &fallback}}
|
||||
a.Input = ref("choice")
|
||||
b.Input = ref("choice")
|
||||
joined := definition.Step{Key: "outputs", Kind: "PARALLEL", Parallel: &definition.Parallel{Branches: []definition.ParallelBranch{{Name: "first", Body: block(number, ref("a"), definition.Step{Key: "a", Kind: "TASK", Task: &a})}, {Name: "second", Body: block(number, ref("b"), definition.Step{Key: "b", Kind: "TASK", Task: &b})}}}}
|
||||
out := definition.Type{Kind: "OBJECT", Fields: map[string]definition.Type{"first": number, "second": number}}
|
||||
return definition.Specification{SchemaVersion: 1, InputType: definition.Type{Kind: "OBJECT", Fields: map[string]definition.Type{"n": number}}, TimeoutMS: 30000, FailurePolicy: "REVIEW", Body: block(out, ref("outputs"), choose, joined)}
|
||||
}
|
||||
func snapshot() Snapshot {
|
||||
return Snapshot{WorkflowID: "22222222222222222222222222222222", BusinessKey: "operation", Input: json.RawMessage(`{"n":9007199254740993}`), Steps: map[string]State{}, AllowNew: true}
|
||||
}
|
||||
func compiled(t *testing.T, spec definition.Specification) *Program {
|
||||
t.Helper()
|
||||
p, err := Compile(spec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
func persist(t *testing.T, s *Snapshot, d Decision) {
|
||||
t.Helper()
|
||||
for _, c := range d.Changes {
|
||||
s.Steps[c.Key] = c.State
|
||||
}
|
||||
raw, err := json.Marshal(s.Steps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.Steps = nil
|
||||
if err := json.Unmarshal(raw, &s.Steps); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func succeed(s *Snapshot, key string, raw json.RawMessage) {
|
||||
row := s.Steps[key]
|
||||
row.Status = weave.StepSucceeded
|
||||
row.Output = copyJSON(raw)
|
||||
s.Steps[key] = row
|
||||
}
|
||||
|
||||
func TestBudgetedRestartedBranchParallelPlan(t *testing.T) {
|
||||
for _, budget := range []int{1, 2, 32} {
|
||||
t.Run(string(rune('A'+budget)), func(t *testing.T) {
|
||||
spec := programSpec()
|
||||
s := snapshot()
|
||||
started := map[string]int{}
|
||||
var final Decision
|
||||
for iteration := 0; iteration < 40; iteration++ {
|
||||
// 每轮重新编译并从JSON恢复步骤;模拟存储适配层可丢弃全部进程内状态。
|
||||
p := compiled(t, spec)
|
||||
before, _ := json.Marshal(s.Steps)
|
||||
d, err := p.Plan(s, budget)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, _ := json.Marshal(s.Steps)
|
||||
if !bytes.Equal(before, after) {
|
||||
t.Fatal("planner mutated caller snapshot")
|
||||
}
|
||||
if len(d.Changes) > budget {
|
||||
t.Fatal("step budget exceeded")
|
||||
}
|
||||
persist(t, &s, d)
|
||||
for _, c := range d.Changes {
|
||||
if c.Task != nil {
|
||||
started[c.Key]++
|
||||
if string(c.Task.Payload) != `9007199254740993` {
|
||||
t.Fatal("numeric reference changed", string(c.Task.Payload))
|
||||
}
|
||||
succeed(&s, c.Key, c.Task.Payload)
|
||||
}
|
||||
}
|
||||
if d.Complete {
|
||||
final = d
|
||||
break
|
||||
}
|
||||
}
|
||||
if !final.Complete || string(final.Output) != `{"first":9007199254740993,"second":9007199254740993}` || !reflect.DeepEqual(started, map[string]int{"selected": 1, "a": 1, "b": 1}) {
|
||||
t.Fatal("incomplete or duplicated work", final, started)
|
||||
}
|
||||
if s.Steps["unused"].Status != weave.StepSkipped {
|
||||
t.Fatal("unselected path was not skipped")
|
||||
}
|
||||
repeated, err := compiled(t, spec).Plan(s, budget)
|
||||
if err != nil || !repeated.Complete || len(repeated.Changes) != 0 {
|
||||
t.Fatal("completed replay changed state", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestBudgetExhaustionStillVisitsActiveParallelBranch(t *testing.T) {
|
||||
spec := programSpec()
|
||||
number := definition.Type{Kind: "NUMBER"}
|
||||
work := task("11111111111111111111111111111111", ref("input", "n"))
|
||||
left, right := val(`0`), val(`1`)
|
||||
fallback := block(number, ref("y"), definition.Step{Key: "y", Kind: "TASK", Task: &work})
|
||||
leftChoice := definition.Step{Key: "left_choice", Kind: "BRANCH", Branch: &definition.Branch{
|
||||
Cases: []definition.BranchCase{{When: definition.Predicate{Op: "EQ", Left: &left, Right: &right}, Body: block(number, val(`0`))}}, Default: &fallback,
|
||||
}}
|
||||
joined := definition.Step{Key: "joined", Kind: "PARALLEL", Parallel: &definition.Parallel{Branches: []definition.ParallelBranch{
|
||||
{Name: "first", Body: block(number, ref("left_choice"), definition.Step{Key: "x", Kind: "TASK", Task: &work}, leftChoice)},
|
||||
{Name: "second", Body: block(number, ref("choice"), spec.Body.Steps[0])},
|
||||
}}}
|
||||
spec.Body = block(spec.Body.OutputType, ref("joined"), joined)
|
||||
s := snapshot()
|
||||
d, err := compiled(t, spec).Plan(s, 32)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
persist(t, &s, d)
|
||||
succeed(&s, "x", json.RawMessage(`1`))
|
||||
// 左侧激活组合后耗尽预算,右侧已有分支及在途任务仍须遍历,不能误报不可达。
|
||||
d, err = compiled(t, spec).Plan(s, 1)
|
||||
if err != nil || !d.More || len(d.Changes) != 1 || d.Changes[0].Key != "left_choice" {
|
||||
t.Fatal("budget prevented visiting existing work", d, err)
|
||||
}
|
||||
persist(t, &s, d)
|
||||
succeed(&s, "selected", json.RawMessage(`2`))
|
||||
for i := 0; i < 12; i++ {
|
||||
d, err = compiled(t, spec).Plan(s, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
persist(t, &s, d)
|
||||
for _, change := range d.Changes {
|
||||
if change.Task != nil {
|
||||
if change.Key != "y" {
|
||||
t.Fatal("existing task was activated again", change.Key)
|
||||
}
|
||||
succeed(&s, "y", json.RawMessage(`3`))
|
||||
}
|
||||
}
|
||||
if d.Complete {
|
||||
if string(d.Output) != `{"first":3,"second":2}` {
|
||||
t.Fatal("invalid parallel output", string(d.Output))
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("bounded parallel plan did not complete")
|
||||
}
|
||||
|
||||
func TestExactConditionsAndValues(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
a, b, op string
|
||||
want bool
|
||||
}{
|
||||
{`9007199254740993`, `9007199254740992`, "GT", true},
|
||||
{`0.10000000000000000001`, `0.1`, "GT", true},
|
||||
{`1e3`, `1000.000`, "EQ", true},
|
||||
{`-1e-308`, `0`, "LT", true},
|
||||
{`"a "`, `"a"`, "EQ", false}, {`true`, `false`, "NE", true}, {`null`, `null`, "EQ", true},
|
||||
} {
|
||||
a, b := val(tc.a), val(tc.b)
|
||||
got, err := compare(definition.Predicate{Op: tc.op, Left: &a, Right: &b}, values{})
|
||||
if err != nil || got != tc.want {
|
||||
t.Fatalf("%s %s %s: %v %v", tc.a, tc.op, tc.b, got, err)
|
||||
}
|
||||
}
|
||||
value := definition.Value{Kind: "OBJECT", Fields: map[string]definition.Value{"n": ref("input", "n"), "array": {Kind: "ARRAY", Items: []definition.Value{val(`1.0000000000000000001`), val(`null`)}}}}
|
||||
raw, err := resolve(value, definition.Type{Kind: "JSON"}, values{"input": json.RawMessage(`{"n":9007199254740993}`)})
|
||||
if err != nil || string(raw) != `{"array":[1.0000000000000000001,null],"n":9007199254740993}` {
|
||||
t.Fatal(string(raw), err)
|
||||
}
|
||||
a, b := val(`true`), val(`true`)
|
||||
condition := definition.Predicate{Op: "NOT", Arguments: []definition.Predicate{{Op: "EQ", Left: &a, Right: &b}}}
|
||||
ok, err := compare(condition, nil)
|
||||
if err != nil || ok {
|
||||
t.Fatal("logical comparison", err)
|
||||
}
|
||||
}
|
||||
func TestSnapshotAndResultFailuresProduceNoChanges(t *testing.T) {
|
||||
p := compiled(t, programSpec())
|
||||
for _, change := range []func(*Snapshot){
|
||||
func(s *Snapshot) { s.Input = json.RawMessage(`{"n":"wrong"}`) },
|
||||
func(s *Snapshot) { s.Steps["unknown"] = State{Status: weave.StepBlocked} },
|
||||
func(s *Snapshot) {
|
||||
s.Steps["selected"] = State{Status: weave.StepSucceeded, Input: json.RawMessage(`1`), Output: json.RawMessage(`"wrong"`)}
|
||||
},
|
||||
func(s *Snapshot) { s.Steps["selected"] = State{Status: weave.StepNeedsReview} },
|
||||
func(s *Snapshot) {
|
||||
s.Steps["choice"] = State{Status: weave.StepActive, Input: json.RawMessage(`{"case":100}`)}
|
||||
},
|
||||
func(s *Snapshot) {
|
||||
s.Steps["choice"] = State{Status: weave.StepSucceeded, Input: json.RawMessage(`{"case":0}`), Output: json.RawMessage(`1`)}
|
||||
},
|
||||
func(s *Snapshot) { s.Steps["unused"] = State{Status: weave.StepActive, Input: json.RawMessage(`1`)} },
|
||||
} {
|
||||
s := snapshot()
|
||||
change(&s)
|
||||
d, err := p.Plan(s, 32)
|
||||
if err == nil || len(d.Changes) != 0 || d.Complete {
|
||||
t.Fatal("unsafe snapshot advanced", d, err)
|
||||
}
|
||||
}
|
||||
for _, limit := range []int{0, 33} {
|
||||
if _, err := p.Plan(snapshot(), limit); err == nil {
|
||||
t.Fatal("unbounded iteration accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestPauseFrozenChoiceAndCompiledIsolation(t *testing.T) {
|
||||
spec := programSpec()
|
||||
p := compiled(t, spec)
|
||||
s := snapshot()
|
||||
s.AllowNew = false
|
||||
d, err := p.Plan(s, 32)
|
||||
if err != nil || len(d.Changes) != 0 || d.Complete {
|
||||
t.Fatal("pause activated work", d, err)
|
||||
}
|
||||
s.AllowNew = true
|
||||
d, err = p.Plan(s, 1)
|
||||
if err != nil || len(d.Changes) != 1 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
persist(t, &s, d)
|
||||
s.AllowNew = false
|
||||
d, err = p.Plan(s, 32)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range d.Changes {
|
||||
if c.Operation == "ACTIVATE" {
|
||||
t.Fatal("pause activated branch child")
|
||||
}
|
||||
}
|
||||
persist(t, &s, d)
|
||||
// 编译之后修改调用方定义和公开副本均不能改变正在解释的固定版本。
|
||||
spec.Body.Steps[0].Branch.Cases[0].Body.Steps[0].Task.Handler = "changed"
|
||||
copy, ok := p.Step("selected")
|
||||
if !ok {
|
||||
t.Fatal("missing metadata")
|
||||
}
|
||||
copy.Task.Handler = "also.changed"
|
||||
s.AllowNew = true
|
||||
d, err = p.Plan(s, 32)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, c := range d.Changes {
|
||||
if c.Task != nil {
|
||||
found = true
|
||||
if c.Task.Handler != "object.prepare" {
|
||||
t.Fatal("compiled program changed")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("frozen branch did not resume")
|
||||
}
|
||||
// 已保存的默认分支选择即便重新求值会匹配case,也必须继续原选择。
|
||||
s = snapshot()
|
||||
s.Steps["choice"] = State{Status: weave.StepActive, Input: json.RawMessage(`{"case":-1}`)}
|
||||
d, err = p.Plan(s, 32)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range d.Changes {
|
||||
if c.Task != nil && c.Key != "unused" {
|
||||
t.Fatal("branch was reevaluated")
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestWaitAndPollPreparationAndRuntimeLimits(t *testing.T) {
|
||||
spec := programSpec()
|
||||
str := definition.Type{Kind: "STRING"}
|
||||
status := definition.Type{Kind: "STRING", Enum: []string{"PENDING", "DONE"}}
|
||||
spec.InputType = str
|
||||
signal := definition.Signal{Name: "job.final", PayloadVersion: "v1", Correlation: ref("input"), PayloadType: status, TimeoutMS: 10000}
|
||||
query := task("11111111111111111111111111111111", val(`1`))
|
||||
query.ResultType = status
|
||||
poll := definition.Poll{Task: query, MaxRounds: 2, TimeoutMS: 10000, IntervalMS: 1000, Pending: []string{"PENDING"}, Succeeded: []string{"DONE"}, FinalSignal: &signal}
|
||||
spec.Body = block(status, ref("poll"), definition.Step{Key: "signal", Kind: "SIGNAL", Signal: &signal}, definition.Step{Key: "timer", Kind: "TIMER", Timer: &definition.Timer{DelayMS: 1000}}, definition.Step{Key: "poll", Kind: "POLL", Poll: &poll})
|
||||
p := compiled(t, spec)
|
||||
s := snapshot()
|
||||
s.Input = json.RawMessage(`"operation"`)
|
||||
d, err := p.Plan(s, 32)
|
||||
if err != nil || len(d.Changes) != 1 || d.Changes[0].Wait.Correlation != "operation" || d.Changes[0].Task != nil {
|
||||
t.Fatal("signal", d, err)
|
||||
}
|
||||
persist(t, &s, d)
|
||||
again, err := p.Plan(s, 32)
|
||||
if err != nil || len(again.Changes) != 0 {
|
||||
t.Fatal("wait restarted", err)
|
||||
}
|
||||
succeed(&s, "signal", json.RawMessage(`"DONE"`))
|
||||
d, err = p.Plan(s, 32)
|
||||
if err != nil || d.Changes[0].Wait.TimeoutMS != 1000 {
|
||||
t.Fatal("timer", err)
|
||||
}
|
||||
persist(t, &s, d)
|
||||
succeed(&s, "timer", json.RawMessage(`null`))
|
||||
d, err = p.Plan(s, 32)
|
||||
if err != nil || d.Changes[0].Task == nil || d.Changes[0].Wait.Kind != "POLL" {
|
||||
t.Fatal("poll", err)
|
||||
}
|
||||
s = snapshot()
|
||||
s.Input = json.RawMessage(`""`)
|
||||
if d, err := p.Plan(s, 32); err == nil || len(d.Changes) != 0 {
|
||||
t.Fatal("empty runtime correlation accepted")
|
||||
}
|
||||
s.Input, _ = json.Marshal(strings.Repeat("x", 513))
|
||||
if _, err := p.Plan(s, 32); err == nil {
|
||||
t.Fatal("unbounded correlation accepted")
|
||||
}
|
||||
// 同一引用同时用作两个Pulse键,不能因指针相同漏掉一个键。
|
||||
shared := ref("key")
|
||||
q := task("11111111111111111111111111111111", val(`1`))
|
||||
q.BusinessKey = &shared
|
||||
q.ConcurrencyKey = &shared
|
||||
prepared, err := prepareTask(q, values{"key": json.RawMessage(`"operation"`)})
|
||||
if err != nil || prepared.BusinessKey != "operation" || prepared.ConcurrencyKey != "operation" {
|
||||
t.Fatal(prepared, err)
|
||||
}
|
||||
}
|
||||
func TestParallelCompletionIntegrityAndConcurrentPlanning(t *testing.T) {
|
||||
spec := programSpec()
|
||||
p := compiled(t, spec)
|
||||
s := snapshot()
|
||||
for i := 0; i < 15; i++ {
|
||||
d, err := p.Plan(s, 32)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
persist(t, &s, d)
|
||||
for _, c := range d.Changes {
|
||||
if c.Task != nil {
|
||||
succeed(&s, c.Key, c.Task.Payload)
|
||||
}
|
||||
}
|
||||
if d.Complete {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(s.Steps["outputs"].Status, weave.StepSucceeded) {
|
||||
t.Fatal("not completed")
|
||||
}
|
||||
// 完成的组合不能掩盖未结清子步骤或伪造输出。
|
||||
broken := snapshot()
|
||||
for k, v := range s.Steps {
|
||||
broken.Steps[k] = v
|
||||
}
|
||||
row := broken.Steps["a"]
|
||||
row.Status = weave.StepActive
|
||||
row.Output = nil
|
||||
broken.Steps["a"] = row
|
||||
_, err := p.Plan(broken, 32)
|
||||
var issue *Problem
|
||||
if !errors.As(err, &issue) || issue.Code != "INCOMPLETE_COMPOSITE_CHILD" {
|
||||
t.Fatal("incomplete child hidden", err)
|
||||
}
|
||||
broken.Steps["a"] = s.Steps["a"]
|
||||
row = broken.Steps["outputs"]
|
||||
row.Output = json.RawMessage(`{"first":0,"second":0}`)
|
||||
broken.Steps["outputs"] = row
|
||||
if _, err := p.Plan(broken, 32); err == nil {
|
||||
t.Fatal("fabricated composite result accepted")
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 16; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
d, err := p.Plan(s, 32)
|
||||
if err != nil || !d.Complete || len(d.Changes) != 0 {
|
||||
t.Error("concurrent immutable plan", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestUnreachableExecutionsAndAggregateOutputLimit(t *testing.T) {
|
||||
p := compiled(t, programSpec())
|
||||
s := snapshot()
|
||||
s.Steps["outputs"] = State{Status: weave.StepActive, Input: json.RawMessage(`{}`)}
|
||||
if d, err := p.Plan(s, 32); err == nil || len(d.Changes) != 0 {
|
||||
t.Fatal("future execution was ignored")
|
||||
}
|
||||
large, _ := json.Marshal(strings.Repeat("x", 140000))
|
||||
expr := definition.Value{Kind: "OBJECT", Fields: map[string]definition.Value{"a": ref("value"), "b": ref("value")}}
|
||||
if _, err := resolve(expr, definition.Type{Kind: "JSON"}, values{"value": large}); err == nil {
|
||||
t.Fatal("aggregate output limit ignored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollCompletionMustHaveFinalSuccess(t *testing.T) {
|
||||
spec := programSpec()
|
||||
query := task("11111111111111111111111111111111", val(`1`))
|
||||
query.ResultType = definition.Type{Kind: "STRING", Enum: []string{"WORKING", "DONE", "FAILED", "REVIEW"}}
|
||||
poll := definition.Poll{Task: query, MaxRounds: 2, TimeoutMS: 10000, IntervalMS: 1000, Pending: []string{"WORKING"}, Succeeded: []string{"DONE"}, Failed: []string{"FAILED"}, Review: []string{"REVIEW"}}
|
||||
spec.Body = block(query.ResultType, ref("poll"), definition.Step{Key: "poll", Kind: "POLL", Poll: &poll})
|
||||
p := compiled(t, spec)
|
||||
for status, want := range map[string]PollOutcome{"WORKING": PollPending, "DONE": PollSucceeded, "FAILED": PollFailed, "REVIEW": PollNeedsReview} {
|
||||
raw, _ := json.Marshal(status)
|
||||
out, err := p.ClassifyPoll("poll", raw)
|
||||
if err != nil || out != want {
|
||||
t.Fatal(status, out, err)
|
||||
}
|
||||
s := snapshot()
|
||||
s.Steps["poll"] = State{Status: weave.StepSucceeded, Input: json.RawMessage(`1`), Output: raw}
|
||||
decision, err := p.Plan(s, 32)
|
||||
if status == "DONE" {
|
||||
if err != nil || !decision.Complete {
|
||||
t.Fatal("final success rejected", err)
|
||||
}
|
||||
} else if err == nil || len(decision.Changes) != 0 {
|
||||
t.Fatal("nonfinal poll result advanced")
|
||||
}
|
||||
}
|
||||
if _, err := p.ClassifyPoll("poll", json.RawMessage(`"unexpected"`)); err == nil {
|
||||
t.Fatal("unknown status accepted")
|
||||
}
|
||||
}
|
||||
54
weave/engine/poll.go
Normal file
54
weave/engine/poll.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/weave/definition"
|
||||
)
|
||||
|
||||
// PollOutcome 仅分类一次成功查询的业务结果,不代表查询请求的Pulse执行状态。
|
||||
type PollOutcome string
|
||||
|
||||
const (
|
||||
PollPending PollOutcome = "PENDING"
|
||||
PollSucceeded PollOutcome = "SUCCEEDED"
|
||||
PollFailed PollOutcome = "FAILED"
|
||||
PollNeedsReview PollOutcome = "NEEDS_REVIEW"
|
||||
)
|
||||
|
||||
// ClassifyPoll 解释固定定义中的结果映射;轮次/时限/信号竞态仍由持久化运行器决定。
|
||||
func (p *Program) ClassifyPoll(key string, raw json.RawMessage) (PollOutcome, error) {
|
||||
node, ok := p.nodes[key]
|
||||
if !ok || node.Poll == nil {
|
||||
return "", problem(key, "NOT_A_POLL_STEP")
|
||||
}
|
||||
result, err := classifyPoll(*node.Poll, raw)
|
||||
if err != nil {
|
||||
return "", problem(key, "POLL_RESULT_INVALID")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func classifyPoll(p definition.Poll, raw json.RawMessage) (PollOutcome, error) {
|
||||
if err := definition.ValidateData(p.Task.ResultType, raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
status, err := resolve(definition.Value{Kind: "REFERENCE", Reference: &definition.Reference{Source: "result", Path: p.StatusPath}}, definition.Type{Kind: "STRING"}, values{"result": raw})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var value string
|
||||
if err := json.Unmarshal(status, &value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, group := range []struct {
|
||||
values []string
|
||||
outcome PollOutcome
|
||||
}{{p.Pending, PollPending}, {p.Succeeded, PollSucceeded}, {p.Failed, PollFailed}, {p.Review, PollNeedsReview}} {
|
||||
for _, candidate := range group.values {
|
||||
if value == candidate {
|
||||
return group.outcome, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", problem("", "UNMAPPED_POLL_RESULT")
|
||||
}
|
||||
164
weave/engine/program.go
Normal file
164
weave/engine/program.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/jsonwire"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||
"git.yuntuo.hk/k2safe/orbit/weave/definition"
|
||||
weave "git.yuntuo.hk/k2safe/orbit/weave/model"
|
||||
)
|
||||
|
||||
// TaskOwnerKind 标识由Weave激活拥有的Pulse任务;TASK及POLL轮次都使用激活ID作为owner_id。
|
||||
const TaskOwnerKind = "workflow_activation"
|
||||
|
||||
// Program 持有独立拷贝的已验证规则,可并发读取;不暴露可变的内部映射。
|
||||
type Program struct {
|
||||
spec definition.Specification
|
||||
nodes map[string]definition.Step
|
||||
}
|
||||
|
||||
func Compile(spec definition.Specification) (*Program, error) {
|
||||
if _, err := definition.Check(spec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := jsonwire.Marshal(spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := &Program{nodes: map[string]definition.Step{}}
|
||||
if err := json.Unmarshal(raw, &p.spec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var visit func(definition.Block)
|
||||
visit = func(b definition.Block) {
|
||||
for _, s := range b.Steps {
|
||||
p.nodes[s.Key] = s
|
||||
if s.Branch != nil {
|
||||
for _, c := range s.Branch.Cases {
|
||||
visit(c.Body)
|
||||
}
|
||||
visit(*s.Branch.Default)
|
||||
}
|
||||
if s.Parallel != nil {
|
||||
for _, branch := range s.Parallel.Branches {
|
||||
visit(branch.Body)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
visit(p.spec.Body)
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Step 返回定义副本,调用方修改不会改变已编译版本。
|
||||
func (p *Program) Step(key string) (definition.Step, bool) {
|
||||
s, ok := p.nodes[key]
|
||||
if !ok {
|
||||
return definition.Step{}, false
|
||||
}
|
||||
raw, err := jsonwire.Marshal(s)
|
||||
if err != nil {
|
||||
return definition.Step{}, false
|
||||
}
|
||||
var out definition.Step
|
||||
if json.Unmarshal(raw, &out) != nil {
|
||||
return out, false
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// State 是存储适配层核验身份/激活/版本后提供的当前步骤快照,不是直接信任的外部请求。
|
||||
type State struct {
|
||||
Status weave.StepState `json:"status"` // Status 逻辑步骤当前状态,BLOCKED可无激活。
|
||||
Input json.RawMessage `json:"input,omitempty"` // Input 激活时冻结的参数或组合决策,重复推进不得重算。
|
||||
Output json.RawMessage `json:"output,omitempty"` // Output 仅SUCCEEDED提供,显式null和未提供不同。
|
||||
}
|
||||
|
||||
// Snapshot 必须来自同一实例事务内的持久化视图;节点不以内存缓存授予执行权。
|
||||
type Snapshot struct {
|
||||
WorkflowID core.ID // WorkflowID 固定实例标识,加入workflow引用上下文。
|
||||
BusinessKey string // BusinessKey 业务追踪键,不代替幂等身份。
|
||||
Input json.RawMessage // Input 启动时冻结且已保存的输入。
|
||||
Steps map[string]State // Steps 当前激活投影;缺少的静态步骤视为BLOCKED。
|
||||
AllowNew bool // AllowNew 已核验暂停、取消、截止时间和门控后是否允许激活新步骤。
|
||||
}
|
||||
|
||||
// WaitCommand 只描述需要持久化的等待;存储层用数据库激活时间固定绝对截止。
|
||||
type WaitCommand struct {
|
||||
Kind string // Kind SIGNAL、TIMER或POLL最终回执。
|
||||
Name string // Name 业务信号名称;TIMER为空。
|
||||
PayloadVersion string // PayloadVersion 信号契约版本;TIMER为空。
|
||||
Correlation string // Correlation 激活时冻结的业务操作,非定时等待必须非空。
|
||||
TimeoutMS uint64 // TimeoutMS 相对激活时间的等待上界,还受实例绝对截止限制。
|
||||
}
|
||||
|
||||
// Change 是一个必须与激活、任务/等待、检查点和审计同事务落地的决策。
|
||||
type Change struct {
|
||||
Key string // Key 对应静态步骤身份。
|
||||
Operation string // Operation ACTIVATE、COMPLETE或SKIP。
|
||||
Kind string // Kind 原定义节点类型,存储层仍需核对。
|
||||
State State // State 变更后的步骤投影。
|
||||
Task *service.SubmitInput // Task 已解析和校验的TASK/POLL输入,不包含实际任务ID或执行授权。
|
||||
Wait *WaitCommand // Wait 需要持久化的等待信息;TIMER不占业务Worker。
|
||||
}
|
||||
|
||||
// Decision 不含数据库副作用;只有存储事务提交后Changes才可用于后续工作。
|
||||
type Decision struct {
|
||||
Changes []Change // Changes 按依赖顺序的有界决策列表。
|
||||
Complete bool // Complete 根块所有已选择步骤完成;不能替代存储层的在途结清校验。
|
||||
Output json.RawMessage // Output Complete时的根块输出,已按契约验证。
|
||||
More bool // More 因本轮步数预算停止,必须保存持久化待续记录。
|
||||
}
|
||||
|
||||
// Problem 是可审计的稳定分类,不携带任意业务参数。
|
||||
type Problem struct {
|
||||
Key string
|
||||
Code string
|
||||
}
|
||||
|
||||
func (e *Problem) Error() string { return fmt.Sprintf("workflow step %q: %s", e.Key, e.Code) }
|
||||
func problem(key, code string) error { return &Problem{Key: key, Code: code} }
|
||||
|
||||
// prepareTask 把冻结定义映射为Pulse受理参数;实际截止及额度由存储事务继续限制。
|
||||
func prepareTask(t definition.Task, env values) (service.SubmitInput, error) {
|
||||
raw, err := resolve(t.Input, t.InputType, env)
|
||||
if err != nil {
|
||||
return service.SubmitInput{}, err
|
||||
}
|
||||
in := service.SubmitInput{QueueID: t.QueueID, Handler: t.Handler, HandlerVersion: t.HandlerVersion, PayloadVersion: t.PayloadVersion, Payload: raw, TaskTimeoutMS: t.TimeoutMS, Retry: t.Retry, RecoverySafety: t.RecoverySafety, Priority: t.Priority}
|
||||
for _, key := range []struct {
|
||||
value *definition.Value
|
||||
target *string
|
||||
}{{t.BusinessKey, &in.BusinessKey}, {t.ConcurrencyKey, &in.ConcurrencyKey}} {
|
||||
if key.value != nil {
|
||||
raw, err := resolve(*key.value, definition.Type{Kind: "STRING"}, env)
|
||||
if err != nil {
|
||||
return in, err
|
||||
}
|
||||
if err := json.Unmarshal(raw, key.target); err != nil {
|
||||
return in, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return service.NormalizeSubmit(in)
|
||||
}
|
||||
func prepareSignal(s definition.Signal, kind string, env values) (*WaitCommand, error) {
|
||||
raw, err := resolve(s.Correlation, definition.Type{Kind: "STRING"}, env)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var correlation string
|
||||
if json.Unmarshal(raw, &correlation) != nil || correlation == "" || len(correlation) > 512 {
|
||||
return nil, errors.New("invalid runtime signal correlation")
|
||||
}
|
||||
return &WaitCommand{Kind: kind, Name: s.Name, PayloadVersion: s.PayloadVersion, Correlation: correlation, TimeoutMS: s.TimeoutMS}, nil
|
||||
}
|
||||
func validBusinessKey(s string) bool {
|
||||
return utf8.ValidString(s) && utf8.RuneCountInString(s) <= 191 && !strings.ContainsRune(s, 0)
|
||||
}
|
||||
@@ -15,6 +15,14 @@ type InboxEvent struct {
|
||||
WorkflowID core.ID `db:"workflow_id"`
|
||||
// EventID 来源持久化事件标识,用于消费去重。
|
||||
EventID core.ID `db:"event_id"`
|
||||
// SourceTaskID 来源Pulse任务;只有迁移前未核实的历史记录可空,不能据此重新推进。
|
||||
SourceTaskID *core.ID `db:"source_task_id"`
|
||||
// SourceActivationID 事件发生时的激活身份,消费时需核对是否仍为当前激活;未核实历史可空。
|
||||
SourceActivationID *core.ID `db:"source_activation_id"`
|
||||
// SourceRevision 来源任务状态版本,大于零,同一任务版本只交付一次;未核实历史可空。
|
||||
SourceRevision *uint64 `db:"source_revision"`
|
||||
// Snapshot 当时的版本化结果JSON文本,独立于外部订阅日志和当前任务;未核实历史为空。
|
||||
Snapshot core.JSON `db:"snapshot"`
|
||||
// State 当前状态;只允许定义的状态转换并配合事务校验。
|
||||
State InboxState `db:"state"`
|
||||
// AvailableAt 最早可被领取或处理的时间,统一 UTC。
|
||||
|
||||
Reference in New Issue
Block a user