feat: publish immutable workflow definitions with typed SDK
This commit is contained in:
519
weave/definition/check.go
Normal file
519
weave/definition/check.go
Normal file
@@ -0,0 +1,519 @@
|
||||
package definition
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/jsonvalue"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/jsonwire"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||
)
|
||||
|
||||
const MaxSpecificationBytes = 256 << 10
|
||||
const maxTimeoutMS = 30 * 86400000
|
||||
|
||||
type checker struct {
|
||||
result Checked
|
||||
keys map[string]bool
|
||||
queues map[core.ID]bool
|
||||
timeout uint64
|
||||
}
|
||||
type scope map[string]Type
|
||||
|
||||
// Check 验证完整结构、词法数据依赖和资源边界;不修改调用方定义,也不访问数据库。
|
||||
// 队列存在性、当前权限和不可变版本身份由发布事务另行核验。
|
||||
func Check(in Specification) (Checked, error) {
|
||||
if in.SchemaVersion != 1 || in.TimeoutMS == 0 || in.TimeoutMS > maxTimeoutMS || (in.FailurePolicy != "REVIEW" && in.FailurePolicy != "FAIL_AFTER_SETTLED") {
|
||||
return Checked{}, invalid("specification", "schema_version=1, finite timeout and explicit failure policy required")
|
||||
}
|
||||
if err := checkType(in.InputType, 0); err != nil {
|
||||
return Checked{}, err
|
||||
}
|
||||
c := checker{keys: map[string]bool{}, queues: map[core.ID]bool{}, timeout: in.TimeoutMS}
|
||||
initial := scope{"input": in.InputType, "workflow": {Kind: "OBJECT", Fields: map[string]Type{"id": {Kind: "STRING"}, "business_key": {Kind: "STRING"}}}}
|
||||
width, err := c.block(in.Body, initial, 0)
|
||||
if err != nil {
|
||||
return Checked{}, err
|
||||
}
|
||||
if width > 32 || c.result.MaxTaskAttempts > 10000 {
|
||||
return Checked{}, invalid("specification", "parallel or total attempt budget exceeded")
|
||||
}
|
||||
raw, err := jsonwire.Marshal(in)
|
||||
if err != nil || len(raw) > MaxSpecificationBytes {
|
||||
return Checked{}, invalid("specification", "encoded definition exceeds 256 KiB or is invalid")
|
||||
}
|
||||
canonical, err := jsonvalue.Canonical(raw)
|
||||
if err != nil {
|
||||
return Checked{}, invalid("specification", "definition JSON exceeds supported depth or value limits")
|
||||
}
|
||||
c.result.Digest = core.Digest(sha256.Sum256(append([]byte("orbit.weave.spec.v1\x00"), canonical...)))
|
||||
c.result.MaxConcurrency = width
|
||||
for q := range c.queues {
|
||||
c.result.Queues = append(c.result.Queues, q)
|
||||
}
|
||||
sort.Slice(c.result.Queues, func(i, j int) bool { return c.result.Queues[i] < c.result.Queues[j] })
|
||||
return c.result, nil
|
||||
}
|
||||
|
||||
func invalid(where, message string) error { return fmt.Errorf("%s: %s", where, message) }
|
||||
func name(s string, limit int) bool {
|
||||
if len(s) == 0 || len(s) > limit {
|
||||
return false
|
||||
}
|
||||
for i, r := range s {
|
||||
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || i > 0 && (r == '_' || r == '.' || r == '-')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
func cloneScope(in scope) scope {
|
||||
out := make(scope, len(in)+8)
|
||||
for key, value := range in {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
func (c *checker) block(b Block, parent scope, depth int) (uint32, error) {
|
||||
if depth > 4 || len(b.Steps) > 128 {
|
||||
return 0, invalid("body", "block depth or step limit exceeded")
|
||||
}
|
||||
if err := checkType(b.OutputType, 0); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
env := cloneScope(parent)
|
||||
var width uint32
|
||||
for _, step := range b.Steps {
|
||||
if !name(step.Key, 64) || step.Key == "input" || step.Key == "workflow" || c.keys[step.Key] {
|
||||
return 0, invalid("step", "key must be unique and nonreserved")
|
||||
}
|
||||
c.keys[step.Key] = true
|
||||
c.result.Steps++
|
||||
if c.result.Steps > 128 {
|
||||
return 0, invalid("body", "at most 128 static steps allowed")
|
||||
}
|
||||
result, concurrency, err := c.step(step, env, depth)
|
||||
if err != nil {
|
||||
return 0, invalid(step.Key, err.Error())
|
||||
}
|
||||
env[step.Key] = result
|
||||
if concurrency > width {
|
||||
width = concurrency
|
||||
}
|
||||
}
|
||||
if err := checkValue(b.Output, b.OutputType, env, 0); err != nil {
|
||||
return 0, invalid("output", err.Error())
|
||||
}
|
||||
return width, nil
|
||||
}
|
||||
func (c *checker) step(s Step, env scope, depth int) (Type, uint32, error) {
|
||||
n := 0
|
||||
for _, present := range []bool{s.Task != nil, s.Branch != nil, s.Parallel != nil, s.Signal != nil, s.Timer != nil, s.Poll != nil} {
|
||||
if present {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
return Type{}, 0, invalid("step", "exactly one kind configuration required")
|
||||
}
|
||||
switch s.Kind {
|
||||
case "TASK":
|
||||
if s.Task != nil {
|
||||
if err := c.task(*s.Task, env); err != nil {
|
||||
return Type{}, 0, err
|
||||
}
|
||||
c.result.MaxTaskAttempts += s.Task.Retry.MaxAttempts
|
||||
return s.Task.ResultType, 1, nil
|
||||
}
|
||||
case "SIGNAL":
|
||||
if s.Signal != nil {
|
||||
return s.Signal.PayloadType, 0, c.signal(*s.Signal, env)
|
||||
}
|
||||
case "TIMER":
|
||||
if s.Timer != nil && s.Timer.DelayMS > 0 && s.Timer.DelayMS <= c.timeout {
|
||||
return Type{Kind: "NULL"}, 0, nil
|
||||
}
|
||||
case "BRANCH":
|
||||
if s.Branch != nil {
|
||||
b := s.Branch
|
||||
if b.Default == nil || len(b.Cases) < 1 || len(b.Cases) > 16 {
|
||||
return Type{}, 0, invalid("branch", "1 to 16 cases and explicit default required")
|
||||
}
|
||||
width, err := c.block(*b.Default, env, depth+1)
|
||||
if err != nil {
|
||||
return Type{}, 0, err
|
||||
}
|
||||
for _, path := range b.Cases {
|
||||
if err := checkPredicate(path.When, env, 0); err != nil {
|
||||
return Type{}, 0, err
|
||||
}
|
||||
w, err := c.block(path.Body, env, depth+1)
|
||||
if err != nil {
|
||||
return Type{}, 0, err
|
||||
}
|
||||
if !sameType(path.Body.OutputType, b.Default.OutputType) {
|
||||
return Type{}, 0, invalid("branch", "all path output types must match")
|
||||
}
|
||||
if w > width {
|
||||
width = w
|
||||
}
|
||||
}
|
||||
return b.Default.OutputType, width, nil
|
||||
}
|
||||
case "PARALLEL":
|
||||
if s.Parallel != nil {
|
||||
if len(s.Parallel.Branches) < 2 || len(s.Parallel.Branches) > 8 {
|
||||
return Type{}, 0, invalid("parallel", "2 to 8 named branches required")
|
||||
}
|
||||
result := Type{Kind: "OBJECT", Fields: map[string]Type{}}
|
||||
var width uint32
|
||||
for _, branch := range s.Parallel.Branches {
|
||||
if !name(branch.Name, 64) {
|
||||
return Type{}, 0, invalid("parallel", "invalid branch name")
|
||||
}
|
||||
if _, exists := result.Fields[branch.Name]; exists {
|
||||
return Type{}, 0, invalid("parallel", "duplicate branch name")
|
||||
}
|
||||
w, err := c.block(branch.Body, env, depth+1)
|
||||
if err != nil {
|
||||
return Type{}, 0, err
|
||||
}
|
||||
width += w
|
||||
result.Fields[branch.Name] = branch.Body.OutputType
|
||||
}
|
||||
if width > 32 {
|
||||
return Type{}, 0, invalid("parallel", "at most 32 concurrent business tasks allowed")
|
||||
}
|
||||
return result, width, nil
|
||||
}
|
||||
case "POLL":
|
||||
if s.Poll != nil {
|
||||
p := s.Poll
|
||||
if p.MaxRounds == 0 || p.MaxRounds > 1000 || p.TimeoutMS == 0 || p.TimeoutMS > c.timeout || p.IntervalMS < 1000 || p.IntervalMS >= p.TimeoutMS || p.Task.TimeoutMS > p.TimeoutMS {
|
||||
return Type{}, 0, invalid("poll", "finite rounds, interval and compatible time budgets required")
|
||||
}
|
||||
if err := c.task(p.Task, env); err != nil {
|
||||
return Type{}, 0, err
|
||||
}
|
||||
status, err := pathType(p.Task.ResultType, p.StatusPath)
|
||||
if err != nil || status.Kind != "STRING" || len(status.Enum) == 0 || len(p.Pending) == 0 || len(p.Succeeded) == 0 {
|
||||
return Type{}, 0, invalid("poll", "status must be a declared string enum with pending and success cases")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, group := range [][]string{p.Pending, p.Succeeded, p.Failed, p.Review} {
|
||||
for _, v := range group {
|
||||
if !contains(status.Enum, v) || seen[v] {
|
||||
return Type{}, 0, invalid("poll", "status groups must form an exact disjoint partition")
|
||||
}
|
||||
seen[v] = true
|
||||
}
|
||||
}
|
||||
if len(seen) != len(status.Enum) {
|
||||
return Type{}, 0, invalid("poll", "unmapped status enum value")
|
||||
}
|
||||
if p.FinalSignal != nil {
|
||||
if err := c.signal(*p.FinalSignal, env); err != nil {
|
||||
return Type{}, 0, err
|
||||
}
|
||||
if p.FinalSignal.TimeoutMS != p.TimeoutMS || !sameType(p.FinalSignal.PayloadType, p.Task.ResultType) {
|
||||
return Type{}, 0, invalid("poll", "final signal must share result type and poll deadline")
|
||||
}
|
||||
}
|
||||
c.result.MaxTaskAttempts += p.MaxRounds * p.Task.Retry.MaxAttempts
|
||||
return p.Task.ResultType, 1, nil
|
||||
}
|
||||
}
|
||||
return Type{}, 0, invalid("step", "unknown kind, mismatched configuration or invalid timer")
|
||||
}
|
||||
|
||||
func (c *checker) task(t Task, env scope) error {
|
||||
if t.TimeoutMS == 0 || t.TimeoutMS > c.timeout || t.InputType.Kind == "NULL" || t.ResultType.Kind == "NULL" {
|
||||
return invalid("task", "finite timeout and non-null payload/result types required")
|
||||
}
|
||||
// 复用Pulse的处理器/策略边界,但定义必须显式提供生效值,不能在后续发布时解释不同默认值。
|
||||
v, err := service.NormalizeSubmit(service.SubmitInput{QueueID: t.QueueID, Handler: t.Handler, HandlerVersion: t.HandlerVersion, PayloadVersion: t.PayloadVersion, Payload: json.RawMessage(`{}`), TaskTimeoutMS: t.TimeoutMS, RecoverySafety: t.RecoverySafety, Retry: t.Retry, Priority: t.Priority})
|
||||
if err != nil || v.Retry != t.Retry || v.RecoverySafety != t.RecoverySafety {
|
||||
return invalid("task", "invalid or incomplete frozen Pulse policy")
|
||||
}
|
||||
if err := checkType(t.InputType, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkType(t.ResultType, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkValue(t.Input, t.InputType, env, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if t.Input.Kind == "LITERAL" && bytes.Equal(bytes.TrimSpace(t.Input.Literal), []byte("null")) {
|
||||
return invalid("task", "null task input is not accepted by Pulse")
|
||||
}
|
||||
for _, key := range []*Value{t.BusinessKey, t.ConcurrencyKey} {
|
||||
if key != nil {
|
||||
if err := checkValue(*key, Type{Kind: "STRING"}, env, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
c.queues[t.QueueID] = true
|
||||
return nil
|
||||
}
|
||||
func (c *checker) signal(s Signal, env scope) error {
|
||||
if !name(s.Name, 128) || !name(s.PayloadVersion, 32) || s.TimeoutMS == 0 || s.TimeoutMS > c.timeout {
|
||||
return invalid("signal", "name, payload version and finite timeout required")
|
||||
}
|
||||
if err := checkType(s.PayloadType, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkValue(s.Correlation, Type{Kind: "STRING"}, env, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.Correlation.Kind == "LITERAL" {
|
||||
var value string
|
||||
if json.Unmarshal(s.Correlation.Literal, &value) != nil || len(value) == 0 || len(value) > 512 {
|
||||
return invalid("signal", "literal correlation must be a nonempty string up to 512 bytes")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkType(t Type, depth int) error {
|
||||
if depth > 8 || len(t.Fields) > 64 || len(t.Enum) > 32 {
|
||||
return invalid("type", "schema depth or width limit exceeded")
|
||||
}
|
||||
if t.Kind != "OBJECT" && len(t.Fields) != 0 || t.Kind != "ARRAY" && t.Items != nil || t.Kind != "STRING" && len(t.Enum) != 0 {
|
||||
return invalid("type", "fields, items or enum do not match kind")
|
||||
}
|
||||
switch t.Kind {
|
||||
case "JSON", "NUMBER", "BOOLEAN", "NULL":
|
||||
case "STRING":
|
||||
seen := map[string]bool{}
|
||||
for _, value := range t.Enum {
|
||||
if !utf8.ValidString(value) || len(value) > 128 || seen[value] {
|
||||
return invalid("type", "invalid or duplicate string enum value")
|
||||
}
|
||||
seen[value] = true
|
||||
}
|
||||
case "OBJECT":
|
||||
for key, field := range t.Fields {
|
||||
if !name(key, 64) {
|
||||
return invalid("type", "invalid object field name")
|
||||
}
|
||||
if err := checkType(field, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case "ARRAY":
|
||||
if t.Items == nil {
|
||||
return invalid("type", "array items type required")
|
||||
}
|
||||
return checkType(*t.Items, depth+1)
|
||||
default:
|
||||
return invalid("type", "unknown kind")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func sameType(a, b Type) bool { return assignable(a, b) && assignable(b, a) }
|
||||
func assignable(actual, expected Type) bool {
|
||||
if expected.Kind == "JSON" {
|
||||
return true
|
||||
}
|
||||
if actual.Kind != expected.Kind {
|
||||
return false
|
||||
}
|
||||
switch expected.Kind {
|
||||
case "OBJECT":
|
||||
if len(actual.Fields) != len(expected.Fields) {
|
||||
return false
|
||||
}
|
||||
for key, t := range expected.Fields {
|
||||
v, ok := actual.Fields[key]
|
||||
if !ok || !assignable(v, t) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
case "ARRAY":
|
||||
return actual.Items != nil && expected.Items != nil && assignable(*actual.Items, *expected.Items)
|
||||
case "STRING":
|
||||
if len(expected.Enum) == 0 {
|
||||
return true
|
||||
}
|
||||
if len(actual.Enum) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, v := range actual.Enum {
|
||||
if !contains(expected.Enum, v) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
func contains(values []string, value string) bool {
|
||||
for _, v := range values {
|
||||
if v == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func pathType(t Type, path []string) (Type, error) {
|
||||
if len(path) > 8 {
|
||||
return Type{}, invalid("reference", "path exceeds 8 fields")
|
||||
}
|
||||
for _, key := range path {
|
||||
if t.Kind != "OBJECT" {
|
||||
return Type{}, invalid("reference", "path crosses a non-object or untyped JSON value")
|
||||
}
|
||||
var ok bool
|
||||
t, ok = t.Fields[key]
|
||||
if !ok {
|
||||
return Type{}, invalid("reference", "field not declared")
|
||||
}
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
func referenceType(r Reference, env scope) (Type, error) {
|
||||
t, ok := env[r.Source]
|
||||
if !ok {
|
||||
return Type{}, invalid("reference", "source is missing, forward, conditional or outside this block")
|
||||
}
|
||||
return pathType(t, r.Path)
|
||||
}
|
||||
|
||||
func checkValue(v Value, expected Type, env scope, depth int) error {
|
||||
if depth > 8 {
|
||||
return invalid("value", "expression depth exceeds 8")
|
||||
}
|
||||
if v.Kind != "LITERAL" && len(v.Literal) != 0 || v.Kind != "REFERENCE" && v.Reference != nil || v.Kind != "OBJECT" && len(v.Fields) != 0 || v.Kind != "ARRAY" && len(v.Items) != 0 {
|
||||
return invalid("value", "configuration does not match kind")
|
||||
}
|
||||
switch v.Kind {
|
||||
case "LITERAL":
|
||||
return ValidateData(expected, v.Literal)
|
||||
case "REFERENCE":
|
||||
if v.Reference == nil {
|
||||
return invalid("reference", "source required")
|
||||
}
|
||||
actual, err := referenceType(*v.Reference, env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !assignable(actual, expected) {
|
||||
return invalid("reference", "source and target types are incompatible")
|
||||
}
|
||||
case "OBJECT":
|
||||
if expected.Kind != "OBJECT" && expected.Kind != "JSON" || len(v.Fields) > 64 {
|
||||
return invalid("value", "object expression has incompatible type or too many fields")
|
||||
}
|
||||
if expected.Kind == "OBJECT" && len(v.Fields) != len(expected.Fields) {
|
||||
return invalid("value", "object expression must define every field exactly")
|
||||
}
|
||||
for key, child := range v.Fields {
|
||||
if !name(key, 64) {
|
||||
return invalid("value", "invalid object field")
|
||||
}
|
||||
t := Type{Kind: "JSON"}
|
||||
if expected.Kind == "OBJECT" {
|
||||
var ok bool
|
||||
t, ok = expected.Fields[key]
|
||||
if !ok {
|
||||
return invalid("value", "extra object field")
|
||||
}
|
||||
}
|
||||
if err := checkValue(child, t, env, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case "ARRAY":
|
||||
if expected.Kind != "ARRAY" && expected.Kind != "JSON" || len(v.Items) > 256 {
|
||||
return invalid("value", "array expression has incompatible type or too many elements")
|
||||
}
|
||||
t := Type{Kind: "JSON"}
|
||||
if expected.Kind == "ARRAY" {
|
||||
t = *expected.Items
|
||||
}
|
||||
for _, child := range v.Items {
|
||||
if err := checkValue(child, t, env, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
default:
|
||||
return invalid("value", "unknown kind")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scalarType(v Value, env scope) (string, error) {
|
||||
if err := checkValue(v, Type{Kind: "JSON"}, env, 0); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var kind string
|
||||
switch v.Kind {
|
||||
case "REFERENCE":
|
||||
t, err := referenceType(*v.Reference, env)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
kind = t.Kind
|
||||
case "LITERAL":
|
||||
var raw any
|
||||
d := json.NewDecoder(bytes.NewReader(v.Literal))
|
||||
d.UseNumber()
|
||||
if err := d.Decode(&raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch raw.(type) {
|
||||
case nil:
|
||||
kind = "NULL"
|
||||
case string:
|
||||
kind = "STRING"
|
||||
case bool:
|
||||
kind = "BOOLEAN"
|
||||
case json.Number:
|
||||
kind = "NUMBER"
|
||||
}
|
||||
}
|
||||
if kind != "STRING" && kind != "NUMBER" && kind != "BOOLEAN" && kind != "NULL" {
|
||||
return "", invalid("predicate", "only typed scalar comparisons allowed")
|
||||
}
|
||||
return kind, nil
|
||||
}
|
||||
func checkPredicate(p Predicate, env scope, depth int) error {
|
||||
if depth > 8 {
|
||||
return invalid("predicate", "expression depth exceeds 8")
|
||||
}
|
||||
switch p.Op {
|
||||
case "AND", "OR", "NOT":
|
||||
if p.Left != nil || p.Right != nil || len(p.Arguments) > 16 || (p.Op == "NOT" && len(p.Arguments) != 1) || (p.Op != "NOT" && len(p.Arguments) < 2) {
|
||||
return invalid("predicate", "invalid logical arguments")
|
||||
}
|
||||
for _, child := range p.Arguments {
|
||||
if err := checkPredicate(child, env, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case "EQ", "NE", "LT", "LE", "GT", "GE":
|
||||
if p.Left == nil || p.Right == nil || len(p.Arguments) != 0 {
|
||||
return invalid("predicate", "exactly two comparison operands required")
|
||||
}
|
||||
left, err := scalarType(*p.Left, env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
right, err := scalarType(*p.Right, env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if left != right || (p.Op != "EQ" && p.Op != "NE" && left != "STRING" && left != "NUMBER") {
|
||||
return invalid("predicate", "incompatible comparison operands")
|
||||
}
|
||||
default:
|
||||
return invalid("predicate", "unknown operator")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
211
weave/definition/check_test.go
Normal file
211
weave/definition/check_test.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package definition
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
|
||||
)
|
||||
|
||||
func literal(raw string) Value { return Value{Kind: "LITERAL", Literal: json.RawMessage(raw)} }
|
||||
func ref(source string, path ...string) Value {
|
||||
return Value{Kind: "REFERENCE", Reference: &Reference{Source: source, Path: path}}
|
||||
}
|
||||
func scalar(kind string) Type { return Type{Kind: kind} }
|
||||
func block(result Type, value Value, steps ...Step) Block {
|
||||
return Block{Steps: steps, OutputType: result, Output: value}
|
||||
}
|
||||
func testTask() Task {
|
||||
return Task{QueueID: "11111111111111111111111111111111", Handler: "object.prepare", HandlerVersion: "v1", PayloadVersion: "v1", InputType: scalar("STRING"), Input: ref("input", "operation"), ResultType: scalar("STRING"), TimeoutMS: 10000, Retry: pulse.RetryPolicy{MaxAttempts: 2, AttemptTimeoutMS: 3000, InitialDelayMS: 1000, MaxDelayMS: 2000}, RecoverySafety: pulse.RecoveryReconcileOnly}
|
||||
}
|
||||
func testSpecification() Specification {
|
||||
t := testTask()
|
||||
return Specification{SchemaVersion: 1, TimeoutMS: 60000, FailurePolicy: "REVIEW", InputType: Type{Kind: "OBJECT", Fields: map[string]Type{"operation": scalar("STRING"), "enabled": scalar("BOOLEAN")}}, Body: block(scalar("STRING"), ref("prepare"), Step{Key: "prepare", Kind: "TASK", Task: &t})}
|
||||
}
|
||||
func TestStructuredDefinitionScopesAndBudgets(t *testing.T) {
|
||||
base := testSpecification()
|
||||
left, right := ref("input", "enabled"), literal("true")
|
||||
task := testTask()
|
||||
branch := Branch{Cases: []BranchCase{{When: Predicate{Op: "EQ", Left: &left, Right: &right}, Body: block(scalar("STRING"), ref("chosen"), Step{Key: "chosen", Kind: "TASK", Task: &task})}}, Default: ptrBlock(block(scalar("STRING"), literal(`"skipped"`)))}
|
||||
parallel := Parallel{Branches: []ParallelBranch{{Name: "first", Body: block(scalar("STRING"), ref("choice"))}, {Name: "second", Body: block(scalar("NULL"), ref("timer"), Step{Key: "timer", Kind: "TIMER", Timer: &Timer{DelayMS: 1}})}}}
|
||||
base.Body = block(Type{Kind: "OBJECT", Fields: map[string]Type{"first": scalar("STRING"), "second": scalar("NULL")}}, ref("joined"), Step{Key: "choice", Kind: "BRANCH", Branch: &branch}, Step{Key: "joined", Kind: "PARALLEL", Parallel: ¶llel})
|
||||
got, err := Check(base)
|
||||
if err != nil || got.Steps != 4 || got.MaxConcurrency != 1 || got.MaxTaskAttempts != 2 || len(got.Queues) != 1 {
|
||||
t.Fatal("structured definition", got, err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
change func(*Specification)
|
||||
}{
|
||||
{"forward_reference", func(s *Specification) { s.Body.Steps[0].Task.Input = ref("later") }},
|
||||
{"self_reference", func(s *Specification) { s.Body.Steps[0].Task.Input = ref("prepare") }},
|
||||
{"missing_property", func(s *Specification) { s.Body.Steps[0].Task.Input = ref("input", "missing") }},
|
||||
{"wrong_type", func(s *Specification) { s.Body.Steps[0].Task.Input = ref("input", "enabled") }},
|
||||
{"default_policy_not_frozen", func(s *Specification) { s.Body.Steps[0].Task.Retry.MaxAttempts = 0 }},
|
||||
{"unknown_kind", func(s *Specification) { s.Body.Steps[0].Kind = "SCRIPT" }},
|
||||
{"extra_kind_body", func(s *Specification) { s.Body.Steps[0].Timer = &Timer{DelayMS: 1} }},
|
||||
{"reserved_step_name", func(s *Specification) { s.Body.Steps[0].Key = "input" }},
|
||||
{"type_cycle", func(s *Specification) { x := scalar("ARRAY"); x.Items = &x; s.InputType = x }},
|
||||
{"invalid_unicode", func(s *Specification) { s.Body.Steps[0].Task.Input = literal(`"\ud800"`) }},
|
||||
{"duplicate_literal_key", func(s *Specification) {
|
||||
s.Body.Steps[0].Task.InputType = scalar("JSON")
|
||||
s.Body.Steps[0].Task.Input = literal(`{"a":1,"a":2}`)
|
||||
}},
|
||||
{"null_task_literal", func(s *Specification) {
|
||||
s.Body.Steps[0].Task.InputType = scalar("JSON")
|
||||
s.Body.Steps[0].Task.Input = literal(" null ")
|
||||
}},
|
||||
{"empty_task_deadline", func(s *Specification) { s.Body.Steps[0].Task.TimeoutMS = 0 }},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
s := testSpecification()
|
||||
test.change(&s)
|
||||
if _, err := Check(s); err == nil {
|
||||
t.Fatal("invalid definition accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
branch.Cases[0].Body.Output = ref("joined", "first")
|
||||
if _, err := Check(base); err == nil {
|
||||
t.Fatal("future join result was visible inside a branch")
|
||||
}
|
||||
branch.Cases[0].Body.Output = ref("chosen")
|
||||
base.Body.Output = ref("chosen")
|
||||
if _, err := Check(base); err == nil {
|
||||
t.Fatal("conditional child leaked outside composite")
|
||||
}
|
||||
base.Body.Output = ref("joined")
|
||||
parallel.Branches[1].Body.Output = ref("chosen")
|
||||
if _, err := Check(base); err == nil {
|
||||
t.Fatal("sibling/unselected result leaked")
|
||||
}
|
||||
parallel.Branches[1].Body.Output = ref("timer")
|
||||
branch.Default = nil
|
||||
if _, err := Check(base); err == nil {
|
||||
t.Fatal("nonexhaustive branch accepted")
|
||||
}
|
||||
}
|
||||
func ptrBlock(b Block) *Block { return &b }
|
||||
|
||||
func TestDefinitionParallelBudgetAndDuplicateIdentity(t *testing.T) {
|
||||
makeParallel := func(prefix string) Step {
|
||||
p := Parallel{}
|
||||
for _, suffix := range []string{"a", "b", "c", "d", "e", "f", "g", "h"} {
|
||||
task := testTask()
|
||||
key := prefix + suffix
|
||||
p.Branches = append(p.Branches, ParallelBranch{Name: suffix, Body: block(scalar("STRING"), ref(key), Step{Key: key, Kind: "TASK", Task: &task})})
|
||||
}
|
||||
return Step{Key: prefix, Kind: "PARALLEL", Parallel: &p}
|
||||
}
|
||||
s := testSpecification()
|
||||
p := makeParallel("wide")
|
||||
s.Body = block(scalar("JSON"), ref("wide"), p)
|
||||
got, err := Check(s)
|
||||
if err != nil || got.MaxConcurrency != 8 || got.Steps != 9 || got.MaxTaskAttempts != 16 {
|
||||
t.Fatal(got, err)
|
||||
}
|
||||
p.Parallel.Branches[1].Body.Steps[0].Key = "widea"
|
||||
if _, err := Check(s); err == nil {
|
||||
t.Fatal("duplicate step identities accepted")
|
||||
}
|
||||
outer := Parallel{}
|
||||
for _, prefix := range []string{"a", "b", "c", "d", "e"} {
|
||||
child := makeParallel(prefix)
|
||||
outer.Branches = append(outer.Branches, ParallelBranch{Name: prefix, Body: block(scalar("JSON"), ref(prefix), child)})
|
||||
}
|
||||
s.Body = block(scalar("JSON"), ref("outer"), Step{Key: "outer", Kind: "PARALLEL", Parallel: &outer})
|
||||
if _, err := Check(s); err == nil {
|
||||
t.Fatal("nested concurrency over 32 accepted")
|
||||
}
|
||||
s = testSpecification()
|
||||
for i := 0; i < 128; i++ {
|
||||
task := testTask()
|
||||
s.Body.Steps = append(s.Body.Steps, Step{Key: "extra" + strings.Repeat("x", i+1), Kind: "TASK", Task: &task})
|
||||
}
|
||||
if _, err := Check(s); err == nil {
|
||||
t.Fatal("overlong block accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollPartitionsAndIndependentBudgets(t *testing.T) {
|
||||
s := testSpecification()
|
||||
task := testTask()
|
||||
task.ResultType = Type{Kind: "OBJECT", Fields: map[string]Type{"state": {Kind: "STRING", Enum: []string{"processing", "done", "failed", "review"}}, "reference": scalar("STRING")}}
|
||||
p := Poll{Task: task, MaxRounds: 3, TimeoutMS: 30000, IntervalMS: 1000, StatusPath: []string{"state"}, Pending: []string{"processing"}, Succeeded: []string{"done"}, Failed: []string{"failed"}, Review: []string{"review"}, FinalSignal: &Signal{Name: "completion", PayloadVersion: "v1", Correlation: ref("input", "operation"), PayloadType: task.ResultType, TimeoutMS: 30000}}
|
||||
s.Body = block(task.ResultType, ref("poll"), Step{Key: "poll", Kind: "POLL", Poll: &p})
|
||||
got, err := Check(s)
|
||||
if err != nil || got.MaxTaskAttempts != 6 || got.Steps != 1 {
|
||||
t.Fatal(got, err)
|
||||
}
|
||||
for _, change := range []func(*Poll){
|
||||
func(p *Poll) { p.MaxRounds = 0 }, func(p *Poll) { p.MaxRounds = 1001 }, func(p *Poll) { p.IntervalMS = 0 }, func(p *Poll) { p.IntervalMS = p.TimeoutMS },
|
||||
func(p *Poll) { p.Review = nil }, func(p *Poll) { p.Failed = []string{"processing"} }, func(p *Poll) { p.StatusPath = []string{"reference"} },
|
||||
func(p *Poll) { p.MaxRounds = 1000; p.Task.Retry.MaxAttempts = 100 }, func(p *Poll) { p.Task.TimeoutMS = p.TimeoutMS + 1 },
|
||||
func(p *Poll) { v := *p.FinalSignal; v.TimeoutMS++; p.FinalSignal = &v },
|
||||
} {
|
||||
copy := p
|
||||
change(©)
|
||||
s.Body.Steps[0].Poll = ©
|
||||
if _, err := Check(s); err == nil {
|
||||
t.Fatal("unbounded or ambiguous poll accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefinitionDigestAndPreciseData(t *testing.T) {
|
||||
s := testSpecification()
|
||||
s.Body.Steps[0].Task.InputType = scalar("JSON")
|
||||
s.Body.Steps[0].Task.Input = literal(`{"n":9007199254740993,"decimal":12345678901234567890.1234567890123456789}`)
|
||||
a, err := Check(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, _ := json.Marshal(s)
|
||||
s.Body.Steps[0].Task.Input = literal(`{"decimal":12345678901234567890.123456789012345678900,"n":9007199254740993.0}`)
|
||||
b, err := Check(s)
|
||||
if err != nil || a.Digest != b.Digest {
|
||||
t.Fatal("semantic numeric digest changed", err)
|
||||
}
|
||||
s.Body.Steps[0].Task.Input = literal(`{"n":9007199254740992,"decimal":12345678901234567890.1234567890123456789}`)
|
||||
c, err := Check(s)
|
||||
if err != nil || a.Digest == c.Digest {
|
||||
t.Fatal("large integer rounded", err)
|
||||
}
|
||||
var old Specification
|
||||
if json.Unmarshal(before, &old) != nil {
|
||||
t.Fatal("snapshot")
|
||||
}
|
||||
if _, err := Check(old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, _ := json.Marshal(old)
|
||||
if !bytes.Equal(before, after) {
|
||||
t.Fatal("check mutated definition")
|
||||
}
|
||||
for _, raw := range []string{`"bad"`, `null`, `{"extra":1}`, `{"operation":"x","enabled":true,"extra":1}`, `{"operation":"x","enabled":1}`, `{"operation":"x","operation":"y","enabled":true}`} {
|
||||
if ValidateData(old.InputType, json.RawMessage(raw)) == nil {
|
||||
t.Fatal("bad instance input accepted")
|
||||
}
|
||||
}
|
||||
if err := ValidateData(old.InputType, json.RawMessage(`{"operation":"x","enabled":true}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s = Specification{SchemaVersion: 1, InputType: scalar("NULL"), TimeoutMS: 1, FailurePolicy: "REVIEW", Body: block(scalar("NULL"), literal("null"))}
|
||||
if _, err := Check(s); err != nil {
|
||||
t.Fatal("empty typed block must be valid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func FuzzDefinitionDataNeverPanics(f *testing.F) {
|
||||
for _, raw := range []string{`{"operation":"x","enabled":true}`, `null`, `{"n":9007199254740993}`, `{"n":1,"n":2}`} {
|
||||
f.Add([]byte(raw))
|
||||
}
|
||||
f.Fuzz(func(t *testing.T, raw []byte) {
|
||||
if len(raw) > 4096 {
|
||||
return
|
||||
}
|
||||
_ = ValidateData(testSpecification().InputType, raw)
|
||||
})
|
||||
}
|
||||
75
weave/definition/data.go
Normal file
75
weave/definition/data.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package definition
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/jsonvalue"
|
||||
)
|
||||
|
||||
// ValidateData 在实例输入、任务成功结果、信号和块输出边界复用同一类型契约。
|
||||
// 使用精确JSON数值文本;大小、重复字段、Unicode和嵌套限制先于类型检查。
|
||||
func ValidateData(schema Type, raw json.RawMessage) error {
|
||||
if err := checkType(schema, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(raw) == 0 || len(raw) > 256<<10 {
|
||||
return invalid("data", "JSON value required, maximum 256 KiB")
|
||||
}
|
||||
if _, err := jsonvalue.Canonical(raw); err != nil {
|
||||
return invalid("data", "invalid JSON, duplicate fields or unsupported value limits")
|
||||
}
|
||||
var value any
|
||||
d := json.NewDecoder(bytes.NewReader(raw))
|
||||
d.UseNumber()
|
||||
if err := d.Decode(&value); err != nil {
|
||||
return invalid("data", "invalid JSON value")
|
||||
}
|
||||
return matchData(schema, value)
|
||||
}
|
||||
func matchData(schema Type, value any) error {
|
||||
valid := false
|
||||
switch schema.Kind {
|
||||
case "JSON":
|
||||
return nil
|
||||
case "NULL":
|
||||
valid = value == nil
|
||||
case "STRING":
|
||||
v, ok := value.(string)
|
||||
valid = ok && (len(schema.Enum) == 0 || contains(schema.Enum, v))
|
||||
case "NUMBER":
|
||||
_, valid = value.(json.Number)
|
||||
case "BOOLEAN":
|
||||
_, valid = value.(bool)
|
||||
case "OBJECT":
|
||||
v, ok := value.(map[string]any)
|
||||
if !ok || len(v) != len(schema.Fields) {
|
||||
return invalid("data", "object fields do not match declared type")
|
||||
}
|
||||
for key, t := range schema.Fields {
|
||||
child, ok := v[key]
|
||||
if !ok {
|
||||
return invalid("data", "required object field missing")
|
||||
}
|
||||
if err := matchData(t, child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case "ARRAY":
|
||||
v, ok := value.([]any)
|
||||
if !ok || len(v) > 4096 {
|
||||
return invalid("data", "array required, maximum 4096 elements")
|
||||
}
|
||||
for _, child := range v {
|
||||
if err := matchData(*schema.Items, child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !valid {
|
||||
return invalid("data", "value does not match declared type")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
116
weave/definition/service.go
Normal file
116
weave/definition/service.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package definition
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||
)
|
||||
|
||||
const ReadAction = "workflows:definitions:read"
|
||||
const PublishAction = "workflows:definitions:publish"
|
||||
const MaxDefinitions = 100
|
||||
const MaxVersions = 100
|
||||
|
||||
// CreateInput 创建命名空间内的稳定定义身份,不发布版本或启动实例。
|
||||
type CreateInput struct {
|
||||
Name string `json:"name"` // Name 命名空间内唯一ASCII名称,最长128字符。
|
||||
Description string `json:"description"` // Description 用途说明,最多1024个Unicode字符。
|
||||
}
|
||||
|
||||
// PublishInput 发布不可变版本;同版本内容冲突不能通过更换幂等键覆盖。
|
||||
type PublishInput struct {
|
||||
Version string `json:"version"` // Version 研发指定的ASCII版本名,最长64字符,不包含路径分隔符。
|
||||
Specification Specification `json:"specification"` // Specification 完整且显式的冻结规则。
|
||||
Reason string `json:"reason"` // Reason 非空发布原因,最多1024个字符,写入审计。
|
||||
}
|
||||
|
||||
// Summary 仅含命名空间级元数据;读取完整版本还须授权所有引用队列。
|
||||
type Summary struct {
|
||||
ID core.ID `json:"id"` // ID 定义稳定标识。
|
||||
NamespaceID core.ID `json:"namespace_id"` // NamespaceID 定义隔离范围。
|
||||
Name string `json:"name"` // Name 创建时冻结的唯一名称。
|
||||
Description string `json:"description"` // Description 创建时冻结的用途说明。
|
||||
Revision uint64 `json:"revision,string"` // Revision 从1开始,每个新发布版本增加1,不能替代业务版本名。
|
||||
CreatedAt time.Time `json:"created_at"` // CreatedAt UTC数据库创建时间。
|
||||
UpdatedAt time.Time `json:"updated_at"` // UpdatedAt 最近新版本发布的UTC数据库时间。
|
||||
}
|
||||
|
||||
// Analysis 是静态预算上界;不表示业务任务已执行或已分配配额。
|
||||
type Analysis struct {
|
||||
Steps uint32 `json:"steps"` // Steps 全定义静态步骤数。
|
||||
MaxConcurrency uint32 `json:"max_concurrency"` // MaxConcurrency 并行业务任务上界。
|
||||
MaxTaskAttempts uint32 `json:"max_task_attempts"` // MaxTaskAttempts 所有路径的保守尝试预算总和。
|
||||
Queues []core.ID `json:"queues"` // Queues 排序去重队列,始终为数组。
|
||||
}
|
||||
|
||||
// VersionView 返回原始发布内容和身份,不使用可变的latest别名。
|
||||
type VersionView struct {
|
||||
ID core.ID `json:"id"` // ID 不可变版本记录标识。
|
||||
NamespaceID core.ID `json:"namespace_id"` // NamespaceID 隔离范围。
|
||||
DefinitionID core.ID `json:"definition_id"` // DefinitionID 父定义标识,由应用事务校验。
|
||||
Version string `json:"version"` // Version 发布版本名。
|
||||
Specification Specification `json:"specification"` // Specification 已冻结规则,数字精度不经float64转换。
|
||||
ContentDigest string `json:"content_digest"` // ContentDigest 带域分隔的SHA-256,64位小写十六进制。
|
||||
PublishedBy core.ID `json:"published_by"` // PublishedBy 原发布凭据标识,不包含凭据秘密。
|
||||
PublishedAt time.Time `json:"published_at"` // PublishedAt 原发布时间,重复发布不更新。
|
||||
Analysis Analysis `json:"analysis"` // Analysis 根据结构版本1规则计算的静态检查结果。
|
||||
}
|
||||
type CreateResult struct {
|
||||
Definition Summary `json:"definition"` // Definition 创建事务的原始快照。
|
||||
Deduplicated bool `json:"deduplicated"` // Deduplicated 是否返回原回执。
|
||||
}
|
||||
type PublishResult struct {
|
||||
Version VersionView `json:"version"` // Version 原版本快照,不包含当前父定义状态。
|
||||
Deduplicated bool `json:"deduplicated"` // Deduplicated 原键或同版本同内容重复请求。
|
||||
}
|
||||
type Store interface {
|
||||
Create(context.Context, auth.Token, string, CreateInput) (CreateResult, error)
|
||||
Get(context.Context, auth.Token, core.ID) (Summary, error)
|
||||
Publish(context.Context, auth.Token, core.ID, string, PublishInput) (PublishResult, error)
|
||||
Version(context.Context, auth.Token, core.ID, string) (VersionView, error)
|
||||
}
|
||||
type Service struct{ store Store }
|
||||
|
||||
func New(store Store) *Service { return &Service{store: store} }
|
||||
func (s *Service) Create(ctx context.Context, t auth.Token, key string, in CreateInput) (CreateResult, error) {
|
||||
if err := service.ValidateKey(key); err != nil {
|
||||
return CreateResult{}, err
|
||||
}
|
||||
if !name(in.Name, 128) || !textValid(in.Description, false) {
|
||||
return CreateResult{}, fault.Invalid("invalid workflow definition name or description")
|
||||
}
|
||||
return s.store.Create(ctx, t, key, in)
|
||||
}
|
||||
func textValid(s string, required bool) bool {
|
||||
return utf8.ValidString(s) && utf8.RuneCountInString(s) <= 1024 && !strings.ContainsRune(s, 0) && (!required || strings.TrimSpace(s) != "")
|
||||
}
|
||||
func (s *Service) Get(ctx context.Context, t auth.Token, id core.ID) (Summary, error) {
|
||||
if id.Validate() != nil {
|
||||
return Summary{}, fault.Invalid("invalid definition id")
|
||||
}
|
||||
return s.store.Get(ctx, t, id)
|
||||
}
|
||||
func (s *Service) Publish(ctx context.Context, t auth.Token, id core.ID, key string, in PublishInput) (PublishResult, error) {
|
||||
if err := service.ValidateKey(key); err != nil {
|
||||
return PublishResult{}, err
|
||||
}
|
||||
if id.Validate() != nil || !name(in.Version, 64) || !textValid(in.Reason, true) {
|
||||
return PublishResult{}, fault.Invalid("valid definition id, version and reason required")
|
||||
}
|
||||
if _, err := Check(in.Specification); err != nil {
|
||||
return PublishResult{}, fault.Invalid(err.Error())
|
||||
}
|
||||
return s.store.Publish(ctx, t, id, key, in)
|
||||
}
|
||||
func (s *Service) Version(ctx context.Context, t auth.Token, id core.ID, version string) (VersionView, error) {
|
||||
if id.Validate() != nil || !name(version, 64) {
|
||||
return VersionView{}, fault.Invalid("invalid definition id or version")
|
||||
}
|
||||
return s.store.Version(ctx, t, id, version)
|
||||
}
|
||||
130
weave/definition/types.go
Normal file
130
weave/definition/types.go
Normal file
@@ -0,0 +1,130 @@
|
||||
// Package definition 描述可发布的有限流程;只解释类型和数据引用,不执行上传的业务代码。
|
||||
package definition
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
|
||||
)
|
||||
|
||||
// Specification 是结构版本1的完整定义。版本发布后禁止原地修改。
|
||||
type Specification struct {
|
||||
SchemaVersion uint32 `json:"schema_version"` // SchemaVersion 当前必须为1,和业务发布版本分开。
|
||||
InputType Type `json:"input_type"` // InputType 实例输入契约,OBJECT字段全部必填且不允许额外字段。
|
||||
TimeoutMS uint64 `json:"timeout_ms"` // TimeoutMS 从实例受理起计的有限总时限,1毫秒至30天,暂停不续期。
|
||||
FailurePolicy string `json:"failure_policy"` // FailurePolicy REVIEW或FAIL_AFTER_SETTLED;未知效果始终先核查。
|
||||
Body Block `json:"body"` // Body 顶层顺序块;组合节点内部同样使用有限顺序块。
|
||||
}
|
||||
|
||||
// Type 是有限的类型描述;JSON表示只能整体传递的任意JSON,不能据此访问未知字段。
|
||||
type Type struct {
|
||||
Kind string `json:"kind"` // Kind STRING、NUMBER、BOOLEAN、NULL、OBJECT、ARRAY或JSON。
|
||||
Fields map[string]Type `json:"fields,omitempty"` // Fields 仅OBJECT使用,最多64项;字段名称为ASCII标识。
|
||||
Items *Type `json:"items,omitempty"` // Items 仅ARRAY使用,数组元素采用同一契约。
|
||||
Enum []string `json:"enum,omitempty"` // Enum 仅STRING使用,可选的1至32个互异值,按精确文本比较。
|
||||
}
|
||||
|
||||
// Value 明确区分常量、引用和构造对象/数组,禁止字符串插值和任意脚本。
|
||||
type Value struct {
|
||||
Kind string `json:"kind"` // Kind LITERAL、REFERENCE、OBJECT或ARRAY。
|
||||
Literal json.RawMessage `json:"literal,omitempty" nullable:"true"` // Literal 仅LITERAL使用,保留精确数字,可以显式为null。
|
||||
Reference *Reference `json:"reference,omitempty"` // Reference 仅REFERENCE使用,必须指向当前路径可用的输入或结果。
|
||||
Fields map[string]Value `json:"fields,omitempty"` // Fields 仅OBJECT使用,字段值递归解析,最多64项。
|
||||
Items []Value `json:"items,omitempty"` // Items 仅ARRAY使用,最多256项;业务大数组可整体引用。
|
||||
}
|
||||
|
||||
type Reference struct {
|
||||
Source string `json:"source"` // Source input、workflow或在当前词法作用域已完成的步骤key。
|
||||
Path []string `json:"path,omitempty"` // Path 最多8层已声明OBJECT字段;空路径引用整个值,不支持动态路径或数组下标。
|
||||
}
|
||||
|
||||
// Block 的输出是组合节点向外暴露的唯一数据出口;内部步骤不能被外部直接引用。
|
||||
type Block struct {
|
||||
Steps []Step `json:"steps,omitempty"` // Steps 按顺序执行;空块可直接映射输入,所有分支仍需显式输出。
|
||||
OutputType Type `json:"output_type"` // OutputType 本块输出契约。
|
||||
Output Value `json:"output"` // Output 全部步骤完成后解析的输出表达式。
|
||||
}
|
||||
|
||||
type Step struct {
|
||||
Key string `json:"key"` // Key 全定义唯一ASCII标识,1至64字符,不能为input或workflow。
|
||||
Kind string `json:"kind"` // Kind TASK、BRANCH、PARALLEL、SIGNAL、TIMER或POLL;必须只提供对应配置。
|
||||
Task *Task `json:"task,omitempty"` // Task 普通业务工作,交由独立Worker处理。
|
||||
Branch *Branch `json:"branch,omitempty"` // Branch 按顺序选择第一个匹配路径,必须有默认路径。
|
||||
Parallel *Parallel `json:"parallel,omitempty"` // Parallel 静态有限分支,全部成功后汇合。
|
||||
Signal *Signal `json:"signal,omitempty"` // Signal 等待一次按步骤和业务操作匹配的外部输入。
|
||||
Timer *Timer `json:"timer,omitempty"` // Timer 持久化时间等待,不占业务Worker槽位。
|
||||
Poll *Poll `json:"poll,omitempty"` // Poll 有界查询,轮次预算与每轮Pulse尝试预算独立。
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
QueueID core.ID `json:"queue_id"` // QueueID 发布及启动时都需按命名空间和当前权限校验。
|
||||
Handler string `json:"handler"` // Handler 已命名的业务处理器;定义不携带可执行源码。
|
||||
HandlerVersion string `json:"handler_version"` // HandlerVersion 固定的处理器版本。
|
||||
PayloadVersion string `json:"payload_version"` // PayloadVersion 固定的输入契约版本。
|
||||
InputType Type `json:"input_type"` // InputType 输入映射必须满足的处理器参数契约。
|
||||
Input Value `json:"input"` // Input 在激活时根据已保存数据解析,不使用当前时间或随机数。
|
||||
ResultType Type `json:"result_type"` // ResultType 权威成功结果契约,不匹配时进入核查而不推进。
|
||||
TimeoutMS uint64 `json:"timeout_ms"` // TimeoutMS 单任务总时限,正值且不超过流程时限,运行时还受实例剩余时限限制。
|
||||
Retry pulse.RetryPolicy `json:"retry"` // Retry 显式冻结有限策略,包含首次尝试,最多100次。
|
||||
RecoverySafety pulse.RecoverySafety `json:"recovery_safety"` // RecoverySafety 显式RECONCILE_ONLY或IDEMPOTENT。
|
||||
Priority int16 `json:"priority,omitempty"` // Priority 冻结优先级0至9。
|
||||
BusinessKey *Value `json:"business_key,omitempty"` // BusinessKey 可选字符串映射,只用于业务追踪,不代表幂等。
|
||||
ConcurrencyKey *Value `json:"concurrency_key,omitempty"` // ConcurrencyKey 可选字符串映射,采用Pulse共享资源许可规则。
|
||||
}
|
||||
|
||||
type Predicate struct {
|
||||
Op string `json:"op"` // Op EQ/NE/LT/LE/GT/GE或AND/OR/NOT;只比较已声明的标量。
|
||||
Left *Value `json:"left,omitempty"` // Left 比较操作左值,逻辑操作不得携带。
|
||||
Right *Value `json:"right,omitempty"` // Right 比较操作右值,必须与左值同种标量。
|
||||
Arguments []Predicate `json:"arguments,omitempty"` // Arguments AND/OR为2至16项,NOT为1项,深度最多8层。
|
||||
}
|
||||
|
||||
type Branch struct {
|
||||
Cases []BranchCase `json:"cases"` // Cases 1至16个按顺序匹配的条件路径。
|
||||
Default *Block `json:"default"` // Default 必填,穷尽未匹配情况,不能静默跳过。
|
||||
}
|
||||
type BranchCase struct {
|
||||
When Predicate `json:"when"` // When 在分支激活时根据前置持久化数据计算。
|
||||
Body Block `json:"body"` // Body 选中路径;所有路径输出类型必须一致。
|
||||
}
|
||||
type Parallel struct {
|
||||
Branches []ParallelBranch `json:"branches"` // Branches 2至8个静态分支;结果为各分支名称对应输出的OBJECT。
|
||||
}
|
||||
type ParallelBranch struct {
|
||||
Name string `json:"name"` // Name 本并行节点内唯一ASCII字段标识。
|
||||
Body Block `json:"body"` // Body 分支内部独立顺序块;不能读取兄弟分支尚未汇合的数据。
|
||||
}
|
||||
|
||||
type Signal struct {
|
||||
Name string `json:"name"` // Name 外部信号类型,需同时匹配实例、步骤激活和业务关联。
|
||||
PayloadVersion string `json:"payload_version"` // PayloadVersion 外部输入契约版本。
|
||||
Correlation Value `json:"correlation"` // Correlation 必须解析为非空业务操作字符串,运行时冻结在等待记录。
|
||||
PayloadType Type `json:"payload_type"` // PayloadType 信号内容及该节点成功输出的类型。
|
||||
TimeoutMS uint64 `json:"timeout_ms"` // TimeoutMS 激活起的有限等待时限,不超过流程时限;暂停不续期。
|
||||
}
|
||||
type Timer struct {
|
||||
DelayMS uint64 `json:"delay_ms"` // DelayMS 激活起的正等待时长,不超过流程时限;输出为NULL。
|
||||
}
|
||||
|
||||
type Poll struct {
|
||||
Task Task `json:"task"` // Task 同一查询处理器及固定输入;查询不得再次调用原业务提交接口。
|
||||
MaxRounds uint32 `json:"max_rounds"` // MaxRounds 1至1000,轮次含首次;每轮一个任务,单轮内部重试不新增轮次。
|
||||
TimeoutMS uint64 `json:"timeout_ms"` // TimeoutMS 激活起的总查询时限,不超过流程时限。
|
||||
IntervalMS uint64 `json:"interval_ms"` // IntervalMS 从上一轮处理中结果受理后等待,至少1000毫秒且小于查询总时限;不补发错过的轮次。
|
||||
StatusPath []string `json:"status_path,omitempty"` // StatusPath 成功查询结果内的STRING枚举字段路径,空表示整个结果就是枚举。
|
||||
Pending []string `json:"pending"` // Pending 表示仍在处理的枚举值,至少1项。
|
||||
Succeeded []string `json:"succeeded"` // Succeeded 表示业务确认成功的枚举值,至少1项。
|
||||
Failed []string `json:"failed,omitempty"` // Failed 表示业务确认失败的枚举值。
|
||||
Review []string `json:"review,omitempty"` // Review 表示需要核查的枚举值;四组必须无交集并覆盖整个状态枚举。
|
||||
FinalSignal *Signal `json:"final_signal,omitempty"` // FinalSignal 可选最终回执,其内容类型与查询结果一致,只接受非Pending状态。
|
||||
}
|
||||
|
||||
// Checked 是发布验证的计算结果;资源上限按所有静态路径保守计数,不代表运行已发生。
|
||||
type Checked struct {
|
||||
Steps uint32 // Steps 静态节点总数,不按轮次展开,最多128。
|
||||
MaxConcurrency uint32 // MaxConcurrency 结构可产生的并行工作上界,最多32。
|
||||
MaxTaskAttempts uint32 // MaxTaskAttempts 各静态任务尝试数与查询轮次乘积总和,最多10000。
|
||||
Queues []core.ID // Queues 排序且去重的引用队列,发布/启动必须检查权限和归属。
|
||||
Digest core.Digest // Digest 规范化JSON的SHA-256,用于不可变版本内容比较。
|
||||
}
|
||||
Reference in New Issue
Block a user