feat: expose workflow state events and persist signal transitions

This commit is contained in:
2026-09-18 09:27:45 +08:00
parent 082898e705
commit a4d9900e2c
34 changed files with 1499 additions and 27 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 orbit_events DROP INDEX ix_event_workflow_history,DROP COLUMN workflow_id`)
f.exec(`DROP TABLE weave_version_scopes`)
f.exec(`ALTER TABLE weave_instances DROP INDEX ix_workflow_created,DROP INDEX ix_workflow_state_created,DROP INDEX ix_workflow_business_state_created,DROP INDEX ix_workflow_definition_created,DROP INDEX ix_workflow_definition_state_created,DROP INDEX ix_workflow_definition_business_created,DROP INDEX ix_workflow_definition_business_state_created`)
f.exec(`ALTER TABLE weave_signals DROP INDEX ix_signal_history`)

View File

@@ -0,0 +1,15 @@
-- 从已有不可变事件派生流程检索范围,不改写历史原文或伪造过去的状态转换。
ALTER TABLE orbit_events
ADD COLUMN workflow_id BINARY(16) GENERATED ALWAYS AS (
CASE
WHEN aggregate_kind='workflow' THEN aggregate_id
WHEN aggregate_kind IN ('workflow_step','workflow_signal')
AND JSON_TYPE(JSON_EXTRACT(payload,'$.workflow_id'))='STRING'
AND OCTET_LENGTH(JSON_UNQUOTE(JSON_EXTRACT(payload,'$.workflow_id')))=32
-- 严格DDL模式下UNHEX非法值会中止迁移,先确认32个字符全部是十六进制。
AND OCTET_LENGTH(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(LOWER(JSON_UNQUOTE(JSON_EXTRACT(payload,'$.workflow_id'))),'0',''),'1',''),'2',''),'3',''),'4',''),'5',''),'6',''),'7',''),'8',''),'9',''),'a',''),'b',''),'c',''),'d',''),'e',''),'f',''))=0
THEN UNHEX(JSON_UNQUOTE(JSON_EXTRACT(payload,'$.workflow_id')))
ELSE NULL
END
) STORED COMMENT '从不可变事件派生的流程查询标识;无范围或非法历史值为空,读取仍核对父资源,不能作为授权依据',
ADD KEY ix_event_workflow_history(namespace_id,workflow_id,occurred_at,id);

View File

@@ -88,8 +88,8 @@ func TestIntegrationNoForeignKeysUpgrade(t *testing.T) {
}
indexes := func() []string {
t.Helper()
// 排除后续新增/替换索引;原有项在此逐项比较,v24新增项由WorkflowQueryUpgrade核对。
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','weave_version_scopes') 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','ix_signal_expiry','ix_signal_activation','uq_wait_winner_signal','ix_task_workflow_owner','ix_signal_history','ix_workflow_created','ix_workflow_state_created','ix_workflow_business_state_created','ix_workflow_definition_created','ix_workflow_definition_state_created','ix_workflow_definition_business_created','ix_workflow_definition_business_state_created') ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX`)
// 排除后续新增/替换索引;原有项在此逐项比较,v24/v25新增项由对应Query/Timeline升级测试核对。
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','weave_version_scopes') 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','ix_signal_expiry','ix_signal_activation','uq_wait_winner_signal','ix_task_workflow_owner','ix_signal_history','ix_event_workflow_history','ix_workflow_created','ix_workflow_state_created','ix_workflow_business_state_created','ix_workflow_definition_created','ix_workflow_definition_state_created','ix_workflow_definition_business_created','ix_workflow_definition_business_state_created') ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX`)
if err != nil {
t.Fatal(err)
}

View File

@@ -182,7 +182,7 @@ func (r *WorkflowRuntime) settleWorkflow(ctx context.Context, tx *sql.Tx, g *wor
}
}
// 已无平台内有效授权时不逐秒空转;UNKNOWN不证明外部执行已结束,后续核查负责接续。
if _, err := tx.ExecContext(ctx, `UPDATE weave_signals SET state='REJECTED',disposition='WORKFLOW_STOPPED_REVIEW',revision=revision+1,updated_at=? WHERE namespace_id=? AND workflow_id=? AND state='PENDING'`, now, g.view.NamespaceID, g.view.ID); err != nil {
if err := rejectPendingWorkflowSignals(ctx, tx, g, "", nil, "WORKFLOW_STOPPED_REVIEW", now); err != nil {
return false, err
}
if err := deleteWorkflowClock(ctx, tx, g, g.steps[workflowRootKey]); err != nil {
@@ -212,7 +212,7 @@ func (r *WorkflowRuntime) settleWorkflow(ctx context.Context, tx *sql.Tx, g *wor
}
changed++
}
if _, err := tx.ExecContext(ctx, `UPDATE weave_signals SET state='REJECTED',disposition='WORKFLOW_STOPPED',revision=revision+1,updated_at=? WHERE namespace_id=? AND workflow_id=? AND state='PENDING'`, now, g.view.NamespaceID, g.view.ID); err != nil {
if err := rejectPendingWorkflowSignals(ctx, tx, g, "", nil, "WORKFLOW_STOPPED", now); err != nil {
return false, err
}
next, rootState := weave.InstanceCancelled, weave.StepCancelled

View File

@@ -0,0 +1,97 @@
package mysql
import (
"context"
"database/sql"
"encoding/json"
"errors"
"strings"
"time"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/weave/signal"
)
type signalTransition struct {
WorkflowID core.ID `json:"workflow_id"` // WorkflowID 与信号归属同事务保存的流程身份。
SignalID core.ID `json:"signal_id"` // SignalID 被修改的信号身份,旧记录保留。
From string `json:"from"` // From 本次转换前的真实状态,不以当前状态推测过去。
State string `json:"state"` // State 本次转换提交的目标状态。
Disposition string `json:"disposition"` // Disposition 消费、过期或拒绝的稳定原因。
ConsumedWaitID *core.ID `json:"consumed_wait_id,omitempty"` // ConsumedWaitID 消费赢家等待,其他转换省略。
}
func emitWorkflowSignalTransition(ctx context.Context, tx *sql.Tx, ns, workflow, id core.ID, revision uint64, state, reason string, wait *core.ID) error {
return emitEvent(ctx, tx, ns, id, revision, "workflow_signal", "workflow.signal.changed", signalTransition{workflow, id, "PENDING", state, reason, wait})
}
// rejectPendingWorkflowSignals 的filter只由内部固定SQL构造。调用方已持有实例写锁,
// 一次读取最多1025条检测破坏上限的存储;更新和每条事件同事务,按32条批量追加事件。
func rejectPendingWorkflowSignals(ctx context.Context, tx *sql.Tx, g *workflowGraph, filter string, args []any, reason string, now time.Time) error {
where := ` WHERE s.namespace_id=? AND s.workflow_id=? AND s.state='PENDING'` + filter
params := append([]any{g.view.NamespaceID, g.view.ID}, args...)
rows, err := tx.QueryContext(ctx, `SELECT s.id,s.revision FROM weave_signals s`+where+` ORDER BY s.id LIMIT ? FOR UPDATE`, append(params, signal.MaxPerWorkflow+1)...)
if err != nil {
return err
}
type entry struct {
id core.ID
revision uint64
}
var items []entry
for rows.Next() {
var v entry
if err = rows.Scan(&v.id, &v.revision); err != nil {
rows.Close()
return err
}
items = append(items, v)
}
err = rows.Err()
rows.Close()
if err != nil {
return err
}
if len(items) > signal.MaxPerWorkflow {
return errors.New("workflow signal limit violated")
}
if len(items) == 0 {
return nil
}
update := append([]any{reason, now}, params...)
result, err := tx.ExecContext(ctx, `UPDATE weave_signals s SET s.state='REJECTED',s.disposition=?,s.revision=s.revision+1,s.updated_at=?`+where, update...)
if err != nil {
return err
}
n, err := result.RowsAffected()
if err != nil {
return err
}
if n != int64(len(items)) {
return errors.New("workflow signal rejection set changed")
}
for start := 0; start < len(items); start += 32 {
end := start + 32
if end > len(items) {
end = len(items)
}
var values []string
var insert []any
for _, v := range items[start:end] {
event, err := core.NewID()
if err != nil {
return err
}
raw, err := json.Marshal(signalTransition{WorkflowID: g.view.ID, SignalID: v.id, From: "PENDING", State: "REJECTED", Disposition: reason})
if err != nil {
return err
}
values = append(values, "(?,?,?,?,?,?,?,UTC_TIMESTAMP(6))")
insert = append(insert, g.view.NamespaceID, event, "workflow_signal", v.id, v.revision+1, "workflow.signal.changed", string(raw))
}
if _, err = tx.ExecContext(ctx, `INSERT INTO orbit_events(namespace_id,id,aggregate_kind,aggregate_id,aggregate_revision,type,payload,occurred_at) VALUES `+strings.Join(values, ","), insert...); err != nil {
return err
}
}
return nil
}

View File

@@ -53,7 +53,7 @@ func (r *WorkflowRuntime) driveWorkflowSignal(ctx context.Context, tx *sql.Tx, g
return false, false, "", err
}
}
if _, err := tx.ExecContext(ctx, `UPDATE weave_signals s JOIN weave_activations a ON a.namespace_id=s.namespace_id AND a.workflow_id=s.workflow_id AND a.sequence=s.target_activation_sequence SET s.state='REJECTED',s.disposition=?,s.revision=s.revision+1,s.updated_at=? WHERE s.namespace_id=? AND s.workflow_id=? AND s.target_step_key=? AND a.id=? AND a.step_id=? AND s.state='PENDING'`, string(invalid), now, g.view.NamespaceID, g.view.ID, row.view.Key, row.view.ActivationID, row.view.ID); err != nil {
if err := rejectPendingWorkflowSignals(ctx, tx, g, ` AND s.target_step_key=? AND EXISTS(SELECT 1 FROM weave_activations a WHERE a.namespace_id=s.namespace_id AND a.workflow_id=s.workflow_id AND a.sequence=s.target_activation_sequence AND a.id=? AND a.step_id=?)`, []any{row.view.Key, row.view.ActivationID, row.view.ID}, string(invalid), now); err != nil {
return false, false, "", err
}
if err := deleteWorkflowClock(ctx, tx, g, row); err != nil {
@@ -315,5 +315,8 @@ func (r *WorkflowRuntime) expireWorkflowSignal(ctx context.Context, tx *sql.Tx,
if err := exactlyOne(res, err); err != nil {
return false, err
}
if err := emitWorkflowSignalTransition(ctx, tx, c.ns, c.workflow, v.ID, v.Revision+1, "EXPIRED", "UNMATCHED_DEADLINE", nil); err != nil {
return false, err
}
return true, r.audit(ctx, tx, g, c.id, "SIGNAL_EXPIRED", now)
}

View File

@@ -175,9 +175,8 @@ func claimWorkflowSignal(ctx context.Context, tx *sql.Tx, g *workflowGraph, w *w
v.Revision++
v.Disposition = "WAIT_MATCHED"
v.ConsumedWaitID = &w.id
return nil
return emitWorkflowSignalTransition(ctx, tx, g.view.NamespaceID, g.view.ID, v.ID, v.Revision, string(v.State), v.Disposition, v.ConsumedWaitID)
}
func rejectOtherWorkflowSignals(ctx context.Context, tx *sql.Tx, g *workflowGraph, row *workflowStep, sequence uint32, reason string, now time.Time) error {
_, err := tx.ExecContext(ctx, `UPDATE weave_signals SET state='REJECTED',disposition=?,revision=revision+1,updated_at=? WHERE namespace_id=? AND workflow_id=? AND target_step_key=? AND target_activation_sequence=? AND state='PENDING'`, reason, now, g.view.NamespaceID, g.view.ID, row.view.Key, sequence)
return err
return rejectPendingWorkflowSignals(ctx, tx, g, ` AND s.target_step_key=? AND s.target_activation_sequence=?`, []any{row.view.Key, sequence}, reason, now)
}

View File

@@ -0,0 +1,266 @@
package mysql
import (
"context"
"database/sql"
"encoding/json"
"errors"
"strings"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
"git.yuntuo.hk/k2safe/orbit/pulse/service"
"git.yuntuo.hk/k2safe/orbit/weave/instance"
)
const workflowEventColumns = `e.id,e.aggregate_kind,e.aggregate_id,e.aggregate_revision,e.type,e.occurred_at,IF(OCTET_LENGTH(CAST(e.payload AS CHAR))<=65536,e.payload,NULL)`
const workflowEventTables = ` FROM orbit_events e FORCE INDEX(ix_event_workflow_history)
LEFT JOIN weave_steps s ON e.aggregate_kind='workflow_step' AND s.namespace_id=e.namespace_id AND s.id=e.aggregate_id AND s.workflow_id=e.workflow_id
WHERE e.namespace_id=? AND e.workflow_id=? AND (e.aggregate_kind<>'workflow_step' OR s.id IS NULL OR s.key<>'$root')`
type workflowEventParent struct {
view instance.View
steps map[core.ID]instance.StepView
}
func readWorkflowEventParent(ctx context.Context, tx *sql.Tx, ns, id core.ID, p auth.Permissions) (workflowEventParent, error) {
var out workflowEventParent
view, gate, ver, err := readWorkflowForQuery(ctx, tx, ns, id, p)
if err != nil {
return out, err
}
steps, err := readWorkflowStepViews(ctx, tx, ns, id, gate, ver, "")
if err != nil {
return out, err
}
out.view = view
out.steps = map[core.ID]instance.StepView{}
for _, step := range steps {
out.steps[step.ID] = step
}
return out, nil
}
func scanWorkflowEvent(row scanner) (instance.EventDetail, error) {
var out instance.EventDetail
var raw []byte
err := row.Scan(&out.ID, &out.AggregateKind, &out.AggregateID, &out.AggregateRevision, &out.Type, &out.OccurredAt, &raw)
out.Payload = raw
return out, err
}
func eventIDField(fields map[string]json.RawMessage, name string) (core.ID, error) {
var id core.ID
if json.Unmarshal(fields[name], &id) != nil || id.Validate() != nil {
return "", errors.New("invalid workflow event reference")
}
return id, nil
}
func verifyWorkflowEvent(ctx context.Context, tx *sql.Tx, h workflowEventParent, e *instance.EventDetail) error {
if e.ID.Validate() != nil || e.AggregateID.Validate() != nil || e.AggregateRevision == 0 || len(e.Payload) == 0 {
return errors.New("workflow event metadata invalid or oversized")
}
var fields map[string]json.RawMessage
if json.Unmarshal(e.Payload, &fields) != nil || fields == nil || json.Unmarshal(fields["state"], &e.State) != nil {
return errors.New("workflow event state missing")
}
e.WorkflowID = h.view.ID
switch e.AggregateKind {
case "workflow":
if e.AggregateID != h.view.ID || e.AggregateRevision > h.view.Revision {
return errors.New("workflow event aggregate mismatch")
}
e.StateScope = "WORKFLOW"
switch e.Type {
case "workflow.started":
def, err := eventIDField(fields, "definition_id")
if err != nil || def != h.view.DefinitionID {
return errors.New("workflow event definition mismatch")
}
version, err := eventIDField(fields, "definition_version_id")
if err != nil || version != h.view.DefinitionVersionID {
return errors.New("workflow event fixed version mismatch")
}
case "workflow.advanced", "workflow.controlled", "workflow.recovered", "workflow.blocked", "workflow.stopped":
case "workflow.signal.accepted":
e.StateScope = "SIGNAL"
signal, err := eventIDField(fields, "signal_id")
if err != nil {
return err
}
if err = verifyEventSignal(ctx, tx, h, signal, 0); err != nil {
return err
}
owner, err := eventIDField(fields, "workflow_id")
if err != nil || owner != h.view.ID {
return errors.New("workflow signal event scope mismatch")
}
default:
return errors.New("unsupported workflow event type")
}
case "workflow_step":
step, ok := h.steps[e.AggregateID]
if !ok || e.AggregateRevision > step.Revision || e.Type != "workflow.step.changed" {
return errors.New("workflow step event aggregate mismatch")
}
owner, err := eventIDField(fields, "workflow_id")
if err != nil || owner != h.view.ID {
return errors.New("workflow step event scope mismatch")
}
e.StepKey = step.Key
e.StateScope = "STEP"
if raw, ok := fields["key"]; ok {
var key string
if json.Unmarshal(raw, &key) != nil || key != step.Key {
return errors.New("workflow event step key mismatch")
}
}
if raw, ok := fields["activation_id"]; ok && string(raw) != "null" {
activation, err := eventIDField(fields, "activation_id")
if err != nil {
return err
}
var count int
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM weave_activations WHERE namespace_id=? AND workflow_id=? AND step_id=? AND id=?`, h.view.NamespaceID, h.view.ID, step.ID, activation).Scan(&count); err != nil {
return err
}
if count != 1 {
return errors.New("workflow event historical activation mismatch")
}
}
case "workflow_signal":
e.StateScope = "SIGNAL"
if e.Type != "workflow.signal.changed" {
return errors.New("unsupported workflow signal event")
}
owner, err := eventIDField(fields, "workflow_id")
if err != nil || owner != h.view.ID {
return errors.New("workflow signal event scope mismatch")
}
signal, err := eventIDField(fields, "signal_id")
if err != nil || signal != e.AggregateID {
return errors.New("workflow event signal reference mismatch")
}
if err = verifyEventSignal(ctx, tx, h, signal, e.AggregateRevision); err != nil {
return err
}
default:
return errors.New("unsupported workflow event aggregate")
}
valid := false
switch e.StateScope {
case "WORKFLOW":
switch e.State {
case "RUNNING", "WAITING", "CANCELLING", "NEEDS_REVIEW", "SUCCEEDED", "FAILED", "CANCELLED":
valid = true
}
case "STEP":
switch e.State {
case "BLOCKED", "ACTIVE", "WAITING", "NEEDS_REVIEW", "SUCCEEDED", "FAILED", "CANCELLED", "SKIPPED":
valid = true
}
case "SIGNAL":
switch e.State {
case "PENDING", "CONSUMED", "EXPIRED", "REJECTED":
valid = true
}
}
if !valid {
return errors.New("workflow event recorded state invalid")
}
return nil
}
func verifyEventSignal(ctx context.Context, tx *sql.Tx, h workflowEventParent, id core.ID, revision uint64) error {
var actual uint64
err := tx.QueryRowContext(ctx, `SELECT revision FROM weave_signals WHERE namespace_id=? AND workflow_id=? AND id=?`, h.view.NamespaceID, h.view.ID, id).Scan(&actual)
if err != nil {
return err
}
if revision > actual {
return errors.New("workflow signal event version mismatch")
}
return nil
}
func (s *WorkflowInstanceStore) ListEvents(ctx context.Context, t auth.Token, id core.ID, in service.HistoryPageInput) (instance.EventPage, error) {
out := instance.EventPage{WorkflowID: id, Items: []instance.EventSummary{}}
err := s.transaction(ctx, t, instance.AuditAction, func(tx *sql.Tx, p auth.Permissions) error {
h, err := readWorkflowEventParent(ctx, tx, t.NamespaceID, id, p)
if err != nil {
return err
}
out.Revision = h.view.Revision
q := `SELECT ` + workflowEventColumns + workflowEventTables
args := []any{t.NamespaceID, id}
if in.Cursor != "" {
c, err := decodeHistoryCursorKind(in.Cursor, t.NamespaceID, id, "", "workflow-events")
if err != nil {
return err
}
q += ` AND (e.occurred_at<? OR (e.occurred_at=? AND e.id<?))`
args = append(args, *c.Created, *c.Created, c.ID)
}
q += ` ORDER BY e.occurred_at DESC,e.id DESC LIMIT ?`
args = append(args, in.Limit+1)
rows, err := tx.QueryContext(ctx, q, args...)
if err != nil {
return err
}
var entries []instance.EventDetail
for rows.Next() {
e, err := scanWorkflowEvent(rows)
if err != nil {
rows.Close()
return err
}
entries = append(entries, e)
}
err = rows.Err()
rows.Close()
if err != nil {
return err
}
for i := range entries {
if err = verifyWorkflowEvent(ctx, tx, h, &entries[i]); err != nil {
return err
}
}
more := len(entries) > in.Limit
if more {
entries = entries[:in.Limit]
}
for _, e := range entries {
out.Items = append(out.Items, e.EventSummary)
}
if more {
last := out.Items[len(out.Items)-1]
out.NextCursor, err = encodeHistoryCursor(historyCursor{Version: 1, Kind: "workflow-events", Namespace: t.NamespaceID, Task: id, ID: last.ID, Created: &last.OccurredAt})
}
return err
})
if err != nil {
return instance.EventPage{}, err
}
return out, nil
}
func (s *WorkflowInstanceStore) GetEvent(ctx context.Context, t auth.Token, id, event core.ID) (instance.EventDetail, error) {
var out instance.EventDetail
err := s.transaction(ctx, t, instance.AuditAction, func(tx *sql.Tx, p auth.Permissions) error {
h, err := readWorkflowEventParent(ctx, tx, t.NamespaceID, id, p)
if err != nil {
return err
}
// 明确事件ID后走主键;仍保留流程派生范围和实际归属核验。
q := `SELECT ` + workflowEventColumns + strings.Replace(workflowEventTables, "FORCE INDEX(ix_event_workflow_history)", "FORCE INDEX(PRIMARY)", 1) + ` AND e.id=?`
out, err = scanWorkflowEvent(tx.QueryRowContext(ctx, q, t.NamespaceID, id, event))
if errors.Is(err, sql.ErrNoRows) {
return fault.NotFound
}
if err != nil {
return err
}
return verifyWorkflowEvent(ctx, tx, h, &out)
})
if err != nil {
return instance.EventDetail{}, err
}
return out, nil
}

