feat: recover workflow waits within original deadlines

This commit is contained in:
2026-09-18 06:10:04 +08:00
parent 56b9a2ccfe
commit af1ae4cb21
25 changed files with 941 additions and 52 deletions

View File

@@ -19,6 +19,9 @@ import (
const Action = "workflows:recover"
// MaxWaitActivations 包含首次等待,约束人工换代次数,防止无执行尝试的无限重建。
const MaxWaitActivations uint32 = 32
// Decision 核查基于整个实例的最新证据版本;后台新事实会使旧决定失效,原键仍可恢复回执。
type Decision struct {
ExpectedRevision uint64 `json:"expected_revision,string"` // ExpectedRevision 当前实例版本,不能使用control_revision代替。
@@ -59,6 +62,16 @@ type RetryStepInput struct {
SafeToRetry bool `json:"safe_to_retry" required:"true"` // SafeToRetry 确认旧执行已结束,并按原业务身份再次执行安全;不能只依据未收到响应。
}
type ContinuePollInput struct {
StepDecision
PendingConfirmed bool `json:"pending_confirmed" required:"true"` // PendingConfirmed 确认权威查询结果为处理中,按原间隔与剩余业务轮次接续。
}
type RearmWaitInput struct {
StepDecision
WaitVerified bool `json:"wait_verified" required:"true"` // WaitVerified 已核验当前等待及旧回执,按原契约/截止开启新等待代次。
}
type Result struct {
Instance instance.View `json:"instance"` // Instance 操作提交时的冻结实例回执;核查步骤不自动解除实例核查。
AuditID core.ID `json:"audit_id"` // AuditID 本次流程管理操作的不可变审计标识。
@@ -69,6 +82,8 @@ type Result struct {
}
type Store interface {
ContinuePoll(context.Context, auth.Token, core.ID, string, ContinuePollInput) (Result, error)
RearmWait(context.Context, auth.Token, core.ID, string, RearmWaitInput) (Result, error)
ReviewTask(context.Context, auth.Token, core.ID, string, ReviewTaskInput) (Result, error)
ResolveStep(context.Context, auth.Token, core.ID, string, ResolveStepInput) (Result, error)
Continue(context.Context, auth.Token, core.ID, string, ContinueInput) (Result, error)
@@ -160,3 +175,28 @@ func (s *Service) RetryStep(ctx context.Context, t auth.Token, id core.ID, key s
}
return s.store.RetryStep(ctx, t, id, key, in)
}
func (s *Service) ContinuePoll(ctx context.Context, t auth.Token, id core.ID, key string, in ContinuePollInput) (Result, error) {
if err := validateDecision(id, key, in.Decision); err != nil {
return Result{}, err
}
if err := validateStep(in.StepDecision); err != nil {
return Result{}, err
}
if !in.PendingConfirmed {
return Result{}, fault.Invalid("pending_confirmed=true required")
}
return s.store.ContinuePoll(ctx, t, id, key, in)
}
func (s *Service) RearmWait(ctx context.Context, t auth.Token, id core.ID, key string, in RearmWaitInput) (Result, error) {
if err := validateDecision(id, key, in.Decision); err != nil {
return Result{}, err
}
if err := validateStep(in.StepDecision); err != nil {
return Result{}, err
}
if !in.WaitVerified {
return Result{}, fault.Invalid("wait_verified=true required")
}
return s.store.RearmWait(ctx, t, id, key, in)
}

View File

@@ -28,6 +28,14 @@ func (s *captureStore) RetryStep(context.Context, auth.Token, core.ID, string, R
s.calls++
return Result{}, nil
}
func (s *captureStore) ContinuePoll(context.Context, auth.Token, core.ID, string, ContinuePollInput) (Result, error) {
s.calls++
return Result{}, nil
}
func (s *captureStore) RearmWait(context.Context, auth.Token, core.ID, string, RearmWaitInput) (Result, error) {
s.calls++
return Result{}, nil
}
func TestRecoveryValidation(t *testing.T) {
id := core.ID(strings.Repeat("a", 32))
d := Decision{ExpectedRevision: 2, Reason: "核实外部结果", EvidenceRef: "evidence://case/1"}
@@ -71,7 +79,19 @@ func TestRecoveryValidation(t *testing.T) {
if _, err := s.RetryStep(ctx, token, id, "operation", RetryStepInput{StepDecision: step}); err == nil {
t.Fatal("unsafe retry accepted")
}
if store.calls != 4 {
if _, err := s.ContinuePoll(ctx, token, id, "operation", ContinuePollInput{StepDecision: step}); err == nil {
t.Fatal("unconfirmed pending accepted")
}
if _, err := s.RearmWait(ctx, token, id, "operation", RearmWaitInput{StepDecision: step}); err == nil {
t.Fatal("unverified wait accepted")
}
if _, err := s.ContinuePoll(ctx, token, id, "operation", ContinuePollInput{StepDecision: step, PendingConfirmed: true}); err != nil {
t.Fatal(err)
}
if _, err := s.RearmWait(ctx, token, id, "operation", RearmWaitInput{StepDecision: step, WaitVerified: true}); err != nil {
t.Fatal(err)
}
if store.calls != 6 {
t.Fatal("invalid inputs reached storage", store.calls)
}
}