Files
orbit/internal/storage/mysql/workflow_instances.go

307 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package mysql
import (
"context"
"database/sql"
"encoding/json"
"errors"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
"git.yuntuo.hk/k2safe/orbit/internal/jsonwire"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
"git.yuntuo.hk/k2safe/orbit/weave/definition"
"git.yuntuo.hk/k2safe/orbit/weave/engine"
"git.yuntuo.hk/k2safe/orbit/weave/instance"
)
// WorkflowInstanceStore 以应用额度锁串行受理,在一个事务内建立全部归属及初始工作。
// 公共HTTP与内部运行器复用同一套固定版本、幂等与事务规则。
type WorkflowInstanceStore struct{ *TaskStore }
func NewWorkflowInstanceStore(db *sql.DB) *WorkflowInstanceStore {
return &WorkflowInstanceStore{NewTaskStore(db)}
}
var _ instance.Store = (*WorkflowInstanceStore)(nil)
// 根步骤是实例级有限推进的检查点;$不属于合法的用户步骤字符,不能与定义冲突。
const workflowRootKey = "$root"
type workflowGraph struct {
view instance.View
gate core.ID
version definition.VersionView
program *engine.Program
steps map[string]*workflowStep
}
type workflowStep struct {
view instance.StepView
input json.RawMessage
output json.RawMessage
}
type workflowPolicy struct {
SchemaVersion uint32 `json:"schema_version"` // SchemaVersion 冻结策略快照的结构版本,当前为1。
VersionID core.ID `json:"version_id"` // VersionID 固定的不可变定义版本,读取时核对实例归属。
ContentDigest string `json:"content_digest"` // ContentDigest 固定定义的SHA-256摘要,防止策略和规则错配。
FailurePolicy string `json:"failure_policy"` // FailurePolicy 原定义的失败结清策略,不随新发布版本变化。
MaxTaskAttempts uint32 `json:"max_task_attempts"` // MaxTaskAttempts 所有路径的静态尝试上界,实际预算由任务与轮次独立保存。
}
func readWorkflowInstance(ctx context.Context, tx *sql.Tx, ns, id core.ID, lock bool) (instance.View, core.ID, error) {
var v instance.View
var gate core.ID
q := `SELECT i.id,i.namespace_id,i.definition_id,i.definition_version_id,v.version,i.gate_id,i.state,i.revision,i.paused,i.control_revision,i.input,i.output,COALESCE(i.business_key,''),i.deadline_at,i.cancel_requested_at,i.stop_kind,i.stop_requested_at,i.completed_at,i.created_at,i.updated_at
FROM weave_instances i JOIN weave_definition_versions v ON v.namespace_id=i.namespace_id AND v.definition_id=i.definition_id AND v.id=i.definition_version_id
JOIN weave_definitions d ON d.namespace_id=i.namespace_id AND d.id=i.definition_id
JOIN pulse_execution_gates g ON g.namespace_id=i.namespace_id AND g.id=i.gate_id
WHERE i.namespace_id=? AND i.id=?`
if lock {
q += ` FOR SHARE`
}
var input, output []byte
err := tx.QueryRowContext(ctx, q, ns, id).Scan(&v.ID, &v.NamespaceID, &v.DefinitionID, &v.DefinitionVersionID, &v.Version, &gate, &v.State, &v.Revision, &v.Paused, &v.ControlRevision, &input, &output, &v.BusinessKey, &v.DeadlineAt, &v.CancelRequestedAt, &v.StopKind, &v.StopRequestedAt, &v.CompletedAt, &v.CreatedAt, &v.UpdatedAt)
v.Input, v.Output = input, output
if errors.Is(err, sql.ErrNoRows) {
err = fault.NotFound
}
return v, gate, err
}
func instanceVersion(ctx context.Context, tx *sql.Tx, v instance.View) (definition.VersionView, error) {
ver, err := readWorkflowVersion(ctx, tx, v.NamespaceID, v.DefinitionID, v.Version)
if err != nil {
return ver, err
}
if ver.ID != v.DefinitionVersionID {
return ver, errors.New("workflow fixed version mismatch")
}
if definition.ValidateData(ver.Specification.InputType, v.Input) != nil {
return ver, errors.New("workflow stored input invalid")
}
if v.State == "SUCCEEDED" && definition.ValidateData(ver.Specification.Body.OutputType, v.Output) != nil {
return ver, errors.New("workflow stored output invalid")
}
var raw []byte
if err := tx.QueryRowContext(ctx, `SELECT policy_snapshot FROM weave_instances WHERE namespace_id=? AND id=?`, v.NamespaceID, v.ID).Scan(&raw); err != nil {
return ver, err
}
var policy workflowPolicy
if json.Unmarshal(raw, &policy) != nil || policy != (workflowPolicy{1, ver.ID, ver.ContentDigest, ver.Specification.FailurePolicy, ver.Analysis.MaxTaskAttempts}) {
return ver, errors.New("workflow frozen policy mismatch")
}
return ver, nil
}
func lockWorkflowQueues(ctx context.Context, tx *sql.Tx, ns core.ID, p auth.Permissions, queues []core.ID) error {
// Checked.Queues始终排序去重。这里直接取排他锁,禁止先共享后升级。
for _, id := range queues {
if !p.AllowsQueue(id) {
return fault.NotFound
}
if _, _, err := lockQueueAfterApplication(ctx, tx, ns, id); err != nil {
return err
}
}
return nil
}
func (s *WorkflowInstanceStore) Start(ctx context.Context, t auth.Token, key string, in instance.StartInput) (instance.StartResult, error) {
var out instance.StartResult
err := s.transaction(ctx, t, instance.StartAction, func(tx *sql.Tx, p auth.Permissions) error {
if _, err := lockApplicationResources(ctx, tx, t.NamespaceID, false); err != nil {
return err
}
id, err := core.NewID()
if err != nil {
return err
}
identity, cached, err := reserveIdentity(ctx, tx, t.NamespaceID, "workflow.start", "", key, "workflow", id, in)
if err != nil {
return err
}
if _, err := readWorkflowDefinition(ctx, tx, t.NamespaceID, in.DefinitionID, false); err != nil {
return err
}
version, err := readWorkflowVersion(ctx, tx, t.NamespaceID, in.DefinitionID, in.Version)
if err != nil {
return err
}
if len(version.Analysis.Queues) > 0 && !p.Allows("tasks:submit") {
return fault.Forbidden
}
if cached != nil {
// 已完成流程所用队列可以退役;原受理回执仍核对当前读取范围,但不重新申请执行权。
if err := authorizeWorkflowQueues(ctx, tx, t.NamespaceID, p, version.Analysis.Queues); err != nil {
return err
}
if err := json.Unmarshal(cached, &out); err != nil {
return err
}
current, _, err := readWorkflowInstance(ctx, tx, t.NamespaceID, out.Instance.ID, false)
if err != nil {
return err
}
if current.DefinitionVersionID != version.ID || out.Instance.DefinitionVersionID != version.ID || out.Instance.NamespaceID != t.NamespaceID || !current.CreatedAt.Equal(out.Instance.CreatedAt) {
return errors.New("workflow start receipt mismatch")
}
out.Deduplicated = true
return nil
}
if err := lockWorkflowQueues(ctx, tx, t.NamespaceID, p, version.Analysis.Queues); err != nil {
return err
}
if err := definition.ValidateData(version.Specification.InputType, in.Input); err != nil {
return fault.Invalid("workflow input does not match fixed version")
}
var count int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM (SELECT id FROM weave_instances WHERE namespace_id=? AND state IN ('RUNNING','WAITING','CANCELLING','NEEDS_REVIEW') LIMIT ?) active_workflows`, t.NamespaceID, instance.MaxUnfinished).Scan(&count); err != nil {
return err
}
if count >= instance.MaxUnfinished {
return fault.New("WORKFLOW_INSTANCE_LIMIT", "namespace unfinished workflow limit reached")
}
program, err := engine.Compile(version.Specification)
if err != nil {
return err
}
decision, err := program.Plan(engine.Snapshot{WorkflowID: id, BusinessKey: in.BusinessKey, Input: in.Input, Steps: map[string]engine.State{}, AllowNew: true}, 32)
if err != nil {
return fault.Invalid("workflow initial step input is invalid")
}
now, err := dbNow(ctx, tx)
if err != nil {
return err
}
gate, err := core.NewID()
if err != nil {
return err
}
deadline := now.Add(time.Duration(version.Specification.TimeoutMS) * time.Millisecond)
policy, err := jsonwire.Marshal(workflowPolicy{1, version.ID, version.ContentDigest, version.Specification.FailurePolicy, version.Analysis.MaxTaskAttempts})
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `INSERT INTO pulse_execution_gates(namespace_id,id,created_at,updated_at) VALUES (?,?,?,?)`, t.NamespaceID, gate, now, now); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `INSERT INTO weave_instances(namespace_id,id,definition_id,definition_version_id,gate_id,input,policy_snapshot,business_key,deadline_at,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)`, t.NamespaceID, id, in.DefinitionID, version.ID, gate, string(in.Input), string(policy), businessKeyValue(in.BusinessKey), deadline, now, now); err != nil {
return err
}
graph := workflowGraph{gate: gate, version: version, program: program, steps: map[string]*workflowStep{}}
graph.view, _, err = readWorkflowInstance(ctx, tx, t.NamespaceID, id, false)
if err != nil {
return err
}
if err := createWorkflowSteps(ctx, tx, &graph, now); err != nil {
return err
}
if err := applyWorkflowDecision(ctx, tx, &graph, decision, now); err != nil {
return err
}
out.Instance, _, err = readWorkflowInstance(ctx, tx, t.NamespaceID, id, false)
if err != nil {
return err
}
meta := map[string]any{"definition_id": in.DefinitionID, "definition_version_id": version.ID, "state": out.Instance.State, "deadline_at": deadline, "steps": version.Analysis.Steps}
if err := emitEvent(ctx, tx, t.NamespaceID, id, out.Instance.Revision, "workflow", "workflow.started", meta); err != nil {
return err
}
if err := appendAudit(ctx, tx, t, identity, id, "workflow", "workflow.start", "start fixed workflow version", nil, meta); err != nil {
return err
}
return saveIdentity(ctx, tx, t.NamespaceID, identity, out)
})
return out, err
}
func (s *WorkflowInstanceStore) Get(ctx context.Context, t auth.Token, id core.ID) (instance.View, error) {
var out instance.View
err := s.transaction(ctx, t, instance.ReadAction, func(tx *sql.Tx, p auth.Permissions) error {
var err error
out, _, err = readWorkflowInstance(ctx, tx, t.NamespaceID, id, false)
if err != nil {
return err
}
version, err := instanceVersion(ctx, tx, out)
if err != nil {
return err
}
return authorizeWorkflowQueues(ctx, tx, t.NamespaceID, p, version.Analysis.Queues)
})
return out, err
}
// readWorkflowForQuery 固定定义授权全部队列后再锁实例,保持与运行器相同的父子锁顺序。
func readWorkflowForQuery(ctx context.Context, tx *sql.Tx, ns, id core.ID, p auth.Permissions) (instance.View, core.ID, definition.VersionView, error) {
v, gate, err := readWorkflowInstance(ctx, tx, ns, id, false)
var version definition.VersionView
if err != nil {
return v, gate, version, err
}
version, err = instanceVersion(ctx, tx, v)
if err != nil {
return v, gate, version, err
}
if err = authorizeWorkflowQueues(ctx, tx, ns, p, version.Analysis.Queues); err != nil {
return v, gate, version, err
}
v, gate, err = readWorkflowInstance(ctx, tx, ns, id, true)
return v, gate, version, err
}
func (s *WorkflowInstanceStore) Steps(ctx context.Context, t auth.Token, id core.ID) (instance.StepsView, error) {
out := instance.StepsView{InstanceID: id, Steps: []instance.StepView{}}
err := s.transaction(ctx, t, instance.ReadAction, func(tx *sql.Tx, p auth.Permissions) error {
v, gate, version, err := readWorkflowForQuery(ctx, tx, t.NamespaceID, id, p)
if err != nil {
return err
}
out.Revision = v.Revision
out.Steps, err = readWorkflowStepViews(ctx, tx, t.NamespaceID, id, gate, version, "")
return err
})
return out, err
}
func readWorkflowStepViews(ctx context.Context, tx *sql.Tx, ns, id, gate core.ID, version definition.VersionView, key string) ([]instance.StepView, error) {
items := []instance.StepView{}
program, err := engine.Compile(version.Specification)
if err != nil {
return nil, err
}
rows, err := tx.QueryContext(ctx, "SELECT s.id,s.`key`,s.kind,s.state,s.revision,s.current_activation_id,a.task_id,COALESCE(a.sequence,0),a.id,a.state,task.id,task.queue_id FROM weave_steps s LEFT JOIN weave_activations a ON a.namespace_id=s.namespace_id AND a.id=s.current_activation_id AND a.workflow_id=s.workflow_id AND a.step_id=s.id LEFT JOIN pulse_tasks task ON task.namespace_id=s.namespace_id AND task.id=a.task_id AND task.owner_kind='workflow_activation' AND task.owner_id=a.id AND task.gate_id=? WHERE s.namespace_id=? AND s.workflow_id=? AND s.`key`<>? AND (?='' OR s.`key`=?) ORDER BY s.`key` LIMIT 129", gate, ns, id, workflowRootKey, key, key)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var step instance.StepView
var activation *core.ID
var task, queue *core.ID
var state sql.NullString
if err := rows.Scan(&step.ID, &step.Key, &step.Kind, &step.State, &step.Revision, &step.ActivationID, &step.TaskID, &step.ActivationSequence, &activation, &state, &task, &queue); err != nil {
return nil, err
}
node, ok := program.Step(step.Key)
if !ok || node.Kind != string(step.Kind) {
return nil, errors.New("workflow step definition mismatch")
}
inactive := step.State == "BLOCKED" || step.State == "SKIPPED"
if inactive && step.ActivationID != nil || !inactive && (step.ActivationSequence == 0 || step.ActivationID == nil || activation == nil || *activation != *step.ActivationID || state.String != string(step.State)) {
return nil, errors.New("workflow current activation mismatch")
}
if !inactive && (step.Kind == "TASK") != (step.TaskID != nil) {
return nil, errors.New("workflow direct task ownership mismatch")
}
if step.TaskID != nil && (task == nil || queue == nil || *task != *step.TaskID || node.Task == nil || *queue != node.Task.QueueID) {
return nil, errors.New("workflow task association invalid")
}
items = append(items, step)
}
if err := rows.Err(); err != nil {
return nil, err
}
if key == "" && len(items) != int(version.Analysis.Steps) {
return nil, errors.New("workflow step set incomplete")
}
return items, nil
}