feat: trace task sources to fixed workflow activations and rounds
This commit is contained in:
@@ -145,6 +145,28 @@ func run() error {
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
steps, err := client.GetWorkflowSteps(ctx, accepted.Instance.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
verified := 0
|
||||
for _, step := range steps.Steps {
|
||||
if step.TaskID == nil {
|
||||
continue
|
||||
}
|
||||
source, err := client.GetTaskSource(ctx, *step.TaskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if source.Workflow == nil || source.Workflow.WorkflowID != accepted.Instance.ID || source.Workflow.DefinitionVersionID != accepted.Instance.DefinitionVersionID || source.Workflow.StepID != step.ID || source.Workflow.ActivationID != *step.ActivationID || source.Workflow.ActivationSequence != 1 {
|
||||
return errors.New("workflow task source mismatch")
|
||||
}
|
||||
verified++
|
||||
}
|
||||
if verified != 3 {
|
||||
return errors.New("incomplete workflow task sources")
|
||||
}
|
||||
fmt.Printf("workflow_sources_verified=%d fixed_version=%s\n", verified, accepted.Instance.Version)
|
||||
stop()
|
||||
err = <-done
|
||||
waited = true
|
||||
|
||||
@@ -10,12 +10,30 @@ import (
|
||||
type TaskSource struct {
|
||||
// TaskID 本次已授权查询的任务标识。
|
||||
TaskID string `json:"task_id"`
|
||||
// Kind 当前支持DIRECT、MANUAL_RETRY、SCHEDULE;分别是普通受理、人工派生、计划触发。
|
||||
// Kind DIRECT、MANUAL_RETRY、SCHEDULE或WORKFLOW;分别是普通受理、人工派生、计划触发或流程步骤。
|
||||
Kind string `json:"kind"`
|
||||
// RetryOf 人工重试的直接原任务,限同一命名空间及队列;其他种类省略。
|
||||
RetryOf *string `json:"retry_of,omitempty"`
|
||||
// Schedule 计划触发的固定来源,仅SCHEDULE返回,不包含任务模板或计划配置。
|
||||
Schedule *TaskScheduleSource `json:"schedule,omitempty"`
|
||||
// Workflow 流程任务的固定来源,不返回其他步骤、业务输入或完整定义。
|
||||
Workflow *TaskWorkflowSource `json:"workflow,omitempty"`
|
||||
}
|
||||
|
||||
// TaskWorkflowSource 指向任务自己的历史激活;人工恢复不会把旧任务改指向当前激活。
|
||||
type TaskWorkflowSource struct {
|
||||
WorkflowID string `json:"workflow_id"` // WorkflowID 所属流程实例。
|
||||
DefinitionID string `json:"definition_id"` // DefinitionID 稳定定义身份。
|
||||
DefinitionVersionID string `json:"definition_version_id"` // DefinitionVersionID 实例固定的不可变版本记录。
|
||||
Version string `json:"version"` // Version 固定发布版本名,不跟随新发布版本。
|
||||
StepID string `json:"step_id"` // StepID 实例内逻辑步骤身份。
|
||||
StepKey string `json:"step_key"` // StepKey 固定定义中的步骤名称。
|
||||
StepKind string `json:"step_kind"` // StepKind TASK或POLL,不返回内部根或等待节点。
|
||||
ActivationID string `json:"activation_id"` // ActivationID 创建此任务的激活身份,可能已经是历史代次。
|
||||
ActivationSequence uint32 `json:"activation_sequence"` // ActivationSequence 此步骤的激活代次,从1开始。
|
||||
PollRound uint32 `json:"poll_round,omitempty"` // PollRound 原业务查询轮编号,跨激活恢复不重置;TASK省略。
|
||||
PollRoundID *string `json:"poll_round_id,omitempty"` // PollRoundID 当前任务对应的唯一轮次记录;TASK省略。
|
||||
RetryOf *string `json:"retry_of,omitempty"` // RetryOf 人工恢复所关联的直接旧任务,限同一流程/步骤及业务轮次,不递归展开。
|
||||
}
|
||||
|
||||
// TaskScheduleSource 关联已生成任务的触发记录及当时的不可变计划版本。
|
||||
@@ -34,7 +52,7 @@ type TaskScheduleSource struct {
|
||||
CoveredUntil *time.Time `json:"covered_until,omitempty"`
|
||||
}
|
||||
|
||||
// GetTaskSource 查询直接来源;不会授予来源计划的读取/管理或任务执行权限。
|
||||
// GetTaskSource 查询直接来源;不会授予来源计划/流程的读取、管理或执行权限。
|
||||
func (c *Client) GetTaskSource(ctx context.Context, id string) (TaskSource, error) {
|
||||
var out TaskSource
|
||||
if !validID(id) {
|
||||
@@ -49,14 +67,24 @@ func (c *Client) GetTaskSource(ctx context.Context, id string) (TaskSource, erro
|
||||
valid := false
|
||||
switch out.Kind {
|
||||
case "DIRECT":
|
||||
valid = out.RetryOf == nil && out.Schedule == nil
|
||||
valid = out.RetryOf == nil && out.Schedule == nil && out.Workflow == nil
|
||||
case "MANUAL_RETRY":
|
||||
valid = out.RetryOf != nil && validID(*out.RetryOf) && *out.RetryOf != id && out.Schedule == nil
|
||||
valid = out.RetryOf != nil && validID(*out.RetryOf) && *out.RetryOf != id && out.Schedule == nil && out.Workflow == nil
|
||||
case "SCHEDULE":
|
||||
s := out.Schedule
|
||||
if s != nil && out.RetryOf == nil && validID(s.ScheduleID) && validID(s.OccurrenceID) && s.ScheduleVersion > 0 && validSourceTime(s.ScheduledAt) {
|
||||
if s != nil && out.RetryOf == nil && out.Workflow == nil && validID(s.ScheduleID) && validID(s.OccurrenceID) && s.ScheduleVersion > 0 && validSourceTime(s.ScheduledAt) {
|
||||
valid = s.Decision == "FIRED" && s.CoveredUntil == nil || s.Decision == "COALESCED" && s.CoveredUntil != nil && validSourceTime(*s.CoveredUntil) && s.CoveredUntil.After(s.ScheduledAt)
|
||||
}
|
||||
case "WORKFLOW":
|
||||
w := out.Workflow
|
||||
if w != nil && out.RetryOf == nil && out.Schedule == nil && validID(w.WorkflowID) && validID(w.DefinitionID) && validID(w.DefinitionVersionID) && workflowVersionName(w.Version) && validID(w.StepID) && workflowVersionName(w.StepKey) && validID(w.ActivationID) && w.ActivationSequence > 0 {
|
||||
valid = w.StepKind == "TASK" && w.PollRound == 0 && w.PollRoundID == nil || w.StepKind == "POLL" && w.PollRound > 0 && w.PollRoundID != nil && validID(*w.PollRoundID)
|
||||
if w.RetryOf != nil {
|
||||
valid = valid && w.ActivationSequence > 1 && validID(*w.RetryOf) && *w.RetryOf != id
|
||||
} else if w.StepKind == "TASK" && w.ActivationSequence > 1 {
|
||||
valid = false
|
||||
}
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
return TaskSource{}, errors.New("invalid or unsupported task source response")
|
||||
|
||||
@@ -39,8 +39,43 @@ func TestTaskSourceValidationAndPrecision(t *testing.T) {
|
||||
t.Fatal("version precision lost")
|
||||
}
|
||||
}
|
||||
workflow := TaskWorkflowSource{WorkflowID: parent, DefinitionID: parent, DefinitionVersionID: occurrence, Version: "v1", StepID: parent, StepKey: "query", StepKind: "TASK", ActivationID: occurrence, ActivationSequence: 1}
|
||||
for _, kind := range []string{"TASK", "POLL"} {
|
||||
for _, sequence := range []uint32{1, 2} {
|
||||
w := workflow
|
||||
w.StepKind, w.ActivationSequence = kind, sequence
|
||||
if sequence == 2 {
|
||||
w.RetryOf = &p
|
||||
}
|
||||
if kind == "POLL" {
|
||||
w.PollRound, w.PollRoundID = 2, &p
|
||||
}
|
||||
reply.Store(TaskSource{TaskID: task, Kind: "WORKFLOW", Workflow: &w})
|
||||
if _, err := client.GetTaskSource(context.Background(), task); err != nil {
|
||||
t.Fatal("valid workflow source", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
self := task
|
||||
invalid := []TaskSource{{TaskID: parent, Kind: "DIRECT"}, {TaskID: task, Kind: "unknown"}, {TaskID: task, Kind: "DIRECT", RetryOf: &p}, {TaskID: task, Kind: "MANUAL_RETRY"}, {TaskID: task, Kind: "MANUAL_RETRY", RetryOf: &self}, {TaskID: task, Kind: "SCHEDULE"}}
|
||||
invalid = append(invalid, TaskSource{TaskID: task, Kind: "WORKFLOW"}, TaskSource{TaskID: task, Kind: "DIRECT", Workflow: &workflow}, TaskSource{TaskID: task, Kind: "WORKFLOW", Workflow: &workflow, RetryOf: &p})
|
||||
for _, change := range []func(*TaskWorkflowSource){
|
||||
func(w *TaskWorkflowSource) { w.WorkflowID = "invalid" },
|
||||
func(w *TaskWorkflowSource) { w.Version = "../version" },
|
||||
func(w *TaskWorkflowSource) { w.StepKey = "$root" },
|
||||
func(w *TaskWorkflowSource) { w.StepKind = "SIGNAL" },
|
||||
func(w *TaskWorkflowSource) { w.ActivationSequence = 0 },
|
||||
func(w *TaskWorkflowSource) { w.ActivationSequence = 2 },
|
||||
func(w *TaskWorkflowSource) { w.RetryOf = &p },
|
||||
func(w *TaskWorkflowSource) { w.ActivationSequence = 2; w.RetryOf = &self },
|
||||
func(w *TaskWorkflowSource) { w.PollRound = 1 },
|
||||
func(w *TaskWorkflowSource) { w.PollRoundID = &p },
|
||||
func(w *TaskWorkflowSource) { w.StepKind = "POLL"; w.PollRound = 1 },
|
||||
} {
|
||||
w := workflow
|
||||
change(&w)
|
||||
invalid = append(invalid, TaskSource{TaskID: task, Kind: "WORKFLOW", Workflow: &w})
|
||||
}
|
||||
for _, change := range []func(*TaskScheduleSource){func(v *TaskScheduleSource) { v.ScheduleVersion = 0 }, func(v *TaskScheduleSource) { v.Decision = "SKIPPED" }, func(v *TaskScheduleSource) { v.Decision = "COALESCED" }, func(v *TaskScheduleSource) { v.CoveredUntil = &until }, func(v *TaskScheduleSource) { v.ScheduledAt = time.Time{} }, func(v *TaskScheduleSource) { v.OccurrenceID = "invalid" }} {
|
||||
v := valid[2]
|
||||
s := *v.Schedule
|
||||
|
||||
@@ -137,6 +137,18 @@ func workflowAPIError(t *testing.T, err error, code string) {
|
||||
}
|
||||
}
|
||||
|
||||
func assertWorkflowSourceSDK(t *testing.T, ctx context.Context, api *Client, v WorkflowInstance, task string, sequence, round uint32, parent string) {
|
||||
t.Helper()
|
||||
source, err := api.GetTaskSource(ctx, task)
|
||||
if err != nil || source.Workflow == nil || source.Kind != "WORKFLOW" {
|
||||
t.Fatal("workflow source", err)
|
||||
}
|
||||
w := source.Workflow
|
||||
if w.WorkflowID != v.ID || w.DefinitionID != v.DefinitionID || w.DefinitionVersionID != v.DefinitionVersionID || w.Version != v.Version || w.ActivationSequence != sequence || w.PollRound != round || parent == "" && w.RetryOf != nil || parent != "" && (w.RetryOf == nil || *w.RetryOf != parent) {
|
||||
t.Fatal("fixed source mismatch", w)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationWorkflowInstancesSDK(t *testing.T) {
|
||||
api, queue, token := integrationWorkerClient(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
@@ -212,6 +224,7 @@ func TestIntegrationWorkflowInstancesSDK(t *testing.T) {
|
||||
w := workflowWorkerSDK(t, ctx, api, queue, handler)
|
||||
defer w.Close()
|
||||
first := finishWorkflowSDK(t, ctx, w, WorkerSucceeded, workflowExact)
|
||||
assertWorkflowSourceSDK(t, ctx, api, started.Instance, first.Task.ID, 1, 0, "")
|
||||
work, err := api.GetWorkflowStep(ctx, id, "work")
|
||||
if err != nil || work.Step.TaskID == nil || *work.Step.TaskID != first.Task.ID || work.Step.ActivationSequence != 1 {
|
||||
t.Fatal("step metadata", err)
|
||||
@@ -349,6 +362,8 @@ func TestIntegrationWorkflowInstancesSDK(t *testing.T) {
|
||||
if err != nil || !repeated.Deduplicated || repeated.Task.ID != child.Task.ID {
|
||||
t.Fatal(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)
|
||||
a := finishWorkflowSDK(t, ctx, w, WorkerSucceeded, workflowExact)
|
||||
if a.Task.ID != child.Task.ID {
|
||||
@@ -374,6 +389,7 @@ func TestIntegrationWorkflowInstancesSDK(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertWorkflowSourceSDK(t, ctx, api, v, source.ID, 1, 1, "")
|
||||
reviewed, err := api.ReviewWorkflowTask(ctx, v.ID, workerTestID(t), ReviewWorkflowTaskRequest{WorkflowRecoveryDecision: workflowDecision(step).WorkflowRecoveryDecision, TaskID: source.ID, ExpectedTaskRevision: source.Revision, ExecutionSettled: true, Outcome: TaskSucceeded, Result: json.RawMessage(`"PENDING"`)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -390,13 +406,36 @@ func TestIntegrationWorkflowInstancesSDK(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
continueWorkflowSDK(t, ctx, api, v.ID)
|
||||
finishWorkflowSDK(t, ctx, w, WorkerSucceeded, json.RawMessage(`"DONE"`))
|
||||
last := finishWorkflowSDK(t, ctx, w, WorkerSucceeded, json.RawMessage(`"DONE"`))
|
||||
assertWorkflowSourceSDK(t, ctx, api, v, last.Task.ID, 1, 2, "")
|
||||
assertWorkflowSourceSDK(t, ctx, api, v, source.ID, 1, 1, "")
|
||||
waitWorkflowSDK(t, ctx, api, v.ID, "SUCCEEDED")
|
||||
final, err := api.GetWorkflowStep(ctx, v.ID, "query")
|
||||
if err != nil || final.Poll.UsedRounds != 2 || final.Poll.DeadlineAt != step.Poll.DeadlineAt {
|
||||
t.Fatal("poll budget changed", err)
|
||||
}
|
||||
})
|
||||
t.Run("poll_recovery_source_keeps_business_round", func(t *testing.T) {
|
||||
spec := workflowSDKSpec(queue, handler)
|
||||
task := *spec.Body.Steps[0].Task
|
||||
task.ResultType = WorkflowType{Kind: "STRING", Enum: []string{"PENDING", "DONE"}}
|
||||
spec.Body = WorkflowBlock{Steps: []WorkflowStep{{Key: "query", Kind: "POLL", Poll: &WorkflowPoll{Task: task, MaxRounds: 3, TimeoutMS: 60000, IntervalMS: 1000, Pending: []string{"PENDING"}, Succeeded: []string{"DONE"}}}}, OutputType: task.ResultType, Output: WorkflowRef("query")}
|
||||
v := startWorkflowSDK(t, ctx, api, spec)
|
||||
w := workflowWorkerSDK(t, ctx, api, queue, handler)
|
||||
defer w.Close()
|
||||
old := finishWorkflowSDK(t, ctx, w, WorkerFailed, json.RawMessage(`"failure"`))
|
||||
step := waitWorkflowStepSDK(t, ctx, api, v.ID, "query", func(v WorkflowStepDetail) bool { return v.Step.State == "NEEDS_REVIEW" })
|
||||
child, err := api.RetryWorkflowStep(ctx, v.ID, workerTestID(t), RetryWorkflowStepRequest{WorkflowStepDecision: workflowDecision(step), SafeToRetry: true})
|
||||
if err != nil || child.Task == nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertWorkflowSourceSDK(t, ctx, api, v, child.Task.ID, 2, 1, old.Task.ID)
|
||||
assertWorkflowSourceSDK(t, ctx, api, v, old.Task.ID, 1, 1, "")
|
||||
continueWorkflowSDK(t, ctx, api, v.ID)
|
||||
finishWorkflowSDK(t, ctx, w, WorkerSucceeded, json.RawMessage(`"DONE"`))
|
||||
waitWorkflowSDK(t, ctx, api, v.ID, "SUCCEEDED")
|
||||
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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user