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

@@ -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
}