feat: expose workflow state events and persist signal transitions
This commit is contained in:
@@ -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`)
|
||||
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
97
internal/storage/mysql/workflow_signal_events.go
Normal file
97
internal/storage/mysql/workflow_signal_events.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
266
internal/storage/mysql/workflow_timeline.go
Normal file
266
internal/storage/mysql/workflow_timeline.go
Normal 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
|
||||
}
|
||||
319
internal/storage/mysql/workflow_timeline_integration_test.go
Normal file
319
internal/storage/mysql/workflow_timeline_integration_test.go
Normal 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 = ¤t
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user