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

293 lines
13 KiB
Go
Raw 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和SDK在消费运行器接通后开放。
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 err := lockWorkflowQueues(ctx, tx, t.NamespaceID, p, version.Analysis.Queues); err != nil {
return err
}
if cached != nil {
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 := 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
}
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, _, err := readWorkflowInstance(ctx, tx, t.NamespaceID, id, false)
if err != nil {
return err
}
version, err := instanceVersion(ctx, tx, v)
if err != nil {
return err
}
if err := authorizeWorkflowQueues(ctx, tx, t.NamespaceID, p, version.Analysis.Queues); err != nil {
return err
}
var gate core.ID
v, gate, err = readWorkflowInstance(ctx, tx, t.NamespaceID, id, true)
if err != nil {
return err
}
out.Revision = v.Revision
program, err := engine.Compile(version.Specification)
if err != nil {
return err
}
rows, err := tx.QueryContext(ctx, "SELECT s.id,s.`key`,s.kind,s.state,s.revision,s.current_activation_id,a.task_id,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`<>? ORDER BY s.`key` LIMIT 129", gate, t.NamespaceID, id, workflowRootKey)
if err != nil {
return 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, &activation, &state, &task, &queue); err != nil {
return err
}
node, ok := program.Step(step.Key)
if !ok || node.Kind != string(step.Kind) {
return errors.New("workflow step definition mismatch")
}
inactive := step.State == "BLOCKED" || step.State == "SKIPPED"
if inactive && step.ActivationID != nil || !inactive && (step.ActivationID == nil || activation == nil || *activation != *step.ActivationID || state.String != string(step.State)) {
return errors.New("workflow current activation mismatch")
}
if !inactive && (step.Kind == "TASK") != (step.TaskID != nil) {
return 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 errors.New("workflow task association invalid")
}
out.Steps = append(out.Steps, step)
}
if err := rows.Err(); err != nil {
return err
}
if len(out.Steps) != int(version.Analysis.Steps) {
return errors.New("workflow step set incomplete")
}
return nil
})
return out, err
}