feat: consume workflow results and resume durable advances

This commit is contained in:
2026-09-18 02:21:30 +08:00
parent 553a0bf522
commit f602a5ee05
15 changed files with 1458 additions and 15 deletions

View File

@@ -0,0 +1,274 @@
package mysql
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"strings"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/jsonvalue"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/events"
"git.yuntuo.hk/k2safe/orbit/weave/definition"
"git.yuntuo.hk/k2safe/orbit/weave/engine"
weave "git.yuntuo.hk/k2safe/orbit/weave/model"
)
// workflowSourceError 只用于已核实的来源/契约矛盾;SQL或连接错误不能据此隔离业务结果。
type workflowSourceError string
func (e workflowSourceError) Error() string { return string(e) }
type workflowResult struct {
event, task, activation core.ID
revision uint64
snapshot events.TaskResult
occurred time.Time
step *workflowStep
}
func readWorkflowResult(ctx context.Context, tx *sql.Tx, g *workflowGraph, c workflowCandidate, now time.Time) (*workflowResult, error) {
var state string
var due time.Time
var raw []byte
source := &workflowResult{}
err := tx.QueryRowContext(ctx, `SELECT event_id,source_task_id,source_activation_id,source_revision,snapshot,state,available_at FROM weave_inbox WHERE namespace_id=? AND id=? AND workflow_id=? FOR UPDATE`, c.ns, c.id, c.workflow).Scan(&source.event, &source.task, &source.activation, &source.revision, &raw, &state, &due)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
if state != "PENDING" || due.After(now) {
return nil, nil
}
if _, err := jsonvalue.Canonical(raw); err != nil {
return nil, workflowSourceError("RESULT_SNAPSHOT_INVALID")
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if decoder.Decode(&source.snapshot) != nil || source.snapshot.SchemaVersion != 1 || source.snapshot.TaskID != source.task || source.snapshot.Revision != source.revision || source.revision == 0 || !events.ResultType("task."+strings.ToLower(source.snapshot.State)) {
return nil, workflowSourceError("RESULT_SNAPSHOT_INVALID")
}
var aggregate core.ID
var kind, eventType string
var rev uint64
err = tx.QueryRowContext(ctx, `SELECT aggregate_kind,aggregate_id,aggregate_revision,type,occurred_at FROM orbit_events WHERE namespace_id=? AND id=?`, c.ns, source.event).Scan(&kind, &aggregate, &rev, &eventType, &source.occurred)
if errors.Is(err, sql.ErrNoRows) {
return nil, workflowSourceError("RESULT_EVENT_MISSING")
}
if err != nil {
return nil, err
}
if kind != "task" || aggregate != source.task || rev != source.revision || eventType != "task."+strings.ToLower(source.snapshot.State) || source.occurred.After(now) {
return nil, workflowSourceError("RESULT_EVENT_MISMATCH")
}
var stepID core.ID
var direct *core.ID
err = tx.QueryRowContext(ctx, `SELECT step_id,task_id FROM weave_activations WHERE namespace_id=? AND id=? AND workflow_id=?`, c.ns, source.activation, c.workflow).Scan(&stepID, &direct)
if errors.Is(err, sql.ErrNoRows) {
return nil, workflowSourceError("RESULT_ACTIVATION_MISSING")
}
if err != nil {
return nil, err
}
for _, step := range g.steps {
if step.view.ID == stepID {
source.step = step
break
}
}
if source.step == nil {
return nil, workflowSourceError("RESULT_STEP_MISSING")
}
node, ok := g.program.Step(source.step.view.Key)
if !ok || node.Task == nil && node.Poll == nil {
return nil, workflowSourceError("RESULT_STEP_KIND")
}
taskDefinition := node.Task
if node.Poll != nil {
taskDefinition = &node.Poll.Task
var round uint32
err = tx.QueryRowContext(ctx, `SELECT r.round FROM weave_poll_rounds r JOIN weave_poll_states p ON p.namespace_id=r.namespace_id AND p.workflow_id=r.workflow_id AND p.activation_id=r.activation_id WHERE r.namespace_id=? AND r.workflow_id=? AND r.activation_id=? AND r.task_id=? AND r.round BETWEEN 1 AND p.used_rounds`, c.ns, c.workflow, source.activation, source.task).Scan(&round)
if errors.Is(err, sql.ErrNoRows) || direct != nil {
return nil, workflowSourceError("RESULT_POLL_OWNER")
}
if err != nil {
return nil, err
}
} else if direct == nil || *direct != source.task {
return nil, workflowSourceError("RESULT_TASK_OWNER")
}
var queue core.ID
var owner, gate *core.ID
var ownerKind sql.NullString
var currentRevision uint64
err = tx.QueryRowContext(ctx, `SELECT queue_id,owner_kind,owner_id,gate_id,revision FROM pulse_tasks WHERE namespace_id=? AND id=?`, c.ns, source.task).Scan(&queue, &ownerKind, &owner, &gate, &currentRevision)
if errors.Is(err, sql.ErrNoRows) {
return nil, workflowSourceError("RESULT_TASK_MISSING")
}
if err != nil {
return nil, err
}
if queue != source.snapshot.QueueID || queue != taskDefinition.QueueID || ownerKind.String != engine.TaskOwnerKind || owner == nil || *owner != source.activation || gate == nil || *gate != g.gate || currentRevision < source.revision {
return nil, workflowSourceError("RESULT_TASK_ASSOCIATION")
}
if source.snapshot.CurrentAttemptID != nil {
var attempt core.ID
err = tx.QueryRowContext(ctx, `SELECT id FROM pulse_attempts WHERE namespace_id=? AND id=? AND task_id=?`, c.ns, source.snapshot.CurrentAttemptID, source.task).Scan(&attempt)
if errors.Is(err, sql.ErrNoRows) {
return nil, workflowSourceError("RESULT_ATTEMPT_ASSOCIATION")
}
if err != nil {
return nil, err
}
}
return source, nil
}
func (r *WorkflowRuntime) consume(ctx context.Context, tx *sql.Tx, g *workflowGraph, c workflowCandidate, allow bool, now time.Time) (bool, error) {
source, err := readWorkflowResult(ctx, tx, g, c, now)
var invalid workflowSourceError
if errors.As(err, &invalid) {
if !g.view.State.Terminal() {
if err := holdWorkflow(ctx, tx, g, weave.InstanceNeedsReview, string(invalid), now); err != nil {
return false, err
}
}
return true, r.confirmWorkflowResult(ctx, tx, g, c, "QUARANTINED", string(invalid), now)
}
if err != nil || source == nil {
return false, err
}
row := source.step
if row.view.ActivationID == nil || *row.view.ActivationID != source.activation {
return true, r.confirmWorkflowResult(ctx, tx, g, c, "PROCESSED", "STALE_ACTIVATION", now)
}
var newer int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM weave_inbox WHERE namespace_id=? AND source_task_id=? AND source_revision>? AND state='PROCESSED'`, c.ns, source.task, source.revision).Scan(&newer); err != nil {
return false, err
}
if newer > 0 {
return true, r.confirmWorkflowResult(ctx, tx, g, c, "PROCESSED", "STALE_RESULT", now)
}
if g.view.State.Terminal() || row.view.State != weave.StepActive && row.view.State != weave.StepWaiting {
return true, r.confirmWorkflowResult(ctx, tx, g, c, "PROCESSED", "STEP_ALREADY_SETTLED", now)
}
state, output, reason := weave.StepSucceeded, source.snapshot.Result, "TASK_RESULT"
if source.snapshot.EffectUncertain || source.snapshot.State == "NEEDS_REVIEW" {
state, output, reason = weave.StepNeedsReview, nil, "TASK_RESULT_UNCERTAIN"
} else if source.snapshot.State == "CANCELLED" {
state, output, reason = weave.StepCancelled, nil, "TASK_RESULT_CANCELLED"
} else if source.snapshot.State != "SUCCEEDED" {
state, output, reason = weave.StepFailed, nil, "TASK_RESULT_FAILED"
}
node, _ := g.program.Step(row.view.Key)
if node.Poll != nil {
var used, max uint32
var deadline time.Time
var current *core.ID
var resolved *time.Time
if err := tx.QueryRowContext(ctx, `SELECT used_rounds,max_rounds,deadline_at,current_task_id,resolved_at FROM weave_poll_states WHERE namespace_id=? AND workflow_id=? AND activation_id=? FOR UPDATE`, c.ns, c.workflow, source.activation).Scan(&used, &max, &deadline, &current, &resolved); err != nil {
return false, err
}
if resolved != nil || current == nil || *current != source.task {
return true, r.confirmWorkflowResult(ctx, tx, g, c, "PROCESSED", "STALE_POLL_ROUND", now)
}
// 单轮请求失败不能通过新建业务轮次绕过Pulse预算。
if state != weave.StepSucceeded {
state, reason = weave.StepNeedsReview, "POLL_REQUEST_REVIEW"
} else if !source.occurred.Before(deadline) {
state, output, reason = weave.StepNeedsReview, nil, "POLL_DEADLINE"
} else {
outcome, classifyErr := g.program.ClassifyPoll(row.view.Key, output)
switch {
case classifyErr != nil:
state, output, reason = weave.StepNeedsReview, nil, "POLL_RESULT_INVALID"
case outcome == engine.PollPending:
state, output, reason = weave.StepWaiting, nil, "POLL_PENDING"
if used >= max {
state, reason = weave.StepNeedsReview, "POLL_ROUNDS_EXHAUSTED"
}
case outcome == engine.PollFailed:
state, output, reason = weave.StepFailed, nil, "POLL_BUSINESS_FAILED"
case outcome == engine.PollNeedsReview:
state, output, reason = weave.StepNeedsReview, nil, "POLL_BUSINESS_REVIEW"
}
}
var next, settled *time.Time
if state == weave.StepWaiting {
v := minimumTime(source.occurred.Add(time.Duration(node.Poll.IntervalMS)*time.Millisecond), deadline)
next = &v
} else {
settled = &now
}
if _, err := tx.ExecContext(ctx, `UPDATE weave_poll_states SET current_task_id=NULL,next_poll_at=?,resolved_at=?,revision=revision+1,updated_at=? WHERE namespace_id=? AND workflow_id=? AND activation_id=?`, next, settled, now, c.ns, c.workflow, source.activation); err != nil {
return false, err
}
if settled != nil {
waitState := "CANCELLED"
if state == weave.StepSucceeded {
waitState = "SATISFIED"
}
if _, err := tx.ExecContext(ctx, `UPDATE weave_waits SET state=?,resolved_at=?,revision=revision+1,updated_at=? WHERE namespace_id=? AND workflow_id=? AND activation_id=? AND state='WAITING'`, waitState, now, now, c.ns, c.workflow, source.activation); err != nil {
return false, err
}
}
} else if state == weave.StepSucceeded && (bytes.Equal(bytes.TrimSpace(output), []byte("null")) || definition.ValidateData(node.Task.ResultType, output) != nil) {
state, output, reason = weave.StepNeedsReview, nil, "TASK_RESULT_INVALID"
}
if err := saveWorkflowResultStep(ctx, tx, g, row, state, output, now); err != nil {
return false, err
}
if state == weave.StepNeedsReview || state == weave.StepFailed || state == weave.StepCancelled {
next := weave.InstanceNeedsReview
if (state == weave.StepFailed || state == weave.StepCancelled) && (g.version.Specification.FailurePolicy == "FAIL_AFTER_SETTLED" || g.view.CancelRequestedAt != nil) {
next = weave.InstanceCancelling
}
if err := holdWorkflow(ctx, tx, g, next, reason, now); err != nil {
return false, err
}
} else if err := r.progress(ctx, tx, g, allow, now); err != nil {
return false, err
}
return true, r.confirmWorkflowResult(ctx, tx, g, c, "PROCESSED", reason, now)
}
func saveWorkflowResultStep(ctx context.Context, tx *sql.Tx, g *workflowGraph, row *workflowStep, state weave.StepState, output json.RawMessage, now time.Time) error {
var raw any
if output != nil {
raw = string(output)
}
var completed *time.Time
if state == weave.StepSucceeded || state == weave.StepFailed || state == weave.StepCancelled {
completed = &now
}
r, err := tx.ExecContext(ctx, `UPDATE weave_activations SET state=?,output=?,completed_at=?,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND workflow_id=? AND step_id=? AND state=?`, state, raw, completed, now, g.view.NamespaceID, row.view.ActivationID, g.view.ID, row.view.ID, row.view.State)
if err := exactlyOne(r, err); err != nil {
return err
}
r, err = tx.ExecContext(ctx, `UPDATE weave_steps SET state=?,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=? AND current_activation_id=?`, state, now, g.view.NamespaceID, row.view.ID, row.view.Revision, row.view.ActivationID)
if err := exactlyOne(r, err); err != nil {
return err
}
row.view.State = state
row.view.Revision++
row.output = output
r, err = tx.ExecContext(ctx, `UPDATE weave_instances SET revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=?`, now, g.view.NamespaceID, g.view.ID, g.view.Revision)
if err := exactlyOne(r, err); err != nil {
return err
}
g.view.Revision++
return emitEvent(ctx, tx, g.view.NamespaceID, row.view.ID, row.view.Revision, "workflow_step", "workflow.step.changed", map[string]any{"workflow_id": g.view.ID, "key": row.view.Key, "state": state, "activation_id": row.view.ActivationID})
}
func (r *WorkflowRuntime) confirmWorkflowResult(ctx context.Context, tx *sql.Tx, g *workflowGraph, c workflowCandidate, state, reason string, now time.Time) error {
res, err := tx.ExecContext(ctx, `UPDATE weave_inbox SET state=?,processed_at=?,error_code=?,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND workflow_id=? AND state='PENDING'`, state, now, reason, now, c.ns, c.id, c.workflow)
if err := exactlyOne(res, err); err != nil {
return err
}
return r.audit(ctx, tx, g, c.id, reason, now)
}

View File

@@ -0,0 +1,259 @@
package mysql
import (
"context"
"database/sql"
"encoding/json"
"errors"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/weave/engine"
weave "git.yuntuo.hk/k2safe/orbit/weave/model"
)
// WorkflowRuntime 消费持久化结果与根推进检查点;不持有业务执行租约或调用业务系统。
// 每个候选独立短事务,数据库行锁决定唯一推进权,节点内存不保存恢复游标。
type WorkflowRuntime struct {
db *sql.DB
node core.ID
}
func NewWorkflowRuntime(db *sql.DB, node core.ID) (*WorkflowRuntime, error) {
if db == nil || node.Validate() != nil {
return nil, errors.New("invalid workflow runtime configuration")
}
return &WorkflowRuntime{db: db, node: node}, nil
}
type WorkflowRuntimeStats struct {
Examined int // Examined 本批取出的持久化候选数,包含被其他节点先处理或跳过的记录。
Consumed int // Consumed 已提交确认或隔离的内部结果数,事务回滚不计入。
Advanced int // Advanced 已提交推进的根检查点数,不等于业务任务执行次数。
}
type workflowCandidate struct {
kind string
ns, id, workflow core.ID
revision uint64
}
// Tick 同时读取两种到期索引,各取最多limit项再全局选前limit项;不先锁Inbox再反锁父资源。
func (r *WorkflowRuntime) Tick(ctx context.Context, limit int) (WorkflowRuntimeStats, error) {
var stats WorkflowRuntimeStats
if limit < 1 || limit > 100 {
return stats, fault.Invalid("workflow batch limit must be 1 to 100")
}
rows, err := r.db.QueryContext(ctx, `(SELECT 'INBOX' kind,namespace_id,id,workflow_id,revision,available_at FROM weave_inbox WHERE state='PENDING' AND available_at<=UTC_TIMESTAMP(6) ORDER BY available_at,namespace_id,id LIMIT ?)
UNION ALL (SELECT 'ROOT' kind,namespace_id,id,workflow_id,revision,available_at FROM weave_pending_advances WHERE available_at<=UTC_TIMESTAMP(6) ORDER BY available_at,namespace_id,id LIMIT ?)
ORDER BY available_at,namespace_id,id LIMIT ?`, limit, limit, limit)
if err != nil {
return stats, err
}
var candidates []workflowCandidate
for rows.Next() {
var c workflowCandidate
var due time.Time
if err := rows.Scan(&c.kind, &c.ns, &c.id, &c.workflow, &c.revision, &due); err != nil {
rows.Close()
return stats, err
}
candidates = append(candidates, c)
}
err = rows.Err()
rows.Close()
if err != nil {
return stats, err
}
var failures []error
for _, c := range candidates {
stats.Examined++
done, err := r.process(ctx, c, true)
if err != nil {
// 仅移动同一版本的未消费记录,不能覆盖其他节点刚提交的确认或待续状态。
// 数据库异常不被归类为业务失败;来源保留供下一批恢复。
if ctx.Err() == nil {
err = errors.Join(err, r.deferCandidate(ctx, c))
}
failures = append(failures, err)
if ctx.Err() != nil {
break
}
} else if done {
if c.kind == "INBOX" {
stats.Consumed++
} else {
stats.Advanced++
}
}
}
return stats, errors.Join(failures...)
}
func (r *WorkflowRuntime) deferCandidate(ctx context.Context, c workflowCandidate) error {
q := `UPDATE weave_pending_advances SET available_at=DATE_ADD(UTC_TIMESTAMP(6),INTERVAL 30 SECOND),revision=revision+1,updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=? AND revision=?`
if c.kind == "INBOX" {
q = `UPDATE weave_inbox SET available_at=DATE_ADD(UTC_TIMESTAMP(6),INTERVAL 30 SECOND),error_code='RUNTIME_UNAVAILABLE',revision=revision+1,updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=? AND revision=? AND state='PENDING'`
}
_, err := r.db.ExecContext(ctx, q, c.ns, c.id, c.revision)
return err
}
func (r *WorkflowRuntime) process(ctx context.Context, c workflowCandidate, skip bool) (bool, error) {
done := false
err := WithTx(ctx, r.db, func(tx *sql.Tx) error {
g, allow, err := lockWorkflowGraph(ctx, tx, c.ns, c.workflow, skip)
if err != nil || g == nil {
return err
}
now, err := dbNow(ctx, tx)
if err != nil {
return err
}
if c.kind == "INBOX" {
done, err = r.consume(ctx, tx, g, c, allow, now)
return err
}
var step core.ID
var expected uint64
var raw []byte
var due time.Time
err = tx.QueryRowContext(ctx, `SELECT step_id,expected_step_revision,checkpoint,available_at FROM weave_pending_advances WHERE namespace_id=? AND id=? AND workflow_id=? FOR UPDATE`, c.ns, c.id, c.workflow).Scan(&step, &expected, &raw, &due)
if errors.Is(err, sql.ErrNoRows) {
return nil
}
if err != nil {
return err
}
if due.After(now) {
return nil
}
var checkpoint struct {
SchemaVersion uint32 `json:"schema_version"`
Kind string `json:"kind"`
}
root := g.steps[workflowRootKey]
if json.Unmarshal(raw, &checkpoint) != nil || checkpoint.SchemaVersion != 1 || checkpoint.Kind != "ROOT" || step != root.view.ID || expected > root.view.Revision {
return errors.New("workflow checkpoint invalid")
}
if g.view.State.Terminal() {
_, err = tx.ExecContext(ctx, `DELETE FROM weave_pending_advances WHERE namespace_id=? AND id=?`, c.ns, c.id)
return err
}
if !allow || g.view.State == weave.InstanceNeedsReview || g.view.State == weave.InstanceCancelling {
// 暂停/停用仍按原截止计时;核查和失败结清由控制运行器接续,不清除其状态。
if (g.view.State == weave.InstanceRunning || g.view.State == weave.InstanceWaiting) && !now.Before(g.view.DeadlineAt) {
if err := holdWorkflow(ctx, tx, g, weave.InstanceNeedsReview, "WORKFLOW_DEADLINE", now); err != nil {
return err
}
if err := r.audit(ctx, tx, g, c.id, "WORKFLOW_DEADLINE", now); err != nil {
return err
}
}
return saveWorkflowAdvance(ctx, tx, g, now.Add(time.Second))
}
beforeRevision := g.view.Revision
if err := r.progress(ctx, tx, g, allow, now); err != nil {
return err
}
if g.view.Revision == beforeRevision { // 额度不足只延后检查点,不逐秒制造业务审计。
return nil
}
if err := r.audit(ctx, tx, g, c.id, "ROOT_ADVANCE", now); err != nil {
return err
}
done = true
return nil
})
return done && err == nil, err
}
func cloneWorkflowGraph(g *workflowGraph) *workflowGraph {
out := *g
out.steps = make(map[string]*workflowStep, len(g.steps))
for key, row := range g.steps {
copy := *row
out.steps[key] = &copy
}
return &out
}
// progress 在结果已保存后执行有限决策。额度不足只回滚后继决策,不撤销已确认的结果。
func (r *WorkflowRuntime) progress(ctx context.Context, tx *sql.Tx, g *workflowGraph, allow bool, now time.Time) error {
if g.view.State.Terminal() || g.view.State == weave.InstanceNeedsReview || g.view.State == weave.InstanceCancelling {
return nil
}
allow = allow && now.Before(g.view.DeadlineAt)
d, err := g.program.Plan(workflowSnapshot(g, allow), 32)
if err != nil {
var problem *engine.Problem
if !errors.As(err, &problem) {
return err
}
return holdWorkflow(ctx, tx, g, weave.InstanceNeedsReview, problem.Code, now)
}
if !d.Complete && !now.Before(g.view.DeadlineAt) {
return holdWorkflow(ctx, tx, g, weave.InstanceNeedsReview, "WORKFLOW_DEADLINE", now)
}
if _, err := tx.ExecContext(ctx, `SAVEPOINT workflow_successors`); err != nil {
return err
}
before := cloneWorkflowGraph(g)
err = applyWorkflowDecision(ctx, tx, g, d, now)
if errors.Is(err, fault.QueueFull) || errors.Is(err, fault.ApplicationFull) {
if _, rollback := tx.ExecContext(ctx, `ROLLBACK TO SAVEPOINT workflow_successors`); rollback != nil {
return rollback
}
*g = *before
return saveWorkflowAdvance(ctx, tx, g, now.Add(time.Second))
}
if err != nil {
return err
}
if !allow && !d.Complete {
return saveWorkflowAdvance(ctx, tx, g, now.Add(time.Second))
}
return nil
}
func holdWorkflow(ctx context.Context, tx *sql.Tx, g *workflowGraph, state weave.InstanceState, reason string, now time.Time) error {
if g.view.State.Terminal() {
return errors.New("cannot hold completed workflow")
}
if g.view.State == weave.InstanceNeedsReview {
state = weave.InstanceNeedsReview
}
if _, err := tx.ExecContext(ctx, `UPDATE pulse_execution_gates SET paused=TRUE,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=?`, now, g.view.NamespaceID, g.gate); err != nil {
return err
}
r, err := tx.ExecContext(ctx, `UPDATE weave_instances SET state=?,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=?`, state, now, g.view.NamespaceID, g.view.ID, g.view.Revision)
if err := exactlyOne(r, err); err != nil {
return err
}
g.view.State = state
g.view.Revision++
return emitEvent(ctx, tx, g.view.NamespaceID, g.view.ID, g.view.Revision, "workflow", "workflow.blocked", map[string]any{"state": state, "reason": reason})
}
func (r *WorkflowRuntime) audit(ctx context.Context, tx *sql.Tx, g *workflowGraph, operation core.ID, reason string, now time.Time) error {
id, err := core.NewID()
if err != nil {
return err
}
// 迟到证据也占用新的实例历史版本,避免与同一实例版本先前的推进事件冲突。
res, err := tx.ExecContext(ctx, `UPDATE weave_instances SET revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=?`, now, g.view.NamespaceID, g.view.ID, g.view.Revision)
if err := exactlyOne(res, err); err != nil {
return err
}
g.view.Revision++
meta := map[string]any{"state": g.view.State, "revision": g.view.Revision, "node_id": r.node, "operation_id": operation, "reason": reason}
raw, err := json.Marshal(meta)
if err != nil {
return err
}
if _, err = tx.ExecContext(ctx, "INSERT INTO orbit_audit_events(namespace_id,id,operation_id,actor_id,action,resource_kind,resource_id,reason,`before`,`after`) VALUES (?,?,?,?,'workflow.advance','workflow',?,?,'null',?)", g.view.NamespaceID, id, id, "system:weave:"+string(r.node), g.view.ID, reason, string(raw)); err != nil {
return err
}
return emitEvent(ctx, tx, g.view.NamespaceID, g.view.ID, g.view.Revision, "workflow", "workflow.advanced", meta)
}

View File

@@ -0,0 +1,122 @@
package mysql
import (
"context"
"database/sql"
"errors"
"time"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/weave/engine"
weave "git.yuntuo.hk/k2safe/orbit/weave/model"
)
// lockWorkflowGraph 只由内部运行器调用。身份→应用额度→排序队列→实例→门控,
// 不用启动凭据继续授权;停用身份仍收集在途结果,但禁止创建新工作。
func lockWorkflowGraph(ctx context.Context, tx *sql.Tx, ns, id core.ID, skip bool) (*workflowGraph, bool, error) {
var enabled bool
if err := tx.QueryRowContext(ctx, `SELECT n.enabled AND a.enabled FROM orbit_namespaces n JOIN orbit_applications a ON a.id=n.application_id WHERE n.id=? FOR SHARE`, ns).Scan(&enabled); err != nil {
return nil, false, err
}
if ok, err := lockApplicationResources(ctx, tx, ns, skip); err != nil || !ok {
return nil, false, err
}
v, gate, err := readWorkflowInstance(ctx, tx, ns, id, false)
if err != nil {
return nil, false, err
}
version, err := instanceVersion(ctx, tx, v)
if err != nil {
return nil, false, err
}
for _, queue := range version.Analysis.Queues {
if _, _, err := lockQueueAfterApplication(ctx, tx, ns, queue); err != nil {
return nil, false, err
}
}
var locked core.ID
if err := tx.QueryRowContext(ctx, `SELECT id FROM weave_instances WHERE namespace_id=? AND id=? FOR UPDATE`, ns, id).Scan(&locked); err != nil {
return nil, false, err
}
v, currentGate, err := readWorkflowInstance(ctx, tx, ns, id, false)
if err != nil {
return nil, false, err
}
if currentGate != gate || v.DefinitionVersionID != version.ID {
return nil, false, errors.New("workflow identity changed")
}
var gatePaused bool
if err := tx.QueryRowContext(ctx, `SELECT paused FROM pulse_execution_gates WHERE namespace_id=? AND id=? FOR UPDATE`, ns, gate).Scan(&gatePaused); err != nil {
return nil, false, err
}
program, err := engine.Compile(version.Specification)
if err != nil {
return nil, false, err
}
g := &workflowGraph{view: v, gate: gate, version: version, program: program, steps: map[string]*workflowStep{}}
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,a.input,a.output,t.id,t.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 t ON t.namespace_id=s.namespace_id AND t.id=a.task_id AND t.owner_kind='workflow_activation' AND t.owner_id=a.id AND t.gate_id=? WHERE s.namespace_id=? AND s.workflow_id=? ORDER BY s.`key` LIMIT 130", gate, ns, id)
if err != nil {
return nil, false, err
}
defer rows.Close()
for rows.Next() {
row := &workflowStep{}
var activation, task, queue *core.ID
var state sql.NullString
var input, output []byte
if err := rows.Scan(&row.view.ID, &row.view.Key, &row.view.Kind, &row.view.State, &row.view.Revision, &row.view.ActivationID, &row.view.TaskID, &activation, &state, &input, &output, &task, &queue); err != nil {
return nil, false, err
}
row.input, row.output = input, output
key := row.view.Key
node, ok := program.Step(key)
if key == workflowRootKey {
if row.view.Kind != weave.NodeJoin {
return nil, false, errors.New("workflow root kind invalid")
}
} else if !ok || node.Kind != string(row.view.Kind) {
return nil, false, errors.New("workflow step definition mismatch")
}
inactive := row.view.State == weave.StepBlocked || row.view.State == weave.StepSkipped
if inactive && row.view.ActivationID != nil || !inactive && (row.view.ActivationID == nil || activation == nil || *activation != *row.view.ActivationID || state.String != string(row.view.State)) {
return nil, false, errors.New("workflow current activation mismatch")
}
if !inactive && (row.view.Kind == weave.NodeTask) != (row.view.TaskID != nil) {
return nil, false, errors.New("workflow task ownership mismatch")
}
if row.view.TaskID != nil && (task == nil || queue == nil || *task != *row.view.TaskID || node.Task == nil || *queue != node.Task.QueueID) {
return nil, false, errors.New("workflow task association invalid")
}
g.steps[key] = row
}
if err := rows.Err(); err != nil {
return nil, false, err
}
root := g.steps[workflowRootKey]
if len(g.steps) != int(version.Analysis.Steps)+1 || root == nil || root.view.ActivationID == nil {
return nil, false, errors.New("workflow step set incomplete")
}
allow := enabled && !gatePaused && !v.Paused && v.CancelRequestedAt == nil && (v.State == weave.InstanceRunning || v.State == weave.InstanceWaiting)
return g, allow, nil
}
func workflowSnapshot(g *workflowGraph, allow bool) engine.Snapshot {
s := engine.Snapshot{WorkflowID: g.view.ID, BusinessKey: g.view.BusinessKey, Input: g.view.Input, Steps: map[string]engine.State{}, AllowNew: allow}
for key, row := range g.steps {
if key != workflowRootKey {
s.Steps[key] = engine.State{Status: row.view.State, Input: row.input, Output: row.output}
}
}
return s
}
// saveWorkflowAdvance 保留根步骤的最新版本;重新调度不重置业务截止或任务预算。
func saveWorkflowAdvance(ctx context.Context, tx *sql.Tx, g *workflowGraph, due time.Time) error {
id, err := core.NewID()
if err != nil {
return err
}
root := g.steps[workflowRootKey]
_, err = tx.ExecContext(ctx, `INSERT INTO weave_pending_advances(namespace_id,id,workflow_id,step_id,expected_step_revision,checkpoint,available_at) VALUES (?,?,?,?,?,'{"schema_version":1,"kind":"ROOT"}',?) ON DUPLICATE KEY UPDATE expected_step_revision=VALUES(expected_step_revision),checkpoint=VALUES(checkpoint),available_at=VALUES(available_at),revision=revision+1,updated_at=UTC_TIMESTAMP(6)`, g.view.NamespaceID, id, g.view.ID, root.view.ID, root.view.Revision, due)
return err
}

View File

@@ -0,0 +1,489 @@
package mysql
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"os"
"strings"
"sync"
"testing"
"time"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/pulse/worker"
"git.yuntuo.hk/k2safe/orbit/weave/definition"
"git.yuntuo.hk/k2safe/orbit/weave/engine"
"git.yuntuo.hk/k2safe/orbit/weave/instance"
)
func runtimeSpec(queue core.ID, count int) definition.Specification {
spec := workflowTestSpec(queue)
spec.TimeoutMS = 120000
base := *spec.Body.Steps[0].Task
base.TimeoutMS = 30000
base.Retry.AttemptTimeoutMS = 20000
spec.Body.Steps = nil
for i := 0; i < count; i++ {
task := base
if i > 0 {
task.Input = definition.Value{Kind: "REFERENCE", Reference: &definition.Reference{Source: fmt.Sprintf("task%d", i-1)}}
}
spec.Body.Steps = append(spec.Body.Steps, definition.Step{Key: fmt.Sprintf("task%d", i), Kind: "TASK", Task: &task})
}
spec.Body.Output = definition.Value{Kind: "REFERENCE", Reference: &definition.Reference{Source: fmt.Sprintf("task%d", count-1)}}
return spec
}
func runtimeParallel(spec definition.Specification) definition.Step {
parallel := definition.Parallel{}
for _, key := range []string{"left", "right"} {
task := *spec.Body.Steps[0].Task
parallel.Branches = append(parallel.Branches, definition.ParallelBranch{Name: key, Body: definition.Block{Steps: []definition.Step{{Key: key, Kind: "TASK", Task: &task}}, OutputType: spec.InputType, Output: definition.Value{Kind: "REFERENCE", Reference: &definition.Reference{Source: key}}}})
}
return definition.Step{Key: "join", Kind: "PARALLEL", Parallel: &parallel}
}
func newRuntime(t *testing.T, db *sql.DB) *WorkflowRuntime {
t.Helper()
r, err := NewWorkflowRuntime(db, executionID(t))
if err != nil {
t.Fatal(err)
}
return r
}
func runtimeStart(t *testing.T, f *executionFixture, spec definition.Specification) instance.View {
t.Helper()
grantWorkflowInstance(t, f)
in := publishInstanceSpec(t, f, spec)
out, err := instance.New(NewWorkflowInstanceStore(f.db)).Start(f.ctx, f.token, string(executionID(t)), in)
if err != nil {
t.Fatal(err)
}
return out.Instance
}
func runtimeInbox(t *testing.T, f *executionFixture, task core.ID) workflowCandidate {
t.Helper()
c := workflowCandidate{kind: "INBOX", ns: f.resources.NamespaceID}
if err := f.db.QueryRowContext(f.ctx, `SELECT id,workflow_id,revision FROM weave_inbox WHERE namespace_id=? AND source_task_id=? ORDER BY source_revision DESC LIMIT 1`, c.ns, task).Scan(&c.id, &c.workflow, &c.revision); err != nil {
t.Fatal(err)
}
return c
}
func runtimeConsume(t *testing.T, f *executionFixture, r *WorkflowRuntime, task core.ID) {
t.Helper()
if done, err := r.process(f.ctx, runtimeInbox(t, f, task), false); err != nil || !done {
t.Fatalf("consume done=%v err=%v", done, err)
}
}
func runtimeView(t *testing.T, f *executionFixture, id core.ID, state string) instance.View {
t.Helper()
v, err := instance.New(NewWorkflowInstanceStore(f.db)).Get(f.ctx, f.token, id)
if err != nil || string(v.State) != state {
t.Fatalf("state=%s want=%s err=%v", v.State, state, err)
}
return v
}
func runtimeAdvance(t *testing.T, f *executionFixture, r *WorkflowRuntime, id core.ID) {
t.Helper()
f.exec(`UPDATE weave_pending_advances SET available_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND workflow_id=?`, f.resources.NamespaceID, id)
c := workflowCandidate{kind: "ROOT", ns: f.resources.NamespaceID, workflow: id}
if err := f.db.QueryRowContext(f.ctx, `SELECT id,revision FROM weave_pending_advances WHERE namespace_id=? AND workflow_id=?`, c.ns, id).Scan(&c.id, &c.revision); err != nil {
t.Fatal(err)
}
if done, err := r.process(f.ctx, c, false); err != nil || !done {
t.Fatalf("advance done=%v err=%v", done, err)
}
}
func TestIntegrationWorkflowRuntime(t *testing.T) {
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
if dsn == "" {
t.Skip("requires disposable MySQL")
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
db, err := Open(ctx, dsn, 32, 8)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := Migrate(ctx, db); err != nil {
t.Fatal(err)
}
t.Run("sequence_duplicate_consumers_and_frozen_result", func(t *testing.T) {
f := newExecutionFixture(t, ctx, db)
v := runtimeStart(t, f, runtimeSpec(f.resources.QueueID, 2))
ref := retryStart(t, f)
retryFinish(t, f, ref, "SUCCEEDED", false)
// 当前任务不是事件快照;后续读取不得把改过的当前值误用为先前结果。
f.exec(`UPDATE pulse_tasks SET result='{"n":1}' WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, ref.TaskID)
c := runtimeInbox(t, f, ref.TaskID)
var wg sync.WaitGroup
failures := make(chan error, 12)
committed := make(chan bool, 12)
for i := 0; i < 12; i++ {
r := newRuntime(t, db)
wg.Add(1)
go func() { defer wg.Done(); done, e := r.process(ctx, c, false); failures <- e; committed <- done }()
}
wg.Wait()
close(failures)
close(committed)
for err := range failures {
if err != nil {
t.Fatal(err)
}
}
n := 0
for done := range committed {
if done {
n++
}
}
if n != 1 || f.count("pulse_tasks", "1=1") != 2 || f.count("orbit_audit_events", "action='workflow.advance'") != 1 {
t.Fatal("duplicate successor/audit", n)
}
second := retryStart(t, f)
if !strings.Contains(string(f.get(second.TaskID).Payload), "9007199254740993") {
t.Fatal("mutable task value replaced frozen result")
}
retryFinish(t, f, second, "SUCCEEDED", false)
runtimeConsume(t, f, newRuntime(t, db), second.TaskID)
after := runtimeView(t, f, v.ID, "SUCCEEDED")
if !strings.Contains(string(after.Output), "9007199254740993") || f.count("pulse_execution_gates", "paused=TRUE") != 1 || f.count("weave_pending_advances", "1=1") != 0 {
t.Fatal("final output/gate/checkpoint")
}
})
t.Run("parallel_results_join_once", func(t *testing.T) {
f := newExecutionFixture(t, ctx, db)
spec := runtimeSpec(f.resources.QueueID, 1)
tail := spec.Body.Steps[0]
tail.Key = "tail"
spec.Body.Steps = []definition.Step{runtimeParallel(spec), tail}
spec.Body.Output = definition.Value{Kind: "REFERENCE", Reference: &definition.Reference{Source: "tail"}}
v := runtimeStart(t, f, spec)
refs := []worker.AttemptRef{retryStart(t, f), retryStart(t, f)}
for _, ref := range refs {
retryFinish(t, f, ref, "SUCCEEDED", false)
}
cs := []workflowCandidate{runtimeInbox(t, f, refs[0].TaskID), runtimeInbox(t, f, refs[1].TaskID)}
var wg sync.WaitGroup
errs := make(chan error, 2)
for _, c := range cs {
r := newRuntime(t, db)
wg.Add(1)
go func() { defer wg.Done(); _, err := r.process(ctx, c, false); errs <- err }()
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatal(err)
}
}
if f.count("pulse_tasks", "1=1") != 3 || f.count("weave_steps", "`key`='join' AND state='SUCCEEDED'") != 1 {
t.Fatal("parallel duplicate or incomplete join")
}
ref := retryStart(t, f)
retryFinish(t, f, ref, "SUCCEEDED", false)
runtimeConsume(t, f, newRuntime(t, db), ref.TaskID)
runtimeView(t, f, v.ID, "SUCCEEDED")
})
t.Run("backpressure_preserves_result_and_rolls_back_partial_successors", func(t *testing.T) {
f := newExecutionFixture(t, ctx, db)
spec := runtimeSpec(f.resources.QueueID, 1)
spec.Body.Steps = append(spec.Body.Steps, runtimeParallel(spec))
v := runtimeStart(t, f, spec)
f.exec(`UPDATE pulse_queues SET max_unfinished_tasks=1 WHERE namespace_id=?`, f.resources.NamespaceID)
ref := retryStart(t, f)
retryFinish(t, f, ref, "SUCCEEDED", false)
runtimeConsume(t, f, newRuntime(t, db), ref.TaskID)
if f.count("pulse_tasks", "1=1") != 1 || f.count("weave_steps", "`key`='task0' AND state='SUCCEEDED'") != 1 || f.count("weave_inbox", "state='PROCESSED'") != 1 || f.count("weave_pending_advances", "1=1") != 1 {
t.Fatal("backpressure lost result or partial tasks leaked")
}
if instanceCount(f, `SELECT unfinished_tasks FROM pulse_queues WHERE namespace_id=?`, f.resources.NamespaceID) != 0 {
t.Fatal("savepoint leaked queue quota")
}
f.exec(`UPDATE pulse_queues SET max_unfinished_tasks=2 WHERE namespace_id=?`, f.resources.NamespaceID)
runtimeAdvance(t, f, newRuntime(t, db), v.ID)
if f.count("pulse_tasks", "1=1") != 3 || f.count("weave_pending_advances", "1=1") != 0 {
t.Fatal("durable continuation did not resume")
}
})
t.Run("audit_failure_rolls_back_consume_and_successor", func(t *testing.T) {
f := newExecutionFixture(t, ctx, db)
runtimeStart(t, f, runtimeSpec(f.resources.QueueID, 2))
ref := retryStart(t, f)
retryFinish(t, f, ref, "SUCCEEDED", false)
f.exec(`CREATE TRIGGER test_reject_workflow_advance BEFORE INSERT ON orbit_audit_events FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='injected workflow advance failure'`)
_, err := newRuntime(t, db).process(ctx, runtimeInbox(t, f, ref.TaskID), false)
f.exec(`DROP TRIGGER test_reject_workflow_advance`)
if err == nil || f.count("pulse_tasks", "1=1") != 1 || f.count("weave_inbox", "state='PENDING'") != 1 || f.count("weave_steps", "`key`='task0' AND state='ACTIVE'") != 1 {
t.Fatal("atomic result/advance rollback failed", err)
}
runtimeConsume(t, f, newRuntime(t, db), ref.TaskID)
})
t.Run("paused_collects_then_resumes_from_checkpoint", func(t *testing.T) {
f := newExecutionFixture(t, ctx, db)
v := runtimeStart(t, f, runtimeSpec(f.resources.QueueID, 2))
ref := retryStart(t, f)
// 控制API尚未实现;此处直接建立其需要维护的持久状态,验证运行器不会越过门控。
f.exec(`UPDATE weave_instances SET paused=TRUE WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, v.ID)
f.exec(`UPDATE pulse_execution_gates SET paused=TRUE WHERE namespace_id=?`, f.resources.NamespaceID)
retryFinish(t, f, ref, "SUCCEEDED", false)
runtimeConsume(t, f, newRuntime(t, db), ref.TaskID)
if f.count("pulse_tasks", "1=1") != 1 || f.count("weave_inbox", "state='PROCESSED'") != 1 || f.count("weave_pending_advances", "1=1") != 1 {
t.Fatal("paused continuation lost")
}
f.exec(`UPDATE weave_instances SET paused=FALSE WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, v.ID)
f.exec(`UPDATE pulse_execution_gates SET paused=FALSE WHERE namespace_id=?`, f.resources.NamespaceID)
runtimeAdvance(t, f, newRuntime(t, db), v.ID)
if f.count("pulse_tasks", "1=1") != 2 {
t.Fatal("resume failed")
}
})
t.Run("root_budget_restarts_without_memory", func(t *testing.T) {
f := newExecutionFixture(t, ctx, db)
spec := runtimeSpec(f.resources.QueueID, 1)
body := spec.Body
unselected := body
unselected.Steps = nil
for i := 0; i < 75; i++ {
task := *body.Steps[0].Task
unselected.Steps = append(unselected.Steps, definition.Step{Key: fmt.Sprintf("skip%d", i), Kind: "TASK", Task: &task})
}
unselected.Output = definition.Value{Kind: "REFERENCE", Reference: &definition.Reference{Source: "skip74"}}
zero, one := definition.Value{Kind: "LITERAL", Literal: json.RawMessage(`0`)}, definition.Value{Kind: "LITERAL", Literal: json.RawMessage(`1`)}
spec.Body.Steps = []definition.Step{{Key: "choice", Kind: "BRANCH", Branch: &definition.Branch{Cases: []definition.BranchCase{{When: definition.Predicate{Op: "EQ", Left: &zero, Right: &one}, Body: unselected}}, Default: &body}}}
spec.Body.Output = definition.Value{Kind: "REFERENCE", Reference: &definition.Reference{Source: "choice"}}
v := runtimeStart(t, f, spec)
if f.count("weave_steps", "state='SKIPPED'") != 31 || f.count("pulse_tasks", "1=1") != 0 {
t.Fatal("initial budget")
}
runtimeAdvance(t, f, newRuntime(t, db), v.ID)
runtimeAdvance(t, f, newRuntime(t, db), v.ID)
if f.count("weave_steps", "state='SKIPPED'") != 75 || f.count("pulse_tasks", "1=1") != 1 || f.count("weave_pending_advances", "1=1") != 0 {
t.Fatal("bounded restart failed")
}
ref := retryStart(t, f)
retryFinish(t, f, ref, "SUCCEEDED", false)
runtimeConsume(t, f, newRuntime(t, db), ref.TaskID)
runtimeView(t, f, v.ID, "SUCCEEDED")
})
t.Run("invalid_source_quarantined_without_advancing", func(t *testing.T) {
f := newExecutionFixture(t, ctx, db)
v := runtimeStart(t, f, runtimeSpec(f.resources.QueueID, 2))
ref := retryStart(t, f)
retryFinish(t, f, ref, "SUCCEEDED", false)
f.exec(`UPDATE weave_inbox SET snapshot=JSON_SET(snapshot,'$.revision','9999') WHERE namespace_id=?`, f.resources.NamespaceID)
runtimeConsume(t, f, newRuntime(t, db), ref.TaskID)
runtimeView(t, f, v.ID, "NEEDS_REVIEW")
if f.count("weave_inbox", "state='QUARANTINED' AND error_code='RESULT_SNAPSHOT_INVALID'") != 1 || f.count("pulse_tasks", "1=1") != 1 || f.count("pulse_execution_gates", "paused=TRUE") != 1 {
t.Fatal("corrupt source escaped quarantine")
}
})
t.Run("failed_and_invalid_results_close_gate", func(t *testing.T) {
for _, outcome := range []string{"FAILED", "UNKNOWN", "CANCELLED", "INVALID"} {
t.Run(outcome, func(t *testing.T) {
f := newExecutionFixture(t, ctx, db)
v := runtimeStart(t, f, runtimeSpec(f.resources.QueueID, 2))
ref := retryStart(t, f)
if outcome == "CANCELLED" {
// 流程控制API尚未实现;建立其应保存的取消意图,再由真实Worker确认。
f.exec(`UPDATE weave_instances SET state='CANCELLING',cancel_requested_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, v.ID)
f.exec(`UPDATE pulse_execution_gates SET paused=TRUE WHERE namespace_id=?`, f.resources.NamespaceID)
f.exec(`UPDATE pulse_tasks SET cancel_requested_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, ref.TaskID)
}
if outcome == "INVALID" {
if _, err := f.a.Finish(ctx, f.token, f.binding, executionID(t), worker.FinishInput{Ref: ref, Outcome: "SUCCEEDED", Result: json.RawMessage(`{"wrong":true}`)}); err != nil {
t.Fatal(err)
}
} else {
retryFinish(t, f, ref, outcome, false)
}
runtimeConsume(t, f, newRuntime(t, db), ref.TaskID)
expected := "NEEDS_REVIEW"
if outcome == "CANCELLED" {
expected = "CANCELLING"
}
runtimeView(t, f, v.ID, expected)
if f.count("pulse_tasks", "1=1") != 1 || f.count("pulse_execution_gates", "paused=TRUE") != 1 {
t.Fatal("failure did not gate successors")
}
if outcome == "CANCELLED" && f.count("weave_steps", "`key`='task0' AND state='CANCELLED'") != 1 {
t.Fatal("confirmed cancellation was rewritten as failure")
}
})
}
})
t.Run("deadline_does_not_create_successor", func(t *testing.T) {
f := newExecutionFixture(t, ctx, db)
v := runtimeStart(t, f, runtimeSpec(f.resources.QueueID, 2))
ref := retryStart(t, f)
retryFinish(t, f, ref, "SUCCEEDED", false)
f.exec(`UPDATE weave_instances SET deadline_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, v.ID)
runtimeConsume(t, f, newRuntime(t, db), ref.TaskID)
runtimeView(t, f, v.ID, "NEEDS_REVIEW")
if f.count("pulse_tasks", "1=1") != 1 || f.count("weave_inbox", "state='PROCESSED'") != 1 {
t.Fatal("deadline lost accepted result or created new work")
}
})
t.Run("old_activation_and_cross_namespace_cannot_advance", func(t *testing.T) {
f := newExecutionFixture(t, ctx, db)
v := runtimeStart(t, f, runtimeSpec(f.resources.QueueID, 1))
ref := retryStart(t, f)
retryFinish(t, f, ref, "SUCCEEDED", false)
c := runtimeInbox(t, f, ref.TaskID)
other := newExecutionFixture(t, ctx, db)
foreign := c
foreign.ns = other.resources.NamespaceID
if done, err := newRuntime(t, db).process(ctx, foreign, false); err == nil || done {
t.Fatal("cross namespace consumer accepted")
}
// 用独立受理任务构造下一激活;旧激活和旧结果完整保留。
next := f.submit(1)
activation := executionID(t)
var step, gate core.ID
if err := db.QueryRowContext(ctx, "SELECT s.id,i.gate_id FROM weave_steps s JOIN weave_instances i ON i.namespace_id=s.namespace_id AND i.id=s.workflow_id WHERE s.namespace_id=? AND s.workflow_id=? AND s.`key`='task0'", c.ns, v.ID).Scan(&step, &gate); err != nil {
t.Fatal(err)
}
f.exec(`INSERT INTO weave_activations(namespace_id,id,workflow_id,step_id,sequence,state,task_id,input) VALUES (?,?,?,?,2,'ACTIVE',?,'{"n":9007199254740993}')`, c.ns, activation, v.ID, step, next.ID)
f.exec(`UPDATE pulse_tasks SET owner_kind=?,owner_id=?,gate_id=? WHERE namespace_id=? AND id=?`, engine.TaskOwnerKind, activation, gate, c.ns, next.ID)
f.exec(`UPDATE weave_steps SET current_activation_id=? WHERE namespace_id=? AND id=?`, activation, c.ns, step)
runtimeConsume(t, f, newRuntime(t, db), ref.TaskID)
if f.count("weave_inbox", "state='PROCESSED' AND error_code='STALE_ACTIVATION'") != 1 || f.count("weave_steps", "`key`='task0' AND state='ACTIVE'") != 1 {
t.Fatal("old activation advanced current work")
}
})
t.Run("newer_review_result_then_older_success_does_not_clear_review", func(t *testing.T) {
f := newExecutionFixture(t, ctx, db)
v := runtimeStart(t, f, runtimeSpec(f.resources.QueueID, 2))
ref := retryStart(t, f)
retryFinish(t, f, ref, "SUCCEEDED", false)
older := runtimeInbox(t, f, ref.TaskID)
f.exec(`UPDATE pulse_tasks SET state='NEEDS_REVIEW',effect_uncertain=TRUE,completed_at=NULL,revision=revision+1 WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, ref.TaskID)
task := f.get(ref.TaskID)
if err := WithTx(ctx, db, func(tx *sql.Tx) error {
return emitEvent(ctx, tx, f.resources.NamespaceID, ref.TaskID, task.Revision, "task", "task.needs_review", map[string]any{"reason": "test later uncertainty"})
}); err != nil {
t.Fatal(err)
}
r := newRuntime(t, db)
runtimeConsume(t, f, r, ref.TaskID)
if done, err := r.process(ctx, older, false); err != nil || !done {
t.Fatal(done, err)
}
runtimeView(t, f, v.ID, "NEEDS_REVIEW")
if f.count("pulse_tasks", "1=1") != 1 || f.count("weave_inbox", "error_code='STALE_RESULT'") != 1 || f.count("orbit_audit_events", "action='workflow.advance'") != 2 {
t.Fatal("late result regressed workflow or lost audit")
}
})
t.Run("disabled_application_collects_without_new_work", func(t *testing.T) {
f := newExecutionFixture(t, ctx, db)
v := runtimeStart(t, f, runtimeSpec(f.resources.QueueID, 2))
ref := retryStart(t, f)
retryFinish(t, f, ref, "SUCCEEDED", false)
f.exec(`UPDATE orbit_applications SET enabled=FALSE WHERE id=?`, f.resources.ApplicationID)
runtimeConsume(t, f, newRuntime(t, db), ref.TaskID)
if f.count("pulse_tasks", "1=1") != 1 || f.count("weave_inbox", "state='PROCESSED'") != 1 || f.count("weave_pending_advances", "1=1") != 1 {
t.Fatal("disabled app dropped result or admitted successor")
}
f.exec(`UPDATE orbit_applications SET enabled=TRUE WHERE id=?`, f.resources.ApplicationID)
runtimeAdvance(t, f, newRuntime(t, db), v.ID)
if f.count("pulse_tasks", "1=1") != 2 {
t.Fatal("reenabled application did not resume")
}
})
t.Run("poll_pending_success_exhaustion_and_request_failure", func(t *testing.T) {
for _, mode := range []string{"PENDING", "DONE", "EXHAUSTED", "INVALID", "REQUEST_FAILED"} {
t.Run(mode, func(t *testing.T) {
f := newExecutionFixture(t, ctx, db)
spec := runtimeSpec(f.resources.QueueID, 1)
task := *spec.Body.Steps[0].Task
task.TimeoutMS = 10000
task.Retry.AttemptTimeoutMS = 5000
task.ResultType = definition.Type{Kind: "STRING", Enum: []string{"PENDING", "DONE"}}
poll := definition.Poll{Task: task, MaxRounds: 3, TimeoutMS: 30000, IntervalMS: 1000, Pending: []string{"PENDING"}, Succeeded: []string{"DONE"}}
if mode == "EXHAUSTED" {
poll.MaxRounds = 1
}
spec.Body = definition.Block{Steps: []definition.Step{{Key: "query", Kind: "POLL", Poll: &poll}}, OutputType: task.ResultType, Output: definition.Value{Kind: "REFERENCE", Reference: &definition.Reference{Source: "query"}}}
v := runtimeStart(t, f, spec)
ref := retryStart(t, f)
result, outcome, errorCode := mode, "SUCCEEDED", ""
if mode == "EXHAUSTED" {
result = "PENDING"
}
if mode == "REQUEST_FAILED" {
outcome, errorCode = "FAILED", "REMOTE_FAILURE"
}
raw, _ := json.Marshal(result)
if _, err := f.a.Finish(ctx, f.token, f.binding, executionID(t), worker.FinishInput{Ref: ref, Outcome: outcome, Result: raw, ErrorCode: errorCode}); err != nil {
t.Fatal(err)
}
runtimeConsume(t, f, newRuntime(t, db), ref.TaskID)
expected := "NEEDS_REVIEW"
if mode == "DONE" {
expected = "SUCCEEDED"
}
if mode == "PENDING" {
expected = "WAITING"
}
runtimeView(t, f, v.ID, expected)
if f.count("pulse_tasks", "1=1") != 1 || f.count("weave_poll_states", "used_rounds=1 AND current_task_id IS NULL") != 1 {
t.Fatal("poll reset budget or started overlapping work")
}
if mode == "PENDING" {
if f.count("weave_poll_states", "next_poll_at IS NOT NULL AND resolved_at IS NULL") != 1 || f.count("weave_steps", "`key`='query' AND state='WAITING'") != 1 {
t.Fatal("pending poll mistaken for completion")
}
}
})
}
})
t.Run("scanner_backoff_and_busy_application", func(t *testing.T) {
f := newExecutionFixture(t, ctx, db)
runtimeStart(t, f, runtimeSpec(f.resources.QueueID, 2))
ref := retryStart(t, f)
retryFinish(t, f, ref, "SUCCEEDED", false)
c := runtimeInbox(t, f, ref.TaskID)
r := newRuntime(t, db)
lock, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
t.Fatal(err)
}
if _, err := lockApplicationResources(ctx, lock, c.ns, false); err != nil {
lock.Rollback()
t.Fatal(err)
}
call, cancel := context.WithTimeout(ctx, time.Second)
done, err := r.process(call, c, true)
cancel()
lock.Rollback()
if err != nil || done {
t.Fatal("busy application not skipped", done, err)
}
f.exec(`UPDATE weave_inbox SET available_at='2000-01-01' WHERE namespace_id=?`, c.ns)
f.exec(`CREATE TRIGGER test_reject_runtime_tick BEFORE INSERT ON orbit_audit_events FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='injected scanner transaction failure'`)
stats, err := r.Tick(ctx, 1)
f.exec(`DROP TRIGGER test_reject_runtime_tick`)
if err == nil || stats.Consumed != 0 || f.count("pulse_tasks", "1=1") != 1 || f.count("weave_inbox", "state='PENDING' AND available_at>UTC_TIMESTAMP(6) AND error_code='RUNTIME_UNAVAILABLE'") != 1 {
t.Fatal("scanner lost rollback or failure backoff", stats, err)
}
if done, err := r.process(ctx, runtimeInbox(t, f, ref.TaskID), false); err != nil || done {
t.Fatal("consumer ignored durable backoff", done, err)
}
f.exec(`UPDATE weave_inbox SET available_at='2000-01-01' WHERE namespace_id=?`, c.ns)
stats, err = r.Tick(ctx, 1)
if err != nil || stats.Consumed != 1 || f.count("pulse_tasks", "1=1") != 2 {
t.Fatal("scanner did not recover", stats, err)
}
// 旧失败回调不能延后或改写已被新事务确认的记录。
if err := r.deferCandidate(ctx, c); err != nil {
t.Fatal(err)
}
if f.count("weave_inbox", "state='PROCESSED' AND error_code='TASK_RESULT'") != 1 {
t.Fatal("old deferral overwrote acknowledgement")
}
})
}