View File

@@ -0,0 +1,319 @@
package mysql
import (
"context"
"encoding/json"
"os"
"strings"
"testing"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/pulse/service"
"git.yuntuo.hk/k2safe/orbit/weave/definition"
"git.yuntuo.hk/k2safe/orbit/weave/instance"
)
func grantWorkflowEvents(f *executionFixture) {
f.exec(`UPDATE orbit_credentials SET permissions=JSON_ARRAY_APPEND(permissions,'$.actions','workflows:audit:read') WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, f.resources.CredentialID)
}
func TestIntegrationWorkflowTimeline(t *testing.T) {
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
if dsn == "" {
t.Skip("requires disposable MySQL")
}
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Second)
defer cancel()
db, err := Open(ctx, dsn, 16, 4)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err = Migrate(ctx, db); err != nil {
t.Fatal(err)
}
api := instance.New(NewWorkflowInstanceStore(db))
t.Run("real_transitions_pagination_scope_and_no_payload_list", func(t *testing.T) {
f := newExecutionFixture(t, ctx, db)
v := runtimeStart(t, f, runtimeSpec(f.resources.QueueID, 2))
if _, err := api.ListEvents(ctx, f.token, v.ID, service.HistoryPageInput{}); fault.Code(err) != "PERMISSION_DENIED" {
t.Fatal("audit permission missing", err)
}
grantWorkflowEvents(f)
ref := retryStart(t, f)
retryFinish(t, f, ref, "SUCCEEDED", false)
runtimeConsume(t, f, newRuntime(t, db), ref.TaskID)
first, err := api.ListEvents(ctx, f.token, v.ID, service.HistoryPageInput{Limit: 1})
if err != nil || len(first.Items) != 1 || first.NextCursor == "" {
t.Fatal(first, err)
}
seen := map[core.ID]bool{}
states := map[string]bool{}
cursor := ""
var last *instance.EventSummary
for n := 0; n < 30; n++ {
page, err := api.ListEvents(ctx, f.token, v.ID, service.HistoryPageInput{Limit: 1, Cursor: cursor})
if err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(page)
if strings.Contains(string(raw), `"payload"`) || strings.Contains(string(raw), `9007199254740993`) {
t.Fatal("body exposed in list")
}
for _, e := range page.Items {
if seen[e.ID] || e.WorkflowID != v.ID || e.StepKey == workflowRootKey {
t.Fatal("event identity", e)
}
seen[e.ID] = true
states[e.Type+":"+e.State] = true
if last != nil && (e.OccurredAt.After(last.OccurredAt) || e.OccurredAt.Equal(last.OccurredAt) && e.ID >= last.ID) {
t.Fatal("unstable event order")
}
current := e
last = &current
detail, err := api.GetEvent(ctx, f.token, v.ID, e.ID)
if err != nil || detail.EventSummary != e || !json.Valid(detail.Payload) {
t.Fatal("event detail mismatch", detail, err)
}
}
if page.NextCursor == "" {
break
}
cursor = page.NextCursor
}
if len(seen) < 5 || !states["workflow.started:RUNNING"] || !states["workflow.step.changed:SUCCEEDED"] || !states["workflow.step.changed:ACTIVE"] {
t.Fatal("missing actual transitions", states)
}
if _, err := api.ListOperations(ctx, f.token, v.ID, service.HistoryPageInput{Cursor: first.NextCursor}); fault.Code(err) != "INVALID_ARGUMENT" {
t.Fatal("cross kind cursor", err)
}
other := runtimeStart(t, f, runtimeSpec(f.resources.QueueID, 1))
grantWorkflowEvents(f)
if _, err := api.GetEvent(ctx, f.token, other.ID, first.Items[0].ID); fault.Code(err) != "NOT_FOUND" {
t.Fatal("cross workflow event", err)
}
if _, err := api.ListEvents(ctx, f.token, other.ID, service.HistoryPageInput{Cursor: first.NextCursor}); fault.Code(err) != "INVALID_ARGUMENT" {
t.Fatal("cross workflow cursor", err)
}
grantWorkflowInstance(t, f, string(executionID(t)))
grantWorkflowEvents(f)
if _, err := api.ListEvents(ctx, f.token, v.ID, service.HistoryPageInput{}); fault.Code(err) != "NOT_FOUND" {
t.Fatal("queue bypass", err)
}
})
t.Run("signal_consumption_and_rejection_atomic_events", func(t *testing.T) {
f, v, _ := signalStart(t, ctx, db, false)
grantWorkflowEvents(f)
first := sendSignal(t, f, v, signalInput(v))
second := sendSignal(t, f, v, signalInput(v))
c := clockCandidate(t, f, v.ID, "signal")
f.exec(`CREATE TRIGGER timeline_signal_failure BEFORE INSERT ON orbit_events FOR EACH ROW SET NEW.type=IF(NEW.aggregate_kind='workflow_signal',NULL,NEW.type)`)
_, err := newRuntime(t, db).process(ctx, c, false)
f.exec(`DROP TRIGGER timeline_signal_failure`)
if err == nil || f.count("weave_signals", "state='PENDING'") != 2 || f.count("weave_waits", "winner_signal_id IS NOT NULL") != 0 || f.count("pulse_tasks", "1=1") != 0 || f.count("orbit_events", "aggregate_kind='workflow_signal'") != 0 {
t.Fatal("partial signal transition committed", err)
}
if !runClock(t, f, c) {
t.Fatal("retry failed")
}
page, err := api.ListEvents(ctx, f.token, v.ID, service.HistoryPageInput{Limit: 100})
if err != nil {
t.Fatal(err)
}
changed := map[core.ID]string{}
for _, e := range page.Items {
if e.AggregateKind == "workflow_signal" {
changed[e.AggregateID] = e.State
if e.StateScope != "SIGNAL" {
t.Fatal("wrong state subject")
}
detail, err := api.GetEvent(ctx, f.token, v.ID, e.ID)
if err != nil {
t.Fatal(err)
}
var meta signalTransition
if json.Unmarshal(detail.Payload, &meta) != nil || meta.From != "PENDING" {
t.Fatal("lost previous state")
}
}
}
if changed[first.Signal.ID] != "CONSUMED" || changed[second.Signal.ID] != "REJECTED" || len(changed) != 2 {
t.Fatal("signal transitions", changed)
}
runClock(t, f, c)
if f.count("orbit_events", "aggregate_kind='workflow_signal'") != 2 {
t.Fatal("duplicate transition")
}
})
t.Run("bulk_stop_preserves_all_signal_events_and_rollback", func(t *testing.T) {
f := newExecutionFixture(t, ctx, db)
spec := signalSpec(f.resources.QueueID, true)
spec.Body.Steps[0] = definition.Step{Key: "before", Kind: "TIMER", Timer: &definition.Timer{DelayMS: 60000}}
v := runtimeStart(t, f, spec)
grantWorkflowSignal(f)
grantWorkflowControl(f)
grantWorkflowEvents(f)
for n := 0; n < 70; n++ {
in := signalInput(v)
in.CorrelationKey += "unmatched"
if sent := sendSignal(t, f, v, in); sent.Signal.State != "PENDING" {
t.Fatal("fixture must retain early pending signals", sent.Signal.State)
}
}
stopped := applyControl(t, f, runtimeView(t, f, v.ID, "WAITING"), "CANCEL").Instance
var target core.ID
if err := db.QueryRowContext(ctx, `SELECT id FROM weave_signals WHERE namespace_id=? ORDER BY id LIMIT 1 OFFSET 40`, f.resources.NamespaceID).Scan(&target); err != nil {
t.Fatal(err)
}
f.exec(`CREATE TRIGGER timeline_batch_failure BEFORE INSERT ON orbit_events FOR EACH ROW SET NEW.type=IF(NEW.aggregate_kind='workflow_signal' AND NEW.aggregate_id=UNHEX('` + string(target) + `'),NULL,NEW.type)`)
c := clockCandidate(t, f, v.ID, workflowRootKey)
var transitionErr error
// 停止会先结清等待,根接续可能退避;推进到实际批量转换事务再注入失败。
for n := 0; n < 10; n++ {
f.exec(`UPDATE weave_pending_advances SET available_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?`, c.ns, c.id)
_, transitionErr = newRuntime(t, db).process(ctx, c, false)
if transitionErr != nil {
break
}
}
f.exec(`DROP TRIGGER timeline_batch_failure`)
if transitionErr == nil || f.count("weave_signals", "state='PENDING'") != 70 || f.count("orbit_events", "aggregate_kind='workflow_signal'") != 0 {
t.Fatal("batch prefix survived rollback", transitionErr, f.count("weave_signals", "state='PENDING'"), f.count("orbit_events", "aggregate_kind='workflow_signal'"))
}
drainStop(t, f, stopped.ID, "CANCELLED")
if f.count("weave_signals", "state='REJECTED'") != 70 || f.count("orbit_events", "aggregate_kind='workflow_signal' AND type='workflow.signal.changed'") != 70 {
t.Fatal("incomplete rejection history")
}
page, err := api.ListEvents(ctx, f.token, v.ID, service.HistoryPageInput{Limit: 100})
if err != nil || len(page.Items) != 100 || page.NextCursor == "" {
t.Fatal("large timeline", len(page.Items), err)
}
for _, e := range page.Items {
if e.StepKey == workflowRootKey {
t.Fatal("internal root exposed")
}
}
// 每批32条事件共享SQL语句时间;小页跨过相同时间,必须按ID无漏无重。
seen := map[core.ID]bool{}
times := map[time.Time]int{}
cursor := ""
for n := 0; n < 100; n++ {
page, err := api.ListEvents(ctx, f.token, v.ID, service.HistoryPageInput{Limit: 7, Cursor: cursor})
if err != nil {
t.Fatal(err)
}
for _, e := range page.Items {
if seen[e.ID] {
t.Fatal("duplicate tied event", e.ID)
}
seen[e.ID] = true
times[e.OccurredAt]++
}
if page.NextCursor == "" {
break
}
cursor = page.NextCursor
}
var expected int
if err := db.QueryRowContext(ctx, `SELECT COUNT(*)`+workflowEventTables, f.resources.NamespaceID, v.ID).Scan(&expected); err != nil || len(seen) != expected {
t.Fatal("tied pagination skipped events", len(seen), expected, err)
}
hasTies := false
for _, count := range times {
hasTies = hasTies || count > 7
}
if !hasTies {
t.Fatal("fixture did not cross timestamp ties")
}
})
t.Run("expiry_records_original_transition_once", func(t *testing.T) {
f, v, _ := signalStart(t, ctx, db, true)
grantWorkflowEvents(f)
in := signalInput(v)
in.CorrelationKey += "unmatched"
sig := sendSignal(t, f, v, in)
f.exec(`UPDATE weave_signals SET expires_at=DATE_SUB(UTC_TIMESTAMP(6),INTERVAL 1 SECOND),available_at=DATE_SUB(UTC_TIMESTAMP(6),INTERVAL 1 SECOND) WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, sig.Signal.ID)
c := workflowCandidate{kind: "SIGNAL", ns: f.resources.NamespaceID, workflow: v.ID, id: sig.Signal.ID, revision: 1}
if _, err := newRuntime(t, db).process(ctx, c, false); err != nil {
t.Fatal(err)
}
page, err := api.ListEvents(ctx, f.token, v.ID, service.HistoryPageInput{Limit: 100})
if err != nil {
t.Fatal(err)
}
count := 0
for _, e := range page.Items {
if e.AggregateKind == "workflow_signal" && e.AggregateID == sig.Signal.ID && e.State == "EXPIRED" {
count++
}
}
if count != 1 {
t.Fatal("expiry event missing", count)
}
})
t.Run("derived_scope_is_not_authority_and_payload_is_bounded", func(t *testing.T) {
f := newExecutionFixture(t, ctx, db)
v := runtimeStart(t, f, runtimeSpec(f.resources.QueueID, 1))
other := runtimeStart(t, f, runtimeSpec(f.resources.QueueID, 1))
grantWorkflowEvents(f)
var step core.ID
if err := db.QueryRowContext(ctx, "SELECT id FROM weave_steps WHERE namespace_id=? AND workflow_id=? AND `key`='task0'", f.resources.NamespaceID, other.ID).Scan(&step); err != nil {
t.Fatal(err)
}
forged := executionID(t)
f.exec(`INSERT INTO orbit_events(namespace_id,id,aggregate_kind,aggregate_id,aggregate_revision,type,payload,occurred_at) VALUES (?,?,'workflow_step',?,1,'workflow.step.changed',JSON_OBJECT('workflow_id',?,'state','ACTIVE'),UTC_TIMESTAMP(6))`, f.resources.NamespaceID, forged, step, string(v.ID))
if page, err := api.ListEvents(ctx, f.token, v.ID, service.HistoryPageInput{}); err == nil || len(page.Items) != 0 {
t.Fatal("forged scope returned partial data")
}
if _, err := api.GetEvent(ctx, f.token, v.ID, forged); err == nil {
t.Fatal("forged parent accepted")
}
huge := executionID(t)
f.exec(`INSERT INTO orbit_events(namespace_id,id,aggregate_kind,aggregate_id,aggregate_revision,type,payload,occurred_at) VALUES (?,?,'workflow',?,1,'workflow.blocked',JSON_OBJECT('state','RUNNING','padding',?),UTC_TIMESTAMP(6))`, f.resources.NamespaceID, huge, other.ID, strings.Repeat("x", 65536))
if _, err := api.GetEvent(ctx, f.token, other.ID, huge); err == nil {
t.Fatal("oversized detail accepted")
}
})
t.Run("historical_activation_survives_recovery_and_checks_parent", func(t *testing.T) {
f, v := waitRecoveryFixture(t, ctx, db, "TIMER", 30000)
grantWorkflowEvents(f)
old, err := api.Step(ctx, f.token, v.ID, "timer")
if err != nil {
t.Fatal(err)
}
page, err := api.ListEvents(ctx, f.token, v.ID, service.HistoryPageInput{Limit: 100})
if err != nil {
t.Fatal(err)
}
var saved instance.EventDetail
for _, event := range page.Items {
if event.AggregateKind != "workflow_step" || event.AggregateID != old.Step.ID {
continue
}
detail, err := api.GetEvent(ctx, f.token, v.ID, event.ID)
if err != nil {
t.Fatal(err)
}
var meta struct {
ActivationID core.ID `json:"activation_id"`
}
if json.Unmarshal(detail.Payload, &meta) == nil && meta.ActivationID == *old.Step.ActivationID {
saved = detail
break
}
}
if saved.ID == "" {
t.Fatal("historical activation event missing")
}
rearmWorkflowWait(t, f, v.ID, "timer")
after, err := api.GetEvent(ctx, f.token, v.ID, saved.ID)
if err != nil || after.EventSummary != saved.EventSummary || string(after.Payload) != string(saved.Payload) {
t.Fatal("old event replaced by recovery", err)
}
f.exec(`UPDATE weave_activations SET workflow_id=? WHERE namespace_id=? AND id=?`, executionID(t), f.resources.NamespaceID, *old.Step.ActivationID)
if _, err := api.GetEvent(ctx, f.token, v.ID, saved.ID); fault.Code(err) != "UNAVAILABLE" {
t.Fatal("foreign historical activation accepted", err)
}
})
}

