feat: add bounded workflow decisions and durable result inbox

This commit is contained in:
2026-09-18 01:11:42 +08:00
parent 520e98d9d1
commit 0f7ac5203b
20 changed files with 1909 additions and 9 deletions

View File

@@ -64,6 +64,7 @@ func TestIntegrationApplicationResourcesUpgrade(t *testing.T) {
retryFinish(t, g, finished, "SUCCEEDED", false)
assertApplicationCounts(t, f, 4, 2, 1)
// 还原后续新增表和字段,得到真实 v9 结构;现有任务内容保持不变。
f.exec(`ALTER TABLE weave_inbox DROP CHECK ck_weave_inbox_source, DROP INDEX uq_weave_task_result, DROP COLUMN source_task_id, DROP COLUMN source_activation_id, DROP COLUMN source_revision, DROP COLUMN snapshot`)
f.exec(`ALTER TABLE pulse_tasks DROP INDEX ix_task_queue_business_created, DROP INDEX ix_task_queue_business_state_created, MODIFY COLUMN business_key VARCHAR(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_as_cs NULL COMMENT '业务侧关联键,用于检索追踪,不自动提供业务幂等'`)
f.exec(`ALTER TABLE pulse_tasks DROP CHECK ck_task_retry_not_self, DROP INDEX uq_task_retry_parent, ADD INDEX fk_pulse_tasks_3(namespace_id,retry_of)`)
f.exec(`DROP TABLE orbit_application_resources`)

View File

@@ -0,0 +1,18 @@
-- Weave内部结果保存独立快照,避免外部结果日志清理或任务后续核查改变历史输入。
ALTER TABLE weave_inbox
ADD COLUMN source_task_id BINARY(16) NULL COMMENT '来源Pulse任务标识;仅迁移前无法核实的历史记录允许为空',
ADD COLUMN source_activation_id BINARY(16) NULL COMMENT '事件发生时的Weave激活标识,保留旧激活归属;仅未核实历史记录可空',
ADD COLUMN source_revision BIGINT UNSIGNED NULL COMMENT '来源任务的状态版本,必须大于零;仅未核实历史记录可空',
ADD COLUMN snapshot MEDIUMTEXT NULL COMMENT 'schema_version版本化结果JSON文本,保留精确数字与当时状态;仅未核实历史记录可空';
-- 旧记录缺少冻结结果,不能用当前任务状态倒推历史并自动推进。
UPDATE weave_inbox SET state='QUARANTINED',lease_owner=NULL,lease_until=NULL,
error_code='LEGACY_RESULT_UNVERIFIED',revision=revision+1,updated_at=UTC_TIMESTAMP(6)
WHERE state<>'PROCESSED';
ALTER TABLE weave_inbox
ADD UNIQUE KEY uq_weave_task_result (namespace_id,source_task_id,source_revision),
ADD CONSTRAINT ck_weave_inbox_source CHECK (
(state IN ('PROCESSED','QUARANTINED') AND source_task_id IS NULL AND source_activation_id IS NULL AND source_revision IS NULL AND snapshot IS NULL)
OR (source_task_id IS NOT NULL AND source_activation_id IS NOT NULL AND source_revision IS NOT NULL AND source_revision>0 AND snapshot IS NOT NULL AND OCTET_LENGTH(snapshot)<=524288 AND JSON_VALID(snapshot))
);

View File

@@ -88,8 +88,8 @@ func TestIntegrationNoForeignKeysUpgrade(t *testing.T) {
}
indexes := func() []string {
t.Helper()
// v15 将原 retry_of 非唯一索引替换为相同列的唯一索引,下方单独验证其定义。
rows, err := db.QueryContext(ctx, `SELECT CONCAT_WS(':',TABLE_NAME,INDEX_NAME,NON_UNIQUE,SEQ_IN_INDEX,COLUMN_NAME,COALESCE(SUB_PART,0),INDEX_TYPE,IS_VISIBLE) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME NOT IN ('orbit_application_resources','orbit_event_streams','orbit_result_events') AND INDEX_NAME NOT IN ('ix_task_priority_dispatch','ix_delivery_subscription_due','ix_delivery_subscription_lease','ix_delivery_history','ix_delivery_state_history','uq_task_retry_parent','fk_pulse_tasks_3','ix_task_queue_business_created','ix_task_queue_business_state_created') ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX`)
// v15替换retry_of索引、v17增加内部结果唯一索引,下方单独验证定义。
rows, err := db.QueryContext(ctx, `SELECT CONCAT_WS(':',TABLE_NAME,INDEX_NAME,NON_UNIQUE,SEQ_IN_INDEX,COLUMN_NAME,COALESCE(SUB_PART,0),INDEX_TYPE,IS_VISIBLE) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME NOT IN ('orbit_application_resources','orbit_event_streams','orbit_result_events') AND INDEX_NAME NOT IN ('ix_task_priority_dispatch','ix_delivery_subscription_due','ix_delivery_subscription_lease','ix_delivery_history','ix_delivery_state_history','uq_task_retry_parent','fk_pulse_tasks_3','ix_task_queue_business_created','ix_task_queue_business_state_created','uq_weave_task_result') ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX`)
if err != nil {
t.Fatal(err)
}
@@ -173,6 +173,10 @@ func TestIntegrationNoForeignKeysUpgrade(t *testing.T) {
if err := db.QueryRowContext(ctx, `SELECT GROUP_CONCAT(CONCAT(COLUMN_NAME,':',NON_UNIQUE) ORDER BY SEQ_IN_INDEX) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='pulse_tasks' AND INDEX_NAME='uq_task_retry_parent'`).Scan(&retryIndex); err != nil || retryIndex != "namespace_id:0,retry_of:0" {
t.Fatalf("retry uniqueness replacement=%q err=%v", retryIndex, err)
}
var resultIndex string
if err := db.QueryRowContext(ctx, `SELECT GROUP_CONCAT(CONCAT(COLUMN_NAME,':',NON_UNIQUE) ORDER BY SEQ_IN_INDEX) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='weave_inbox' AND INDEX_NAME='uq_weave_task_result'`).Scan(&resultIndex); err != nil || resultIndex != "namespace_id:0,source_task_id:0,source_revision:0" {
t.Fatalf("workflow result uniqueness=%q err=%v", resultIndex, err)
}
var collation string
if err := db.QueryRowContext(ctx, `SELECT COLLATION_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='pulse_tasks' AND COLUMN_NAME='business_key'`).Scan(&collation); err != nil || collation != "utf8mb4_0900_bin" {
t.Fatalf("business key collation=%q err=%v", collation, err)

View File

@@ -17,8 +17,13 @@ import (
func appendResultEvent(ctx context.Context, tx *sql.Tx, ns, eventID, taskID core.ID, revision uint64, eventType string) error {
var snapshot events.TaskResult
var raw []byte
var link workflowTaskLink
snapshot.SchemaVersion = 1
err := tx.QueryRowContext(ctx, `SELECT id,queue_id,state,revision,result,effect_uncertain,current_attempt_id FROM pulse_tasks WHERE namespace_id=? AND id=?`, ns, taskID).Scan(&snapshot.TaskID, &snapshot.QueueID, &snapshot.State, &snapshot.Revision, &raw, &snapshot.EffectUncertain, &snapshot.CurrentAttemptID)
err := tx.QueryRowContext(ctx, `SELECT t.id,t.queue_id,t.state,t.revision,t.result,t.effect_uncertain,t.current_attempt_id,t.owner_kind,t.owner_id,t.gate_id,
EXISTS(SELECT 1 FROM weave_activations a WHERE a.namespace_id=t.namespace_id AND a.task_id=t.id)
OR EXISTS(SELECT 1 FROM weave_poll_rounds r WHERE r.namespace_id=t.namespace_id AND r.task_id=t.id)
OR EXISTS(SELECT 1 FROM weave_instances i WHERE i.namespace_id=t.namespace_id AND i.gate_id=t.gate_id)
FROM pulse_tasks t WHERE t.namespace_id=? AND t.id=?`, ns, taskID).Scan(&snapshot.TaskID, &snapshot.QueueID, &snapshot.State, &snapshot.Revision, &raw, &snapshot.EffectUncertain, &snapshot.CurrentAttemptID, &link.Owner, &link.Activation, &link.Gate, &link.Linked)
if err != nil {
return err
}
@@ -30,6 +35,10 @@ func appendResultEvent(ctx context.Context, tx *sql.Tx, ns, eventID, taskID core
if err != nil {
return err
}
// 内部待处理记录和结果同事务,外部结果流锁仍最后取得。
if err := appendWorkflowResult(ctx, tx, ns, eventID, taskID, revision, body, link); err != nil {
return err
}
head, _, err := lockEventStream(ctx, tx, ns)
if err != nil {
return err

View File

@@ -0,0 +1,80 @@
package mysql
import (
"context"
"database/sql"
"errors"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/weave/engine"
)
type workflowTaskLink struct {
Owner sql.NullString
Activation *core.ID
Gate *core.ID
Linked bool
}
// appendWorkflowResult 在原Pulse结果事务内创建内部交付,必须先于外部结果流行锁。
// 这里只核验稳定归属并写入队列,不锁实例/激活、不推进流程、不锁对外订阅。
func appendWorkflowResult(ctx context.Context, tx *sql.Tx, ns, eventID, taskID core.ID, revision uint64, snapshot []byte, link workflowTaskLink) error {
owner, ownerID, gate := link.Owner, link.Activation, link.Gate
if !owner.Valid || owner.String != engine.TaskOwnerKind {
if owner.Valid && owner.String != "schedule" {
return errors.New("unrecognized task owner")
}
// 无外键情况下,丢失owner不能把已有流程关联静默降级成普通任务。
if link.Linked {
return errors.New("workflow result ownership missing")
}
return nil
}
if ownerID == nil || gate == nil {
return errors.New("workflow result owner or gate missing")
}
var workflow core.ID
var kind string
var activationTask *core.ID
// 即使旧激活不再是current,也要保存内部事件,消费方再核对当前激活并留痕。
err := tx.QueryRowContext(ctx, `SELECT a.workflow_id,s.kind,a.task_id
FROM weave_activations a
JOIN weave_steps s ON s.namespace_id=a.namespace_id AND s.id=a.step_id AND s.workflow_id=a.workflow_id
JOIN weave_instances i ON i.namespace_id=a.namespace_id AND i.id=a.workflow_id AND i.gate_id=?
JOIN pulse_execution_gates g ON g.namespace_id=i.namespace_id AND g.id=i.gate_id
JOIN weave_definition_versions v ON v.namespace_id=i.namespace_id AND v.id=i.definition_version_id AND v.definition_id=i.definition_id
JOIN weave_definitions d ON d.namespace_id=i.namespace_id AND d.id=i.definition_id
WHERE a.namespace_id=? AND a.id=?`, gate, ns, ownerID).Scan(&workflow, &kind, &activationTask)
if errors.Is(err, sql.ErrNoRows) {
return errors.New("workflow result associations invalid")
}
if err != nil {
return err
}
switch kind {
case "TASK":
if activationTask == nil || *activationTask != taskID {
return errors.New("workflow activation task mismatch")
}
case "POLL":
if activationTask != nil {
return errors.New("poll activation cannot own a direct 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`, ns, workflow, ownerID, taskID).Scan(&round)
if errors.Is(err, sql.ErrNoRows) {
return errors.New("workflow poll task association invalid")
}
if err != nil {
return err
}
default:
return errors.New("workflow task owner kind invalid")
}
id, err := core.NewID()
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `INSERT INTO weave_inbox(namespace_id,id,workflow_id,event_id,available_at,source_task_id,source_activation_id,source_revision,snapshot) VALUES (?,?,?,?,UTC_TIMESTAMP(6),?,?,?,?)`, ns, id, workflow, eventID, taskID, ownerID, revision, string(snapshot))
return err
}

View File

@@ -0,0 +1,277 @@
package mysql
import (
"context"
"encoding/json"
"os"
"sync"
"testing"
"time"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
"git.yuntuo.hk/k2safe/orbit/platform/events"
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
"git.yuntuo.hk/k2safe/orbit/pulse/service"
"git.yuntuo.hk/k2safe/orbit/pulse/worker"
"git.yuntuo.hk/k2safe/orbit/weave/definition"
"git.yuntuo.hk/k2safe/orbit/weave/engine"
)
// 当前尚无实例API;此夹具只为验证Pulse结果交付边界,不能充当已实现的实例运行器。
type workflowOwnerFixture struct{ workflow, step, activation, gate core.ID }
func bindWorkflowOwner(t *testing.T, f *executionFixture, task core.ID, poll bool) workflowOwnerFixture {
t.Helper()
ns := f.resources.NamespaceID
permissions := auth.Permissions{Actions: []string{"tasks:read", "tasks:submit", "tasks:cancel", "workers:connect", "workers:consume", definition.ReadAction, definition.PublishAction}, Queues: []string{"*"}}
raw, _ := json.Marshal(permissions)
f.exec(`UPDATE orbit_credentials SET permissions=? WHERE namespace_id=? AND id=?`, string(raw), ns, f.resources.CredentialID)
api := definition.New(NewWorkflowDefinitionStore(f.db))
created, err := api.Create(f.ctx, f.token, string(executionID(t)), definition.CreateInput{Name: "flow-" + string(executionID(t))})
if err != nil {
t.Fatal(err)
}
spec := workflowTestSpec(f.resources.QueueID)
kind, key := "TASK", "prepare"
if poll {
kind, key = "POLL", "query"
query := *spec.Body.Steps[0].Task
status := definition.Type{Kind: "OBJECT", Fields: map[string]definition.Type{"status": {Kind: "STRING", Enum: []string{"PENDING", "DONE"}}}}
query.ResultType = status
spec.Body = definition.Block{Steps: []definition.Step{{Key: key, Kind: kind, Poll: &definition.Poll{Task: query, MaxRounds: 2, TimeoutMS: 10000, IntervalMS: 1000, StatusPath: []string{"status"}, Pending: []string{"PENDING"}, Succeeded: []string{"DONE"}}}}, OutputType: status, Output: definition.Value{Kind: "REFERENCE", Reference: &definition.Reference{Source: key}}}
}
version, err := api.Publish(f.ctx, f.token, created.Definition.ID, string(executionID(t)), definition.PublishInput{Version: "v1", Reason: "internal result delivery regression", Specification: spec})
if err != nil {
t.Fatal(err)
}
out := workflowOwnerFixture{executionID(t), executionID(t), executionID(t), executionID(t)}
f.exec(`INSERT INTO pulse_execution_gates(namespace_id,id) VALUES (?,?)`, ns, out.gate)
f.exec(`INSERT INTO weave_instances(namespace_id,id,definition_id,definition_version_id,gate_id,input,policy_snapshot,deadline_at) VALUES (?,?,?,?,?,'{"n":9007199254740993}','{}',DATE_ADD(UTC_TIMESTAMP(6),INTERVAL 1 HOUR))`, ns, out.workflow, created.Definition.ID, version.Version.ID, out.gate)
f.exec("INSERT INTO weave_steps(namespace_id,id,workflow_id,`key`,kind,state) VALUES (?,?,?,?,?,'ACTIVE')", ns, out.step, out.workflow, key, kind)
var direct *core.ID
if !poll {
direct = &task
}
f.exec(`INSERT INTO weave_activations(namespace_id,id,workflow_id,step_id,sequence,state,task_id,input) VALUES (?,?,?,?,1,'ACTIVE',?,'{"n":9007199254740993}')`, ns, out.activation, out.workflow, out.step, direct)
f.exec(`UPDATE weave_steps SET current_activation_id=? WHERE namespace_id=? AND id=?`, out.activation, ns, out.step)
f.exec(`UPDATE pulse_tasks SET owner_kind=?,owner_id=?,gate_id=? WHERE namespace_id=? AND id=?`, engine.TaskOwnerKind, out.activation, out.gate, ns, task)
if poll {
f.exec(`INSERT INTO weave_poll_states(namespace_id,id,workflow_id,activation_id,max_rounds,used_rounds,max_attempts_per_round,interval_ms,deadline_at,current_task_id) VALUES (?,?,?,?,2,1,1,1000,DATE_ADD(UTC_TIMESTAMP(6),INTERVAL 1 HOUR),?)`, ns, executionID(t), out.workflow, out.activation, task)
f.exec("INSERT INTO weave_poll_rounds(namespace_id,id,workflow_id,activation_id,`round`,task_id) VALUES (?,?,?,?,1,?)", ns, executionID(t), out.workflow, out.activation, task)
}
return out
}
func assertWorkflowInbox(t *testing.T, f *executionFixture, owner workflowOwnerFixture, task core.ID, state string, raw string) {
t.Helper()
var taskID, activationID, workflowID core.ID
var revision uint64
var snapshot []byte
var status string
if err := f.db.QueryRowContext(f.ctx, `SELECT source_task_id,source_activation_id,workflow_id,source_revision,snapshot,state FROM weave_inbox WHERE namespace_id=? AND workflow_id=?`, f.resources.NamespaceID, owner.workflow).Scan(&taskID, &activationID, &workflowID, &revision, &snapshot, &status); err != nil {
t.Fatal(err)
}
var result events.TaskResult
if err := json.Unmarshal(snapshot, &result); err != nil {
t.Fatal(err)
}
if taskID != task || activationID != owner.activation || workflowID != owner.workflow || revision != result.Revision || status != "PENDING" || result.State != state || result.TaskID != task || string(result.Result) != raw {
t.Fatal("internal result snapshot mismatch", result)
}
if f.count("weave_inbox", "workflow_id=?", owner.workflow) != 1 {
t.Fatal("duplicate workflow delivery")
}
var n int
if err := f.db.QueryRowContext(f.ctx, `SELECT COUNT(*) FROM weave_inbox i JOIN orbit_events e ON e.namespace_id=i.namespace_id AND e.id=i.event_id JOIN orbit_result_events r ON r.namespace_id=i.namespace_id AND r.id=i.event_id WHERE i.namespace_id=? AND i.workflow_id=? AND e.aggregate_id=i.source_task_id AND e.aggregate_revision=i.source_revision`, f.resources.NamespaceID, owner.workflow).Scan(&n); err != nil || n != 1 {
t.Fatal("source events not atomic", err)
}
}
func TestIntegrationWorkflowResultDelivery(t *testing.T) {
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
if dsn == "" {
t.Skip("requires disposable MySQL")
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
db, err := Open(ctx, dsn, 24, 8)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := Migrate(ctx, db); err != nil {
t.Fatal(err)
}
for _, outcome := range []string{"SUCCEEDED", "FAILED", "UNKNOWN", "CANCELLED"} {
t.Run(outcome, func(t *testing.T) {
f := newExecutionFixture(t, ctx, db)
task := f.submit(1)
owner := bindWorkflowOwner(t, f, task.ID, false)
_, ref := f.claim()
if _, err := f.a.Start(ctx, f.token, f.binding, executionID(t), ref); err != nil {
t.Fatal(err)
}
// 暂停后在途结果仍需持久化;同一完成回执并发不能产生第二条内部事件。
f.exec(`UPDATE pulse_execution_gates SET paused=TRUE WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, owner.gate)
in := worker.FinishInput{Ref: ref, Outcome: outcome, Result: json.RawMessage(`{"n":9007199254740993}`)}
if outcome != "SUCCEEDED" {
in.ErrorCode = "CONTROLLED_RESULT"
}
if outcome == "CANCELLED" {
f.exec(`UPDATE pulse_tasks SET cancel_requested_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, task.ID)
}
message := executionID(t)
var wg sync.WaitGroup
results := make(chan error, 8)
for i := 0; i < 8; i++ {
wg.Add(1)
go func() { defer wg.Done(); _, err := f.a.Finish(ctx, f.token, f.binding, message, in); results <- err }()
}
wg.Wait()
close(results)
for err := range results {
if err != nil {
t.Fatal(err)
}
}
state := outcome
if outcome == "UNKNOWN" {
state = "NEEDS_REVIEW"
}
assertWorkflowInbox(t, f, owner, task.ID, state, string(in.Result))
// 外部结果日志可独立保留/清理,不能改变内部接续输入。
f.exec(`DELETE FROM orbit_result_events WHERE namespace_id=?`, f.resources.NamespaceID)
var frozen string
if err := db.QueryRowContext(ctx, `SELECT snapshot FROM weave_inbox WHERE namespace_id=? AND workflow_id=?`, f.resources.NamespaceID, owner.workflow).Scan(&frozen); err != nil {
t.Fatal(err)
}
var result events.TaskResult
if json.Unmarshal([]byte(frozen), &result) != nil || string(result.Result) != string(in.Result) {
t.Fatal("internal result depended on external journal")
}
})
}
t.Run("poll_and_old_activation_keep_original_identity", func(t *testing.T) {
f := newExecutionFixture(t, ctx, db)
task := f.submit(1)
owner := bindWorkflowOwner(t, f, task.ID, true)
_, err := f.tasks.CancelTask(ctx, f.token, task.ID, "forbidden-direct", service.CancelInput{ExpectedRevision: task.Revision, Reason: "owner controls cancellation"})
expectWorkerCode(t, err, "STATE_CONFLICT")
_, ref := f.claim()
if _, err := f.a.Start(ctx, f.token, f.binding, executionID(t), ref); err != nil {
t.Fatal(err)
}
next := executionID(t)
f.exec(`INSERT INTO weave_activations(namespace_id,id,workflow_id,step_id,sequence,input) VALUES (?,?,?,?,2,'{}')`, f.resources.NamespaceID, next, owner.workflow, owner.step)
f.exec(`UPDATE weave_steps SET current_activation_id=? WHERE namespace_id=? AND id=?`, next, f.resources.NamespaceID, owner.step)
raw := `{"status":"DONE"}`
if _, err := f.a.Finish(ctx, f.token, f.binding, executionID(t), worker.FinishInput{Ref: ref, Outcome: "SUCCEEDED", Result: json.RawMessage(raw)}); err != nil {
t.Fatal(err)
}
assertWorkflowInbox(t, f, owner, task.ID, "SUCCEEDED", raw)
})
t.Run("natural_lease_expiry_also_delivers", func(t *testing.T) {
f := newExecutionFixture(t, ctx, db)
task, err := f.tasks.Submit(ctx, f.token, string(executionID(t)), service.SubmitInput{QueueID: f.resources.QueueID, Handler: "object.prepare", HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{"n":1}`), TaskTimeoutMS: 1500, Retry: pulse.RetryPolicy{MaxAttempts: 1, AttemptTimeoutMS: 1000}})
if err != nil {
t.Fatal(err)
}
owner := bindWorkflowOwner(t, f, task.Task.ID, false)
_, ref := f.claim()
if _, err := f.a.Start(ctx, f.token, f.binding, executionID(t), ref); err != nil {
t.Fatal(err)
}
for f.get(task.Task.ID).State == pulse.TaskRunning {
if _, err := f.storeB.Recover(ctx, 100); err != nil {
t.Fatal(err)
}
select {
case <-ctx.Done():
t.Fatal(ctx.Err())
case <-time.After(50 * time.Millisecond):
}
}
assertWorkflowInbox(t, f, owner, task.Task.ID, "NEEDS_REVIEW", "")
if _, err := f.storeB.Recover(ctx, 100); err != nil {
t.Fatal(err)
}
if f.count("weave_inbox", "workflow_id=?", owner.workflow) != 1 {
t.Fatal("recovery duplicated result")
}
})
for _, table := range []string{"weave_inbox", "orbit_result_events"} {
t.Run("rollback_"+table, func(t *testing.T) {
f := newExecutionFixture(t, ctx, db)
task := f.submit(1)
owner := bindWorkflowOwner(t, f, task.ID, false)
_, ref := f.claim()
if _, err := f.a.Start(ctx, f.token, f.binding, executionID(t), ref); err != nil {
t.Fatal(err)
}
trigger := "orbit_test_workflow_delivery_failure"
f.exec(`CREATE TRIGGER ` + trigger + ` BEFORE INSERT ON ` + table + ` FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='controlled workflow delivery failure'`)
message := executionID(t)
in := worker.FinishInput{Ref: ref, Outcome: "SUCCEEDED", Result: json.RawMessage(`{"n":9007199254740993}`)}
_, err := f.a.Finish(ctx, f.token, f.binding, message, in)
f.exec(`DROP TRIGGER ` + trigger)
if err == nil || f.get(task.ID).State != pulse.TaskRunning || f.used() != 1 || f.count("weave_inbox", "workflow_id=?", owner.workflow) != 0 || f.count("orbit_events", "aggregate_id=? AND type='task.succeeded'", task.ID) != 0 || f.count("pulse_attempt_receipts", "attempt_id=? AND operation='finish'", ref.AttemptID) != 0 {
t.Fatal("partial result committed", err)
}
out, err := f.a.Finish(ctx, f.token, f.binding, message, in)
if err != nil || out.Deduplicated {
t.Fatal("original retry failed", err)
}
assertWorkflowInbox(t, f, owner, task.ID, "SUCCEEDED", string(in.Result))
})
}
for _, broken := range []string{"owner", "gate", "activation", "definition"} {
t.Run("reject_broken_"+broken, func(t *testing.T) {
f := newExecutionFixture(t, ctx, db)
task := f.submit(1)
owner := bindWorkflowOwner(t, f, task.ID, false)
_, ref := f.claim()
if _, err := f.a.Start(ctx, f.token, f.binding, executionID(t), ref); err != nil {
t.Fatal(err)
}
var restore func()
switch broken {
case "owner":
f.exec(`UPDATE pulse_tasks SET owner_kind=NULL,owner_id=NULL WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, task.ID)
restore = func() {
f.exec(`UPDATE pulse_tasks SET owner_kind=?,owner_id=? WHERE namespace_id=? AND id=?`, engine.TaskOwnerKind, owner.activation, f.resources.NamespaceID, task.ID)
}
case "gate":
f.exec(`UPDATE pulse_tasks SET gate_id=NULL WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, task.ID)
restore = func() {
f.exec(`UPDATE pulse_tasks SET gate_id=? WHERE namespace_id=? AND id=?`, owner.gate, f.resources.NamespaceID, task.ID)
}
case "activation":
f.exec(`UPDATE weave_activations SET task_id=NULL WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, owner.activation)
restore = func() {
f.exec(`UPDATE weave_activations SET task_id=? WHERE namespace_id=? AND id=?`, task.ID, f.resources.NamespaceID, owner.activation)
}
case "definition":
var old core.ID
if err := db.QueryRowContext(ctx, `SELECT definition_version_id FROM weave_instances WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, owner.workflow).Scan(&old); err != nil {
t.Fatal(err)
}
f.exec(`UPDATE weave_instances SET definition_version_id=? WHERE namespace_id=? AND id=?`, executionID(t), f.resources.NamespaceID, owner.workflow)
restore = func() {
f.exec(`UPDATE weave_instances SET definition_version_id=? WHERE namespace_id=? AND id=?`, old, f.resources.NamespaceID, owner.workflow)
}
}
message := executionID(t)
in := worker.FinishInput{Ref: ref, Outcome: "SUCCEEDED", Result: json.RawMessage(`{"n":1}`)}
_, err := f.a.Finish(ctx, f.token, f.binding, message, in)
restore()
if err == nil || f.get(task.ID).State != pulse.TaskRunning {
t.Fatal("broken association accepted", err)
}
if _, err := f.a.Finish(ctx, f.token, f.binding, message, in); err != nil {
t.Fatal(err)
}
assertWorkflowInbox(t, f, owner, task.ID, "SUCCEEDED", string(in.Result))
})
}
}

View File

@@ -0,0 +1,120 @@
package mysql
import (
"context"
"database/sql"
"os"
"testing"
"time"
core "git.yuntuo.hk/k2safe/orbit/model"
driver "github.com/go-sql-driver/mysql"
)
func TestIntegrationWorkflowInboxUpgrade(t *testing.T) {
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
if dsn == "" {
t.Skip("requires disposable MySQL")
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
admin, err := Open(ctx, dsn, 2, 1)
if err != nil {
t.Fatal(err)
}
defer admin.Close()
name := "orbit_workflow_upgrade_" + 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 := driver.ParseDSN(dsn)
if err != nil {
t.Fatal("invalid test DSN")
}
cfg.DBName = name
db, err := Open(ctx, cfg.FormatDSN(), 4, 2)
if err != nil {
t.Fatal(err)
}
defer db.Close()
exec := func(q string, args ...any) {
t.Helper()
if _, err := db.ExecContext(ctx, q, args...); err != nil {
t.Fatal(err)
}
}
exec(`CREATE TABLE orbit_schema_migrations (version BIGINT UNSIGNED PRIMARY KEY,name VARCHAR(191) NOT NULL,checksum CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,dirty BOOLEAN NOT NULL,applied_at DATETIME(6) NULL) ENGINE=InnoDB`)
migrations, err := Migrations()
if err != nil {
t.Fatal(err)
}
for _, m := range migrations {
if m.Version > 16 {
break
}
statements, err := splitSQL(m.SQL)
if err != nil {
t.Fatal(err)
}
for _, q := range statements {
exec(q)
}
exec(`INSERT INTO orbit_schema_migrations(version,name,checksum,dirty,applied_at) VALUES (?,?,?,FALSE,UTC_TIMESTAMP(6))`, m.Version, m.Name, m.Checksum)
}
ns, workflow := executionID(t), executionID(t)
rows := map[string]core.ID{}
for _, state := range []string{"PENDING", "LEASED", "PROCESSED", "QUARANTINED"} {
id := executionID(t)
rows[state] = id
var lease *core.ID
var until *time.Time
if state == "LEASED" {
v, tm := executionID(t), time.Now().UTC().Add(time.Minute)
lease, until = &v, &tm
}
exec(`INSERT INTO weave_inbox(namespace_id,id,workflow_id,event_id,state,available_at,lease_owner,lease_until,lease_epoch) VALUES (?,?,?,?,?,UTC_TIMESTAMP(6),?,?,7)`, ns, id, workflow, executionID(t), state, lease, until)
}
if err := Migrate(ctx, db); err != nil {
t.Fatal(err)
}
if err := CheckSchema(ctx, db); err != nil {
t.Fatal(err)
}
for original, id := range rows {
var state string
var source, activation, owner *core.ID
var version *uint64
var snapshot []byte
var code sql.NullString
var epoch, revision uint64
if err := db.QueryRowContext(ctx, `SELECT state,source_task_id,source_activation_id,source_revision,snapshot,lease_owner,error_code,lease_epoch,revision FROM weave_inbox WHERE namespace_id=? AND id=?`, ns, id).Scan(&state, &source, &activation, &version, &snapshot, &owner, &code, &epoch, &revision); err != nil {
t.Fatal(err)
}
expected := "QUARANTINED"
expectedRevision := uint64(2)
if original == "PROCESSED" {
expected = "PROCESSED"
expectedRevision = 1
}
if state != expected || source != nil || activation != nil || version != nil || snapshot != nil || owner != nil || epoch != 7 || revision != expectedRevision {
t.Fatal("legacy record was reinterpreted", state)
}
if original != "PROCESSED" && code.String != "LEGACY_RESULT_UNVERIFIED" {
t.Fatal("missing quarantine reason")
}
}
if _, err := db.ExecContext(ctx, `INSERT INTO weave_inbox(namespace_id,id,workflow_id,event_id,available_at) VALUES (?,?,?,?,UTC_TIMESTAMP(6))`, ns, executionID(t), workflow, executionID(t)); err == nil {
t.Fatal("new unverified pending input accepted")
}
if err := Migrate(ctx, db); err != nil {
t.Fatal(err)
}
t.Log("v16 legacy inputs quarantined without reconstructed results or restored leases; processed history retained; current schema has zero foreign keys")
}