feat: expose bounded workflow activation and signal history
This commit is contained in:
@@ -167,6 +167,29 @@ func run() error {
|
||||
return errors.New("incomplete workflow task sources")
|
||||
}
|
||||
fmt.Printf("workflow_sources_verified=%d fixed_version=%s\n", verified, accepted.Instance.Version)
|
||||
history, err := client.ListWorkflowActivations(ctx, accepted.Instance.ID, "receipt", orbit.HistoryPageRequest{Limit: 20})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(history.Items) != 1 {
|
||||
return errors.New("unexpected receipt activation history")
|
||||
}
|
||||
historical, err := client.GetWorkflowActivation(ctx, accepted.Instance.ID, "receipt", history.Items[0].ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if historical.Wait == nil || historical.Wait.WinnerSignalID == nil || *historical.Wait.WinnerSignalID != sent.Signal.ID {
|
||||
return errors.New("historical signal winner missing")
|
||||
}
|
||||
signals, err := client.ListWorkflowSignals(ctx, accepted.Instance.ID, orbit.HistoryPageRequest{Limit: 20})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(signals.Items) != 1 || signals.Items[0].State != "CONSUMED" || signals.Items[0].ID != sent.Signal.ID {
|
||||
return errors.New("signal history missing")
|
||||
}
|
||||
fmt.Println("workflow_history_verified=true signal_state=CONSUMED")
|
||||
|
||||
stop()
|
||||
err = <-done
|
||||
waited = true
|
||||
|
||||
173
sdk/go/workflow_history.go
Normal file
173
sdk/go/workflow_history.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package orbit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
func workflowActivationPath(id, key string, activation ...string) (string, error) {
|
||||
if !validID(id) || !workflowVersionName(key) {
|
||||
return "", errors.New("invalid workflow step identity")
|
||||
}
|
||||
path := "/v1/workflows/" + id + "/steps/" + key + "/activations"
|
||||
for _, v := range activation {
|
||||
if !validID(v) {
|
||||
return "", errors.New("invalid activation id")
|
||||
}
|
||||
path += "/" + v
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
func workflowSignalPath(id string, signal ...string) (string, error) {
|
||||
if !validID(id) {
|
||||
return "", errors.New("invalid workflow id")
|
||||
}
|
||||
path := "/v1/workflows/" + id + "/signals"
|
||||
for _, v := range signal {
|
||||
if !validID(v) {
|
||||
return "", errors.New("invalid signal id")
|
||||
}
|
||||
path += "/" + v
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
func historyResponseSize(n int, in HistoryPageRequest) bool {
|
||||
limit := in.Limit
|
||||
if limit == 0 {
|
||||
limit = 20
|
||||
}
|
||||
return n <= limit
|
||||
}
|
||||
|
||||
// ListWorkflowActivations 返回指定逻辑步骤的各次激活摘要,代次倒序;跨页状态可继续变化。
|
||||
func (c *Client) ListWorkflowActivations(ctx context.Context, id, key string, in HistoryPageRequest) (WorkflowActivationPage, error) {
|
||||
var out WorkflowActivationPage
|
||||
path, err := workflowActivationPath(id, key)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
query, err := historyPageQuery(in)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err = c.call(ctx, "GET", path+query, "", nil, &out); err != nil {
|
||||
return WorkflowActivationPage{}, err
|
||||
}
|
||||
if out.InstanceID != id || !validID(out.StepID) || out.Revision == 0 || out.Items == nil || !historyResponseSize(len(out.Items), in) {
|
||||
return WorkflowActivationPage{}, errors.New("invalid activation page")
|
||||
}
|
||||
for i, a := range out.Items {
|
||||
if !validID(a.ID) || a.StepID != out.StepID || a.Sequence == 0 || a.Revision == 0 || i > 0 && a.Sequence >= out.Items[i-1].Sequence {
|
||||
return WorkflowActivationPage{}, errors.New("invalid activation summary")
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetWorkflowActivation 明确读取给定历史激活,Step仍为逻辑步骤当前视图,不能用它替代历史Activation。
|
||||
func (c *Client) GetWorkflowActivation(ctx context.Context, id, key, activation string) (WorkflowActivationDetail, error) {
|
||||
var out WorkflowActivationDetail
|
||||
path, err := workflowActivationPath(id, key, activation)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err = c.call(ctx, "GET", path, "", nil, &out); err != nil {
|
||||
return WorkflowActivationDetail{}, err
|
||||
}
|
||||
if out.InstanceID != id || out.Step.Key != key || out.Activation.ID != activation || !validID(out.Step.ID) || out.Activation.StepID != out.Step.ID || out.Activation.Sequence == 0 || out.Revision == 0 {
|
||||
return WorkflowActivationDetail{}, errors.New("activation detail identity mismatch")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListWorkflowPollRounds 只读取一个激活内的查询轮次;人工恢复的同一业务轮可分属不同激活。
|
||||
func (c *Client) ListWorkflowPollRounds(ctx context.Context, id, key, activation string, in HistoryPageRequest) (WorkflowPollRoundPage, error) {
|
||||
var out WorkflowPollRoundPage
|
||||
path, err := workflowActivationPath(id, key, activation)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
query, err := historyPageQuery(in)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err = c.call(ctx, "GET", path+"/rounds"+query, "", nil, &out); err != nil {
|
||||
return WorkflowPollRoundPage{}, err
|
||||
}
|
||||
if out.InstanceID != id || out.ActivationID != activation || out.Revision == 0 || out.Items == nil || !historyResponseSize(len(out.Items), in) {
|
||||
return WorkflowPollRoundPage{}, errors.New("invalid poll round page")
|
||||
}
|
||||
for i, r := range out.Items {
|
||||
if !validID(r.ID) || !validID(r.TaskID) || r.ActivationID != activation || r.Round == 0 || i > 0 && r.Round >= out.Items[i-1].Round {
|
||||
return WorkflowPollRoundPage{}, errors.New("invalid poll round summary")
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListWorkflowSignals 不携带信号正文;单条原始内容用GetWorkflowSignal读取。
|
||||
func (c *Client) ListWorkflowSignals(ctx context.Context, id string, in HistoryPageRequest) (WorkflowSignalPage, error) {
|
||||
var out WorkflowSignalPage
|
||||
path, err := workflowSignalPath(id)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
query, err := historyPageQuery(in)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err = c.call(ctx, "GET", path+query, "", nil, &out); err != nil {
|
||||
return WorkflowSignalPage{}, err
|
||||
}
|
||||
if out.WorkflowID != id || out.Revision == 0 || out.Items == nil || !historyResponseSize(len(out.Items), in) {
|
||||
return WorkflowSignalPage{}, errors.New("invalid signal page")
|
||||
}
|
||||
for _, v := range out.Items {
|
||||
if !validID(v.ID) || v.WorkflowID != id {
|
||||
return WorkflowSignalPage{}, errors.New("signal summary identity mismatch")
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListWorkflowSignalOperations 需要独立workflows:audit:read权限,只返回信号资源的操作审计摘要。
|
||||
func (c *Client) ListWorkflowSignalOperations(ctx context.Context, id, signal string, in HistoryPageRequest) (WorkflowSignalOperationPage, error) {
|
||||
var out WorkflowSignalOperationPage
|
||||
path, err := workflowSignalPath(id, signal)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
query, err := historyPageQuery(in)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err = c.call(ctx, "GET", path+"/operations"+query, "", nil, &out); err != nil {
|
||||
return WorkflowSignalOperationPage{}, err
|
||||
}
|
||||
if out.Items == nil || !historyResponseSize(len(out.Items), in) {
|
||||
return WorkflowSignalOperationPage{}, errors.New("invalid signal operation page")
|
||||
}
|
||||
for _, v := range out.Items {
|
||||
if !validID(v.ID) || v.WorkflowID != id || v.SignalID != signal {
|
||||
return WorkflowSignalOperationPage{}, errors.New("signal operation identity mismatch")
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (c *Client) GetWorkflowSignalOperation(ctx context.Context, id, signal, operation string) (WorkflowSignalOperation, error) {
|
||||
var out WorkflowSignalOperation
|
||||
path, err := workflowSignalPath(id, signal)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if !validID(operation) {
|
||||
return out, errors.New("invalid signal operation id")
|
||||
}
|
||||
if err = c.call(ctx, "GET", path+"/operations/"+operation, "", nil, &out); err != nil {
|
||||
return WorkflowSignalOperation{}, err
|
||||
}
|
||||
if out.ID != operation || out.WorkflowID != id || out.SignalID != signal {
|
||||
return WorkflowSignalOperation{}, errors.New("signal operation detail identity mismatch")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
89
sdk/go/workflow_history_test.go
Normal file
89
sdk/go/workflow_history_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package orbit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWorkflowHistoryIdentityBoundsAndResponseValidation(t *testing.T) {
|
||||
const id = "11111111111111111111111111111111"
|
||||
const child = "22222222222222222222222222222222"
|
||||
ctx := context.Background()
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
if r.Method != "GET" || r.Header.Get("Idempotency-Key") != "" || r.Header.Get("Authorization") != "Bearer test-token" {
|
||||
t.Error("invalid read request")
|
||||
}
|
||||
if r.URL.Query().Get("cursor") != "opaque+cursor/=" || r.URL.Query().Get("limit") != "1" {
|
||||
t.Error("query changed")
|
||||
}
|
||||
// 合法响应信封中的错误父资源,SDK不得将其作为请求资源的数据返回。
|
||||
json.NewEncoder(w).Encode(map[string]any{"request_id": id, "data": map[string]any{"instance_id": child, "workflow_id": child, "step_id": child, "revision": "1", "items": []any{}}})
|
||||
}))
|
||||
defer server.Close()
|
||||
c, err := NewClient(server.URL, Options{Token: "test-token"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, bad := range []string{"../other", strings.Repeat("A", 32), "a?x=b"} {
|
||||
if _, err = c.ListWorkflowActivations(ctx, bad, "work", HistoryPageRequest{}); err == nil {
|
||||
t.Fatal("bad workflow accepted")
|
||||
}
|
||||
if _, err = c.GetWorkflowActivation(ctx, id, "work", bad); err == nil {
|
||||
t.Fatal("bad activation accepted")
|
||||
}
|
||||
if _, err = c.ListWorkflowPollRounds(ctx, id, "work", bad, HistoryPageRequest{}); err == nil {
|
||||
t.Fatal("bad round scope accepted")
|
||||
}
|
||||
if _, err = c.ListWorkflowSignals(ctx, bad, HistoryPageRequest{}); err == nil {
|
||||
t.Fatal("bad signal parent accepted")
|
||||
}
|
||||
if _, err = c.ListWorkflowSignalOperations(ctx, id, bad, HistoryPageRequest{}); err == nil {
|
||||
t.Fatal("bad signal accepted")
|
||||
}
|
||||
if _, err = c.GetWorkflowSignalOperation(ctx, id, child, bad); err == nil {
|
||||
t.Fatal("bad operation accepted")
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"$root", "../other", "", strings.Repeat("x", 65)} {
|
||||
if _, err = c.ListWorkflowActivations(ctx, id, key, HistoryPageRequest{}); err == nil {
|
||||
t.Fatal("bad key accepted")
|
||||
}
|
||||
}
|
||||
for _, in := range []HistoryPageRequest{{Limit: -1}, {Limit: 101}, {Cursor: strings.Repeat("x", 1025)}} {
|
||||
if _, err = c.ListWorkflowActivations(ctx, id, "work", in); err == nil {
|
||||
t.Fatal("bad activation page")
|
||||
}
|
||||
if _, err = c.ListWorkflowPollRounds(ctx, id, "work", child, in); err == nil {
|
||||
t.Fatal("bad round page")
|
||||
}
|
||||
if _, err = c.ListWorkflowSignals(ctx, id, in); err == nil {
|
||||
t.Fatal("bad signal page")
|
||||
}
|
||||
if _, err = c.ListWorkflowSignalOperations(ctx, id, child, in); err == nil {
|
||||
t.Fatal("bad operation page")
|
||||
}
|
||||
}
|
||||
if calls.Load() != 0 {
|
||||
t.Fatal("invalid arguments reached transport")
|
||||
}
|
||||
page := HistoryPageRequest{Limit: 1, Cursor: "opaque+cursor/="}
|
||||
if _, err = c.ListWorkflowActivations(ctx, id, "work", page); err == nil {
|
||||
t.Fatal("foreign activation page accepted")
|
||||
}
|
||||
if _, err = c.ListWorkflowPollRounds(ctx, id, "work", child, page); err == nil {
|
||||
t.Fatal("foreign round page accepted")
|
||||
}
|
||||
if _, err = c.ListWorkflowSignals(ctx, id, page); err == nil {
|
||||
t.Fatal("foreign signal page accepted")
|
||||
}
|
||||
if calls.Load() != 3 {
|
||||
t.Fatal("unexpected automatic read retry")
|
||||
}
|
||||
}
|
||||
92
sdk/go/workflow_history_types.go
Normal file
92
sdk/go/workflow_history_types.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package orbit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WorkflowActivationSummary 描述指定历史激活的当前记录,不携带大输入/结果或执行凭据。
|
||||
type WorkflowActivationSummary struct {
|
||||
ID string `json:"id"` // ID 激活身份,人工恢复时新建且保留原记录。
|
||||
StepID string `json:"step_id"` // StepID 本激活所属逻辑步骤。
|
||||
Sequence uint32 `json:"sequence"` // Sequence 该步骤的激活代次,和POLL业务轮次分开。
|
||||
State string `json:"state"` // State 此激活状态,不代表实例当前状态。
|
||||
Revision uint64 `json:"revision,string"` // Revision 此激活记录的版本,保留整数精度。
|
||||
TaskID *string `json:"task_id,omitempty"` // TaskID TASK的直接任务;POLL从轮次列表查询。
|
||||
CreatedAt time.Time `json:"created_at"` // CreatedAt 激活创建时间,不代表恢复获得新截止。
|
||||
UpdatedAt time.Time `json:"updated_at"` // UpdatedAt 此记录最后变更时间。
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"` // CompletedAt 此激活已保存的结束时间;未记录时省略。
|
||||
}
|
||||
type WorkflowActivationPage struct {
|
||||
InstanceID string `json:"instance_id"` // InstanceID 已授权的所属实例。
|
||||
Revision uint64 `json:"revision,string"` // Revision 本页读取时的实例版本;跨页不保证同一快照。
|
||||
StepID string `json:"step_id"` // StepID 已核验的逻辑步骤。
|
||||
CurrentActivationID *string `json:"current_activation_id,omitempty"` // CurrentActivationID 本页读取时的当前激活,未激活时省略。
|
||||
Items []WorkflowActivationSummary `json:"items"` // Items 按代次倒序的有界摘要,空页为[]。
|
||||
NextCursor string `json:"next_cursor,omitempty"` // NextCursor 绑定实例、步骤和查询类型的后续位置。
|
||||
}
|
||||
type WorkflowActivationDetail struct {
|
||||
InstanceID string `json:"instance_id"` // InstanceID 所属实例。
|
||||
Revision uint64 `json:"revision,string"` // Revision 读取详情时的实例版本。
|
||||
Step WorkflowStepStatus `json:"step"` // Step 逻辑步骤的当前视图,不能替代Activation中的历史代次。
|
||||
Activation WorkflowActivationSummary `json:"activation"` // Activation 指定的历史激活,不自动改为当前激活。
|
||||
Input json.RawMessage `json:"input"` // Input 此激活原输入,显式null保留。
|
||||
Output json.RawMessage `json:"output,omitempty"` // Output 此激活当前保存的结果,缺失与显式null不同。
|
||||
Poll *WorkflowPollStatus `json:"poll,omitempty"` // Poll 此激活的查询预算及最后任务,只用于POLL。
|
||||
Wait *WorkflowWaitStatus `json:"wait,omitempty"` // Wait 此激活的等待记录,恢复不修改旧记录的截止。
|
||||
}
|
||||
type WorkflowPollRoundSummary struct {
|
||||
ID string `json:"id"` // ID 该任务唯一对应的轮次记录。
|
||||
ActivationID string `json:"activation_id"` // ActivationID 产生本次查询的激活。
|
||||
Round uint32 `json:"round"` // Round 从首次激活累计的业务轮编号;恢复同轮不重置。
|
||||
TaskID string `json:"task_id"` // TaskID 该轮Pulse任务,尝试和结果由任务接口读取。
|
||||
CreatedAt time.Time `json:"created_at"` // CreatedAt 本次轮次记录创建时间。
|
||||
}
|
||||
type WorkflowPollRoundPage struct {
|
||||
InstanceID string `json:"instance_id"` // InstanceID 所属实例。
|
||||
Revision uint64 `json:"revision,string"` // Revision 本页读取时的实例版本。
|
||||
ActivationID string `json:"activation_id"` // ActivationID 明确指定的历史激活。
|
||||
Items []WorkflowPollRoundSummary `json:"items"` // Items 本激活内按业务轮倒序的摘要,空页为[]。
|
||||
NextCursor string `json:"next_cursor,omitempty"` // NextCursor 绑定实例、激活及查询类型的后续位置。
|
||||
}
|
||||
|
||||
// WorkflowSignalSummary 不聚合信号正文、关联键或证据;单条Get用于读取完整原始信号。
|
||||
type WorkflowSignalSummary struct {
|
||||
ID string `json:"id"` // ID 持久信号身份。
|
||||
WorkflowID string `json:"workflow_id"` // WorkflowID 所属实例。
|
||||
Revision uint64 `json:"revision,string"` // Revision 该信号当前记录版本。
|
||||
Name string `json:"name"` // Name 原信号类型名称。
|
||||
PayloadVersion string `json:"payload_version"` // PayloadVersion 固定内容契约;未核验旧数据可能为空。
|
||||
TargetStepKey string `json:"target_step_key"` // TargetStepKey 原目标步骤名称,不跟随当前步骤变更。
|
||||
TargetActivationSequence uint32 `json:"target_activation_sequence"` // TargetActivationSequence 原指定代次,未核验旧数据可能为0。
|
||||
State string `json:"state"` // State 当前PENDING、CONSUMED、EXPIRED或REJECTED。
|
||||
Disposition string `json:"disposition,omitempty"` // Disposition 已记录的消费或拒绝原因。
|
||||
AcceptedAt time.Time `json:"accepted_at"` // AcceptedAt 原受理时间,也是分页排序依据。
|
||||
ExpiresAt time.Time `json:"expires_at"` // ExpiresAt 原固定截止,不因读取或重报延长。
|
||||
}
|
||||
type WorkflowSignalPage struct {
|
||||
WorkflowID string `json:"workflow_id"` // WorkflowID 已授权实例。
|
||||
Revision uint64 `json:"revision,string"` // Revision 本页读取时的实例版本;跨页状态可变化。
|
||||
Items []WorkflowSignalSummary `json:"items"` // Items 按受理时间及ID倒序的摘要,空页为[]。
|
||||
NextCursor string `json:"next_cursor,omitempty"` // NextCursor 绑定命名空间及实例的后续位置。
|
||||
}
|
||||
type WorkflowSignalOperationSummary struct {
|
||||
ID string `json:"id"` // ID 不可改写的操作审计身份。
|
||||
WorkflowID string `json:"workflow_id"` // WorkflowID 经信号归属验证的实例。
|
||||
SignalID string `json:"signal_id"` // SignalID 本次操作对应的信号资源。
|
||||
OperationID string `json:"operation_id"` // OperationID 服务端操作身份,不是原始幂等键。
|
||||
ActorID string `json:"actor_id"` // ActorID 已认证的实际操作主体。
|
||||
Action string `json:"action"` // Action 操作种类,如workflow.signal。
|
||||
CreatedAt time.Time `json:"created_at"` // CreatedAt 原操作创建时间。
|
||||
}
|
||||
type WorkflowSignalOperation struct {
|
||||
WorkflowSignalOperationSummary
|
||||
Reason string `json:"reason"` // Reason 原操作原因。
|
||||
EvidenceRef string `json:"evidence_ref,omitempty"` // EvidenceRef 原证据引用,不保存外部秘密。
|
||||
Before json.RawMessage `json:"before"` // Before 操作前摘要,首次受理可为null。
|
||||
After json.RawMessage `json:"after"` // After 原受理身份、摘要、时间和当时状态,不替代当前消费状态。
|
||||
}
|
||||
type WorkflowSignalOperationPage struct {
|
||||
Items []WorkflowSignalOperationSummary `json:"items"` // Items 按创建时间和ID倒序的有界审计摘要。
|
||||
NextCursor string `json:"next_cursor,omitempty"` // NextCursor 绑定实例、信号及查询类型的后续位置。
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user