View File

@@ -0,0 +1,157 @@
package mysql
import (
"context"
"database/sql"
"os"
"reflect"
"testing"
"time"
core "git.yuntuo.hk/k2safe/orbit/model"
driver "github.com/go-sql-driver/mysql"
)
// 用真实v24事件验证派生范围回填,历史内容不变,不凭空补造信号转换。
func TestIntegrationWorkflowTimelineUpgrade(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_timeline_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 > 24 {
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, owner := executionID(t), executionID(t)
type sample struct {
kind, payload string
aggregate core.ID
want *core.ID
}
cases := []sample{
{"workflow", `{"state":"WAITING"}`, owner, &owner},
{"workflow_step", `{"workflow_id":"` + string(owner) + `","state":"ACTIVE","n":9007199254740993}`, executionID(t), &owner},
{"workflow_signal", `{"workflow_id":"` + string(owner) + `","state":"CONSUMED"}`, executionID(t), &owner},
{"task", `{"workflow_id":"` + string(owner) + `"}`, executionID(t), nil},
{"workflow_step", `{"workflow_id":"gggggggggggggggggggggggggggggggg"}`, executionID(t), nil},
{"workflow_step", `{"workflow_id":"abc"}`, executionID(t), nil},
{"workflow_step", `{"workflow_id":null}`, executionID(t), nil},
{"workflow_step", `{"workflow_id":12345678901234567890123456789012}`, executionID(t), nil},
{"workflow_step", `{"state":"ACTIVE"}`, executionID(t), nil},
}
ids := make([]core.ID, len(cases))
for i, v := range cases {
ids[i] = executionID(t)
exec(`INSERT INTO orbit_events(namespace_id,id,aggregate_kind,aggregate_id,aggregate_revision,type,payload,occurred_at) VALUES (?,?,?,?,7,'legacy.event',?,'2026-01-02 03:04:05.123456')`, ns, ids[i], v.kind, v.aggregate, v.payload)
}
snapshot := func() [][]sql.NullString {
t.Helper()
rows, err := db.QueryContext(ctx, `SELECT namespace_id,id,aggregate_kind,aggregate_id,aggregate_revision,type,payload,occurred_at FROM orbit_events ORDER BY namespace_id,id`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var out [][]sql.NullString
for rows.Next() {
row := make([]sql.NullString, 8)
args := make([]any, 8)
for i := range row {
args[i] = &row[i]
}
if err := rows.Scan(args...); err != nil {
t.Fatal(err)
}
out = append(out, row)
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
return out
}
before := snapshot()
if err := Migrate(ctx, db); err != nil {
t.Fatal(err)
}
if err := CheckSchema(ctx, db); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(before, snapshot()) {
t.Fatal("timeline migration changed or invented historical events")
}
for i, v := range cases {
var got *core.ID
if err := db.QueryRowContext(ctx, `SELECT workflow_id FROM orbit_events WHERE namespace_id=? AND id=?`, ns, ids[i]).Scan(&got); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(v.want, got) {
t.Fatalf("case %d derived owner mismatch: want %v got %v", i, v.want, got)
}
}
var columns string
if err := db.QueryRowContext(ctx, `SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='orbit_events' AND INDEX_NAME='ix_event_workflow_history'`).Scan(&columns); err != nil || columns != "namespace_id,workflow_id,occurred_at,id" {
t.Fatal("timeline index mismatch", columns, err)
}
var foreignKeys int
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE()`).Scan(&foreignKeys); err != nil || foreignKeys != 0 {
t.Fatal("foreign keys forbidden", foreignKeys, err)
}
if _, err := db.ExecContext(ctx, `INSERT INTO orbit_events(namespace_id,id,aggregate_kind,aggregate_id,aggregate_revision,type,payload,occurred_at,workflow_id) VALUES (?,?,'workflow',?,1,'forbidden.override','{}',UTC_TIMESTAMP(6),?)`, ns, executionID(t), owner, owner); err == nil {
t.Fatal("generated scope accepted explicit override")
}
if err := Migrate(ctx, db); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(before, snapshot()) {
t.Fatal("repeated migration changed history")
}
t.Log("v24 -> v25: original fields and precision preserved, invalid scopes remain NULL, index ordered, generated scope protected, foreign keys=0")
}

View File

@@ -0,0 +1,27 @@
package httpserver
import (
"net/http"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
"git.yuntuo.hk/k2safe/orbit/weave/instance"
)
func registerWorkflowEvents(mux *http.ServeMux, s *instance.Service) {
mux.HandleFunc("GET /v1/workflows/{id}/events", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
in, err := historyQuery(r, true)
if err != nil {
return nil, 0, err
}
out, err := s.ListEvents(r.Context(), t, core.ID(r.PathValue("id")), in)
return out, 200, err
}))
mux.HandleFunc("GET /v1/workflows/{id}/events/{event}", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
if _, err := historyQuery(r, false); err != nil {
return nil, 0, err
}
out, err := s.GetEvent(r.Context(), t, core.ID(r.PathValue("id")), core.ID(r.PathValue("event")))
return out, 200, err
}))
}

View File

@@ -17,6 +17,7 @@ func RegisterWorkflows(server *http.Server, instances *instance.Service, control
mux := server.Handler.(*http.ServeMux)
registerWorkflowHistory(mux, instances, signals)
registerWorkflowQuery(mux, instances)
registerWorkflowEvents(mux, instances)
mux.HandleFunc("POST /v1/workflows", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
if _, err := historyQuery(r, false); err != nil {
return nil, 0, err