feat: trace task sources to fixed workflow activations and rounds

This commit is contained in:
2026-09-18 07:14:55 +08:00
parent b9e4191c6b
commit ae0d08584a
27 changed files with 593 additions and 47 deletions

View File

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

View File

@@ -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")

View File

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

View File

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