feat: expose bounded workflow activation and signal history

This commit is contained in:
2026-09-18 07:56:18 +08:00
parent ae0d08584a
commit 17a8e0126c
35 changed files with 1756 additions and 59 deletions

View File

@@ -202,6 +202,13 @@ func TestIntegrationWorkflowInstancesSDK(t *testing.T) {
t.Fatal("start receipt", err)
}
id := started.Instance.ID
// 所有新增历史路由必须拒绝未声明参数,不能悄悄扩大读取范围。
for _, suffix := range []string{"/steps/work/activations", "/steps/work/activations/" + id, "/steps/work/activations/" + id + "/rounds", "/signals", "/signals/" + id + "/operations", "/signals/" + id + "/operations/" + id} {
var unused any
err := api.call(ctx, "GET", "/v1/workflows/"+id+suffix+"?unknown=1", "", nil, &unused)
workflowAPIError(t, err, "INVALID_ARGUMENT")
}
before, err := api.GetWorkflowSteps(ctx, id)
if err != nil || len(before.Steps) != 4 {
t.Fatal(err)
@@ -256,6 +263,27 @@ func TestIntegrationWorkflowInstancesSDK(t *testing.T) {
if err != nil || receipt.State != "CONSUMED" {
t.Fatal(err)
}
signals, err := api.ListWorkflowSignals(ctx, id, HistoryPageRequest{Limit: 1})
if err != nil || len(signals.Items) != 1 || signals.Items[0].ID != accepted.Signal.ID || signals.Items[0].State != "CONSUMED" {
t.Fatal("signal history", err)
}
operations, err := api.ListWorkflowSignalOperations(ctx, id, accepted.Signal.ID, HistoryPageRequest{Limit: 1})
if err != nil || len(operations.Items) != 1 {
t.Fatal("signal operations", err)
}
operation, err := api.GetWorkflowSignalOperation(ctx, id, accepted.Signal.ID, operations.Items[0].ID)
if err != nil || operation.Reason != signal.Reason || operation.Action != "workflow.signal" {
t.Fatal("signal operation detail", err)
}
historical, err := api.GetWorkflowStep(ctx, id, "receipt")
if err != nil {
t.Fatal(err)
}
waitHistory, err := api.GetWorkflowActivation(ctx, id, "receipt", *historical.Step.ActivationID)
if err != nil || waitHistory.Wait.WinnerSignalID == nil || *waitHistory.Wait.WinnerSignalID != accepted.Signal.ID {
t.Fatal("historical winner", err)
}
old, err := api.StartWorkflow(ctx, key, in)
if err != nil || old.Instance.Revision != started.Instance.Revision || old.Instance.DeadlineAt != started.Instance.DeadlineAt {
t.Fatal("start receipt changed", err)
@@ -344,6 +372,12 @@ func TestIntegrationWorkflowInstancesSDK(t *testing.T) {
if err != nil || current.Wait.DeadlineAt != old.Wait.DeadlineAt {
t.Fatal("wait extended", err)
}
priorWait, err := api.GetWorkflowActivation(ctx, v.ID, "receipt", *old.Step.ActivationID)
if err != nil || priorWait.Activation.Sequence != 1 || priorWait.Step.ActivationSequence != 2 || priorWait.Wait.DeadlineAt != old.Wait.DeadlineAt || priorWait.Wait.ID != old.Wait.ID {
t.Fatal("wait history retargeted", err)
}
assertTwoActivationPagesSDK(t, ctx, api, v.ID, "receipt", *old.Step.ActivationID, *restored.Step.ActivationID)
stale, err := api.SendWorkflowSignal(ctx, v.ID, workerTestID(t), SendWorkflowSignalRequest{Name: "done", PayloadVersion: "v1", TargetStepKey: "receipt", TargetActivationSequence: 1, CorrelationKey: "shared", Payload: workflowExact, Reason: "late prior generation"})
if err != nil || stale.Signal.State != "REJECTED" {
t.Fatal("old signal not fenced", err)
@@ -362,6 +396,12 @@ func TestIntegrationWorkflowInstancesSDK(t *testing.T) {
if err != nil || !repeated.Deduplicated || repeated.Task.ID != child.Task.ID {
t.Fatal(err)
}
assertTwoActivationPagesSDK(t, ctx, api, v.ID, "work", *step.Step.ActivationID, *child.Step.ActivationID)
oldTask, err := api.GetWorkflowActivation(ctx, v.ID, "work", *step.Step.ActivationID)
if err != nil || oldTask.Activation.TaskID == nil || *oldTask.Activation.TaskID != *child.Task.RetryOf || oldTask.Activation.State != "FAILED" {
t.Fatal("old task activation", err)
}
assertWorkflowSourceSDK(t, ctx, api, v, child.Task.ID, 2, 0, *child.Task.RetryOf)
assertWorkflowSourceSDK(t, ctx, api, v, *child.Task.RetryOf, 1, 0, "")
continueWorkflowSDK(t, ctx, api, v.ID)
@@ -414,6 +454,15 @@ func TestIntegrationWorkflowInstancesSDK(t *testing.T) {
if err != nil || final.Poll.UsedRounds != 2 || final.Poll.DeadlineAt != step.Poll.DeadlineAt {
t.Fatal("poll budget changed", err)
}
roundPage, err := api.ListWorkflowPollRounds(ctx, v.ID, "query", *final.Step.ActivationID, HistoryPageRequest{Limit: 1})
if err != nil || len(roundPage.Items) != 1 || roundPage.Items[0].Round != 2 || roundPage.Items[0].TaskID != last.Task.ID || roundPage.NextCursor == "" {
t.Fatal("round page", err)
}
priorRound, err := api.ListWorkflowPollRounds(ctx, v.ID, "query", *final.Step.ActivationID, HistoryPageRequest{Limit: 1, Cursor: roundPage.NextCursor})
if err != nil || len(priorRound.Items) != 1 || priorRound.Items[0].Round != 1 || priorRound.Items[0].TaskID != source.ID || priorRound.NextCursor != "" {
t.Fatal("prior round", err)
}
})
t.Run("poll_recovery_source_keeps_business_round", func(t *testing.T) {
spec := workflowSDKSpec(queue, handler)
@@ -434,9 +483,31 @@ func TestIntegrationWorkflowInstancesSDK(t *testing.T) {
continueWorkflowSDK(t, ctx, api, v.ID)
finishWorkflowSDK(t, ctx, w, WorkerSucceeded, json.RawMessage(`"DONE"`))
waitWorkflowSDK(t, ctx, api, v.ID, "SUCCEEDED")
assertTwoActivationPagesSDK(t, ctx, api, v.ID, "query", *step.Step.ActivationID, *child.Step.ActivationID)
for _, item := range []struct{ activation, task string }{{*step.Step.ActivationID, old.Task.ID}, {*child.Step.ActivationID, child.Task.ID}} {
rounds, err := api.ListWorkflowPollRounds(ctx, v.ID, "query", item.activation, HistoryPageRequest{})
if err != nil || len(rounds.Items) != 1 || rounds.Items[0].Round != 1 || rounds.Items[0].TaskID != item.task {
t.Fatal("recovery same business round", err)
}
}
assertWorkflowSourceSDK(t, ctx, api, v, child.Task.ID, 2, 1, old.Task.ID)
})
if !t.Failed() {
t.Log("public workflow start/query/control/signal, all six recovery commands, audit and real Worker execution verified without server imports")
}
}
func assertTwoActivationPagesSDK(t *testing.T, ctx context.Context, api *Client, id, key, old, current string) {
t.Helper()
first, err := api.ListWorkflowActivations(ctx, id, key, HistoryPageRequest{Limit: 1})
if err != nil || len(first.Items) != 1 || first.Items[0].ID != current || first.Items[0].Sequence != 2 || first.NextCursor == "" {
t.Fatal("current activation page", err)
}
second, err := api.ListWorkflowActivations(ctx, id, key, HistoryPageRequest{Limit: 1, Cursor: first.NextCursor})
if err != nil || len(second.Items) != 1 || second.Items[0].ID != old || second.Items[0].Sequence != 1 || second.NextCursor != "" {
t.Fatal("old activation page", err)
}
_, err = api.ListWorkflowSignals(ctx, id, HistoryPageRequest{Cursor: first.NextCursor})
workflowAPIError(t, err, "INVALID_ARGUMENT")
}