View File

@@ -0,0 +1,148 @@
package mysql
import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
"git.yuntuo.hk/k2safe/orbit/weave/instance"
mysqldriver "github.com/go-sql-driver/mysql"
)
// 真实运行Orbit控制进程;强杀唯一控制节点后写入结果,再由两个全新进程竞争接续。
// 这里只验证内部实例受理+Pulse执行存储+真实控制进程,公共实例HTTP/SDK另行验收。
func TestIntegrationWorkflowRuntimeProcess(t *testing.T) {
dsn, binary := os.Getenv("ORBIT_TEST_MYSQL_DSN"), os.Getenv("ORBIT_TEST_SERVER_BINARY")
if dsn == "" || binary == "" {
t.Skip("requires disposable MySQL and built Orbit binary")
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
admin, err := Open(ctx, dsn, 2, 1)
if err != nil {
t.Fatal(err)
}
defer admin.Close()
name := "orbit_workflow_process_" + string(executionID(t))
if _, err := admin.ExecContext(ctx, "CREATE DATABASE `"+name+"`"); err != nil {
t.Fatal(err)
}
defer func() {
cleanup, stop := context.WithTimeout(context.Background(), 10*time.Second)
defer stop()
if _, err := admin.ExecContext(cleanup, "DROP DATABASE `"+name+"`"); err != nil {
t.Error(err)
}
}()
cfg, err := mysqldriver.ParseDSN(dsn)
if err != nil {
t.Fatal("invalid disposable DSN")
}
cfg.DBName = name
db, err := Open(ctx, cfg.FormatDSN(), 16, 4)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := Migrate(ctx, db); err != nil {
t.Fatal(err)
}
f := newExecutionFixture(t, ctx, db)
v := runtimeStart(t, f, runtimeSpec(f.resources.QueueID, 3))
dir := t.TempDir()
type controller struct {
cmd *exec.Cmd
done chan error
log *os.File
stopped bool
}
var processes []*controller
stop := func(p *controller) {
t.Helper()
if p.stopped {
return
}
p.stopped = true
if err := p.cmd.Process.Kill(); err != nil {
t.Error(err)
}
select {
case <-p.done:
case <-time.After(10 * time.Second):
t.Error("controller did not exit after SIGKILL")
}
p.log.Close()
}
defer func() {
for _, p := range processes {
stop(p)
}
}()
start := func(label string) *controller {
t.Helper()
log, err := os.Create(filepath.Join(dir, label+".log"))
if err != nil {
t.Fatal(err)
}
cmd := exec.CommandContext(ctx, binary)
cmd.Env = append(os.Environ(), "ORBIT_MYSQL_DSN="+cfg.FormatDSN(), "ORBIT_HTTP_ADDR=127.0.0.1:0")
cmd.Stdout, cmd.Stderr = log, log
if err := cmd.Start(); err != nil {
log.Close()
t.Fatal(err)
}
p := &controller{cmd: cmd, done: make(chan error, 1), log: log}
processes = append(processes, p)
go func() { p.done <- cmd.Wait() }()
return p
}
wait := func(check func() bool, label string) {
t.Helper()
deadline := time.Now().Add(20 * time.Second)
for time.Now().Before(deadline) {
if check() {
return
}
for _, p := range processes {
if p.stopped {
continue
}
select {
case err := <-p.done:
p.stopped = true
t.Fatalf("controller exited unexpectedly (%s): %v", label, err)
default:
}
}
time.Sleep(25 * time.Millisecond)
}
t.Fatalf("timed out waiting for %s", label)
}
first := start("only-controller")
ref := retryStart(t, f)
retryFinish(t, f, ref, "SUCCEEDED", false)
wait(func() bool { return f.count("pulse_tasks", "1=1") == 2 }, "first successor")
second := retryStart(t, f)
stop(first) // 必须等Wait确认死亡,才把下一结果写入;排除活节点代为处理。
retryFinish(t, f, second, "SUCCEEDED", false)
if f.count("weave_inbox", "source_task_id=? AND state='PENDING'", second.TaskID) != 1 || f.count("pulse_tasks", "1=1") != 2 {
t.Fatal("result was not left durable while every controller was stopped")
}
start("replacement-a")
start("replacement-b")
wait(func() bool { return f.count("pulse_tasks", "1=1") == 3 }, "two-node continuation")
third := retryStart(t, f)
retryFinish(t, f, third, "SUCCEEDED", false)
api := instance.New(NewWorkflowInstanceStore(db))
wait(func() bool {
current, err := api.Get(ctx, f.token, v.ID)
return err == nil && current.State == "SUCCEEDED"
}, "completed workflow")
if f.count("pulse_tasks", "1=1") != 3 || f.count("weave_inbox", "state='PROCESSED'") != 3 || f.count("orbit_audit_events", "action='workflow.advance'") != 3 || f.count("weave_pending_advances", "1=1") != 0 {
t.Fatal("process restart duplicated work or lost confirmation")
}
t.Log("sole Orbit process SIGKILL confirmed; result committed with all controllers stopped; two new Orbit processes completed exactly three tasks and three result confirmations")
}