feat: expose workflow state events and persist signal transitions
This commit is contained in:
@@ -189,6 +189,39 @@ func run() error {
|
||||
return errors.New("signal history missing")
|
||||
}
|
||||
fmt.Println("workflow_history_verified=true signal_state=CONSUMED")
|
||||
// 时间线用于审计展示,游标只是倒序翻页位置,不作为可靠消息消费水位。
|
||||
cursor, eventCount, consumed := "", 0, false
|
||||
for {
|
||||
page, err := client.ListWorkflowEvents(ctx, accepted.Instance.ID, orbit.HistoryPageRequest{Limit: 5, Cursor: cursor})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, event := range page.Items {
|
||||
eventCount++
|
||||
if event.AggregateKind == "workflow_signal" && event.AggregateID == sent.Signal.ID && event.State == "CONSUMED" {
|
||||
detail, err := client.GetWorkflowEvent(ctx, accepted.Instance.ID, event.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var transition struct {
|
||||
From string `json:"from"`
|
||||
ConsumedWaitID string `json:"consumed_wait_id"`
|
||||
}
|
||||
if json.Unmarshal(detail.Payload, &transition) != nil || transition.From != "PENDING" || transition.ConsumedWaitID != historical.Wait.ID {
|
||||
return errors.New("signal transition evidence mismatch")
|
||||
}
|
||||
consumed = true
|
||||
}
|
||||
}
|
||||
if page.NextCursor == "" {
|
||||
break
|
||||
}
|
||||
cursor = page.NextCursor
|
||||
}
|
||||
if !consumed {
|
||||
return errors.New("consumed signal event missing")
|
||||
}
|
||||
fmt.Printf("workflow_timeline_verified=true events=%d signal_transition=PENDING->CONSUMED\n", eventCount)
|
||||
|
||||
stop()
|
||||
err = <-done
|
||||
|
||||
88
sdk/go/workflow_events.go
Normal file
88
sdk/go/workflow_events.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package orbit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WorkflowEventSummary 只表示已保存的事件,不把当前状态或当前激活反填到历史。
|
||||
type WorkflowEventSummary struct {
|
||||
ID string `json:"id"` // ID 原不可变状态事件标识。
|
||||
WorkflowID string `json:"workflow_id"` // WorkflowID 已核验授权的流程归属。
|
||||
AggregateKind string `json:"aggregate_kind"` // AggregateKind workflow、workflow_step或workflow_signal。
|
||||
AggregateID string `json:"aggregate_id"` // AggregateID 产生事件的实例、步骤或信号身份。
|
||||
AggregateRevision uint64 `json:"aggregate_revision,string"` // AggregateRevision 来源资源版本,各资源独立,不是全局序号。
|
||||
Type string `json:"type"` // Type 原始事件类型,不按当前状态改写。
|
||||
StateScope string `json:"state_scope"` // StateScope WORKFLOW、STEP或SIGNAL,说明State记录的是哪类对象。
|
||||
State string `json:"state"` // State 该事件保存的状态,不代表对象当前状态。
|
||||
StepKey string `json:"step_key,omitempty"` // StepKey 步骤事件的固定定义名称;其他事件省略。
|
||||
OccurredAt time.Time `json:"occurred_at"` // OccurredAt 数据库事件记录时间,UTC微秒精度,不是外部业务发生时间或提交水位。
|
||||
}
|
||||
type WorkflowEventPage struct {
|
||||
WorkflowID string `json:"workflow_id"` // WorkflowID 本页授权的实例身份。
|
||||
Revision uint64 `json:"revision,string"` // Revision 本页读取时实例版本,不能作为信号或步骤事件的消费水位。
|
||||
Items []WorkflowEventSummary `json:"items"` // Items 按记录时间和ID倒序的有界摘要,空列表为[]。
|
||||
NextCursor string `json:"next_cursor,omitempty"` // NextCursor 绑定实例与事件查询种类,不承诺跨页快照。
|
||||
}
|
||||
type WorkflowEventDetail struct {
|
||||
WorkflowEventSummary
|
||||
Payload json.RawMessage `json:"payload"` // Payload 原不可变事件元数据,最多64KiB;嵌套数字保留原文本,输入/结果另走资源详情。
|
||||
}
|
||||
|
||||
// ListWorkflowEvents 读取实例、步骤和信号的不可变状态事件,需要workflows:audit:read。
|
||||
func (c *Client) ListWorkflowEvents(ctx context.Context, id string, in HistoryPageRequest) (WorkflowEventPage, error) {
|
||||
var out WorkflowEventPage
|
||||
if !validID(id) {
|
||||
return out, errors.New("invalid workflow id")
|
||||
}
|
||||
query, err := historyPageQuery(in)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err = c.call(ctx, "GET", "/v1/workflows/"+id+"/events"+query, "", nil, &out); err != nil {
|
||||
return WorkflowEventPage{}, err
|
||||
}
|
||||
if out.WorkflowID != id || out.Revision == 0 || out.Items == nil || !historyResponseSize(len(out.Items), in) {
|
||||
return WorkflowEventPage{}, errors.New("invalid workflow event page")
|
||||
}
|
||||
for i, e := range out.Items {
|
||||
if !validWorkflowEvent(e, id) || i > 0 && (e.OccurredAt.After(out.Items[i-1].OccurredAt) || e.OccurredAt.Equal(out.Items[i-1].OccurredAt) && e.ID >= out.Items[i-1].ID) {
|
||||
return WorkflowEventPage{}, errors.New("invalid workflow event summary or ordering")
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func validWorkflowEvent(e WorkflowEventSummary, id string) bool {
|
||||
if e.WorkflowID != id || !validID(e.ID) || !validID(e.AggregateID) || e.AggregateRevision == 0 || e.Type == "" || e.State == "" || e.OccurredAt.IsZero() {
|
||||
return false
|
||||
}
|
||||
switch e.AggregateKind {
|
||||
case "workflow":
|
||||
if e.AggregateID != id {
|
||||
return false
|
||||
}
|
||||
case "workflow_step":
|
||||
if !workflowVersionName(e.StepKey) {
|
||||
return false
|
||||
}
|
||||
case "workflow_signal":
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return e.StateScope == "WORKFLOW" || e.StateScope == "STEP" || e.StateScope == "SIGNAL"
|
||||
}
|
||||
func (c *Client) GetWorkflowEvent(ctx context.Context, id, event string) (WorkflowEventDetail, error) {
|
||||
var out WorkflowEventDetail
|
||||
if !validID(id) || !validID(event) {
|
||||
return out, errors.New("invalid workflow event identity")
|
||||
}
|
||||
if err := c.call(ctx, "GET", "/v1/workflows/"+id+"/events/"+event, "", nil, &out); err != nil {
|
||||
return WorkflowEventDetail{}, err
|
||||
}
|
||||
if out.ID != event || !validWorkflowEvent(out.WorkflowEventSummary, id) || len(out.Payload) == 0 || len(out.Payload) > 65536 || !json.Valid(out.Payload) {
|
||||
return WorkflowEventDetail{}, errors.New("invalid workflow event detail")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
80
sdk/go/workflow_events_integration_test.go
Normal file
80
sdk/go/workflow_events_integration_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package orbit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestIntegrationWorkflowEventsSDK(t *testing.T) {
|
||||
api, queue, _ := integrationWorkerClient(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
operation := workerTestID(t)
|
||||
spec := workflowSDKSpec(queue, "events."+operation)
|
||||
spec.Body = WorkflowBlock{Steps: []WorkflowStep{
|
||||
{Key: "receipt", Kind: "SIGNAL", Signal: &WorkflowSignal{Name: "done", PayloadVersion: "v1", Correlation: WorkflowValue{Kind: "LITERAL", Literal: json.RawMessage(`"` + operation + `"`)}, PayloadType: spec.InputType, TimeoutMS: 60000}},
|
||||
{Key: "delay", Kind: "TIMER", Timer: &WorkflowTimer{DelayMS: 60000}},
|
||||
}, OutputType: WorkflowType{Kind: "NULL"}, Output: WorkflowRef("delay")}
|
||||
v := startWorkflowSDK(t, ctx, api, spec)
|
||||
sent, err := api.SendWorkflowSignal(ctx, v.ID, operation, SendWorkflowSignalRequest{Name: "done", PayloadVersion: "v1", TargetStepKey: "receipt", TargetActivationSequence: 1, CorrelationKey: operation, Payload: workflowExact, Reason: "verify timeline"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitWorkflowStepSDK(t, ctx, api, v.ID, "receipt", func(d WorkflowStepDetail) bool { return d.Step.State == "SUCCEEDED" })
|
||||
current, err := api.GetWorkflow(ctx, v.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = api.ControlWorkflow(ctx, v.ID, operation, WorkflowControlRequest{Operation: "CANCEL", ExpectedControlRevision: current.ControlRevision, Reason: "timeline cleanup"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitWorkflowSDK(t, ctx, api, v.ID, "CANCELLED")
|
||||
seen := map[string]bool{}
|
||||
types := map[string]bool{}
|
||||
cursor := ""
|
||||
pages := 0
|
||||
for {
|
||||
page, err := api.ListWorkflowEvents(ctx, v.ID, HistoryPageRequest{Limit: 2, Cursor: cursor})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pages++
|
||||
raw, _ := json.Marshal(page)
|
||||
if strings.Contains(string(raw), `"payload"`) || strings.Contains(string(raw), `9007199254740993`) {
|
||||
t.Fatal("payload in summaries")
|
||||
}
|
||||
for _, e := range page.Items {
|
||||
if seen[e.ID] || e.WorkflowID != v.ID || e.StepKey == "$root" {
|
||||
t.Fatal("event identity", e)
|
||||
}
|
||||
seen[e.ID] = true
|
||||
types[e.Type] = true
|
||||
detail, err := api.GetWorkflowEvent(ctx, v.ID, e.ID)
|
||||
if err != nil || detail.WorkflowEventSummary != e {
|
||||
t.Fatal("detail", detail, err)
|
||||
}
|
||||
if e.AggregateKind == "workflow_signal" && (e.AggregateID != sent.Signal.ID || e.State != "CONSUMED" || e.StateScope != "SIGNAL") {
|
||||
t.Fatal("signal transition", e)
|
||||
}
|
||||
}
|
||||
if page.NextCursor == "" {
|
||||
break
|
||||
}
|
||||
cursor = page.NextCursor
|
||||
if pages > 20 {
|
||||
t.Fatal("unbounded page repetition")
|
||||
}
|
||||
}
|
||||
for _, kind := range []string{"workflow.started", "workflow.step.changed", "workflow.signal.accepted", "workflow.signal.changed", "workflow.controlled", "workflow.stopped"} {
|
||||
if !types[kind] {
|
||||
t.Fatal("event missing", kind, types)
|
||||
}
|
||||
}
|
||||
if len(seen) < 7 || pages < 4 {
|
||||
t.Fatal("incomplete timeline", len(seen), pages)
|
||||
}
|
||||
t.Logf("real HTTP event timeline: %d immutable events over %d pages; signal transition and cancelled workflow retained", len(seen), pages)
|
||||
}
|
||||
78
sdk/go/workflow_events_test.go
Normal file
78
sdk/go/workflow_events_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package orbit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWorkflowEventsSDKIdentityPrecisionAndBounds(t *testing.T) {
|
||||
const id = "11111111111111111111111111111111"
|
||||
const event = "22222222222222222222222222222222"
|
||||
var calls atomic.Int32
|
||||
var corrupt atomic.Bool
|
||||
summary := WorkflowEventSummary{ID: event, WorkflowID: id, AggregateKind: "workflow", AggregateID: id, AggregateRevision: 9007199254740993, Type: "workflow.advanced", StateScope: "WORKFLOW", State: "WAITING", OccurredAt: time.Date(2026, 9, 18, 0, 0, 0, 0, time.UTC)}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
if r.Method != "GET" || r.Header.Get("Authorization") != "Bearer token" || r.Header.Get("Idempotency-Key") != "" {
|
||||
t.Error("read request invalid")
|
||||
}
|
||||
item := summary
|
||||
if corrupt.Load() {
|
||||
item.WorkflowID = event
|
||||
}
|
||||
var data any
|
||||
if strings.HasSuffix(r.URL.Path, "/events") {
|
||||
if r.URL.Query().Get("limit") != "1" || r.URL.Query().Get("cursor") != "cursor+/=" {
|
||||
t.Error("cursor encoding changed")
|
||||
}
|
||||
data = WorkflowEventPage{WorkflowID: id, Revision: 9007199254740993, Items: []WorkflowEventSummary{item}}
|
||||
} else {
|
||||
data = WorkflowEventDetail{WorkflowEventSummary: item, Payload: json.RawMessage(`{"n":9007199254740993,"state":"WAITING"}`)}
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"request_id": event, "data": data})
|
||||
}))
|
||||
defer server.Close()
|
||||
c, err := NewClient(server.URL, Options{Token: "token"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
input := HistoryPageRequest{Limit: 1, Cursor: "cursor+/="}
|
||||
page, err := c.ListWorkflowEvents(context.Background(), id, input)
|
||||
if err != nil || page.Items[0].AggregateRevision != 9007199254740993 {
|
||||
t.Fatal(page, err)
|
||||
}
|
||||
detail, err := c.GetWorkflowEvent(context.Background(), id, event)
|
||||
if err != nil || !strings.Contains(string(detail.Payload), "9007199254740993") {
|
||||
t.Fatal(detail, err)
|
||||
}
|
||||
before := calls.Load()
|
||||
for _, bad := range []string{"", "../wrong", strings.Repeat("A", 32)} {
|
||||
if _, err := c.ListWorkflowEvents(context.Background(), bad, input); err == nil {
|
||||
t.Fatal("invalid parent")
|
||||
}
|
||||
if _, err := c.GetWorkflowEvent(context.Background(), id, bad); err == nil {
|
||||
t.Fatal("invalid event")
|
||||
}
|
||||
}
|
||||
for _, bad := range []HistoryPageRequest{{Limit: -1}, {Limit: 101}, {Cursor: strings.Repeat("x", 1025)}} {
|
||||
if _, err := c.ListWorkflowEvents(context.Background(), id, bad); err == nil {
|
||||
t.Fatal("invalid page")
|
||||
}
|
||||
}
|
||||
if calls.Load() != before {
|
||||
t.Fatal("invalid request reached HTTP")
|
||||
}
|
||||
corrupt.Store(true)
|
||||
if _, err := c.ListWorkflowEvents(context.Background(), id, input); err == nil {
|
||||
t.Fatal("foreign item accepted")
|
||||
}
|
||||
if _, err := c.GetWorkflowEvent(context.Background(), id, event); err == nil {
|
||||
t.Fatal("foreign detail accepted")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user