Skip to content

Commit 3d890f1

Browse files
authored
Fixed potential race-conditions; refactored def validation; do not perform skipped steps. (#23)
* Refactor workflow validation to centralize logic in `ValidateWorkflowDefinition`. * Handle skipped, paused, and rolled-back steps in queue processing; correct error messages format in tests. * Fix compensation retry count initialization from 1 to 0 in rollback processing. * Handle skipped steps in queries and compensation retries; add max retries for nested onFailure steps. * Fixed potential race-conditions and ensure null checks in store methods.
1 parent 5ce466f commit 3d890f1

7 files changed

Lines changed: 105 additions & 103 deletions

File tree

builder.go

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -555,7 +555,7 @@ func (builder *Builder) Build() (*WorkflowDefinition, error) {
555555
},
556556
}
557557

558-
if err := builder.validate(def); err != nil {
558+
if err := ValidateWorkflowDefinition(def); err != nil {
559559
return nil, err
560560
}
561561

@@ -568,30 +568,30 @@ func (builder *Builder) Build() (*WorkflowDefinition, error) {
568568
return def, nil
569569
}
570570

571-
func (builder *Builder) validate(def *WorkflowDefinition) error {
571+
func ValidateWorkflowDefinition(def *WorkflowDefinition) error {
572572
for stepName, stepDef := range def.Definition.Steps {
573573
if err := validateStepName(stepName); err != nil {
574-
return fmt.Errorf("builder %q: %w", builder.name, err)
574+
return fmt.Errorf("def %q: %w", def.Name, err)
575575
}
576576

577577
for _, nextStep := range stepDef.Next {
578578
if _, ok := def.Definition.Steps[nextStep]; !ok {
579-
return fmt.Errorf("builder %q: step %q references unknown step: %q",
580-
builder.name, stepName, nextStep)
579+
return fmt.Errorf("def %q: step %q references unknown step: %q",
580+
def.Name, stepName, nextStep)
581581
}
582582
}
583583

584584
if stepDef.OnFailure != "" {
585585
if _, ok := def.Definition.Steps[stepDef.OnFailure]; !ok {
586-
return fmt.Errorf("builder %q: step %q references unknown compensation step: %q",
587-
builder.name, stepName, stepDef.OnFailure)
586+
return fmt.Errorf("def %q: step %q references unknown compensation step: %q",
587+
def.Name, stepName, stepDef.OnFailure)
588588
}
589589
}
590590

591591
if stepDef.Else != "" {
592592
if _, ok := def.Definition.Steps[stepDef.Else]; !ok {
593-
return fmt.Errorf("builder %q: step %q references unknown else step: %q",
594-
builder.name, stepName, stepDef.Else)
593+
return fmt.Errorf("def %q: step %q references unknown else step: %q",
594+
def.Name, stepName, stepDef.Else)
595595
}
596596
}
597597

@@ -601,8 +601,8 @@ func (builder *Builder) validate(def *WorkflowDefinition) error {
601601
continue
602602
}
603603
if _, ok := def.Definition.Steps[parallelStep]; !ok {
604-
return fmt.Errorf("builder %q: step %q references unknown parallel step: %q",
605-
builder.name, stepName, parallelStep)
604+
return fmt.Errorf("def %q: step %q references unknown parallel step: %q",
605+
def.Name, stepName, parallelStep)
606606
}
607607
}
608608

@@ -614,27 +614,27 @@ func (builder *Builder) validate(def *WorkflowDefinition) error {
614614
continue
615615
}
616616
if _, ok := def.Definition.Steps[waitForStep]; !ok {
617-
return fmt.Errorf("builder %q: join step %q references unknown step in waitFor: %q",
618-
builder.name, stepName, waitForStep)
617+
return fmt.Errorf("def %q: join step %q references unknown step in waitFor: %q",
618+
def.Name, stepName, waitForStep)
619619
}
620620
}
621621
}
622622

623623
if stepDef.Type == StepTypeTask && stepDef.Handler == "" {
624-
return fmt.Errorf("builder %q: task step %q must have a handler", builder.name, stepName)
624+
return fmt.Errorf("def %q: task step %q must have a handler", def.Name, stepName)
625625
}
626626
}
627627

628628
visited := make(map[string]bool)
629-
err := builder.detectCycles(def.Definition.Start, def.Definition.Steps, visited, make(map[string]bool))
629+
err := detectCycles(def.Definition.Start, def.Definition.Steps, visited, make(map[string]bool))
630630
if err != nil {
631-
return fmt.Errorf("builder %q: %w", builder.name, err)
631+
return fmt.Errorf("def %q: %w", def.Name, err)
632632
}
633633

634634
return nil
635635
}
636636

637-
func (builder *Builder) detectCycles(
637+
func detectCycles(
638638
current string,
639639
steps map[string]*StepDefinition,
640640
visited, recStack map[string]bool,
@@ -674,7 +674,7 @@ func (builder *Builder) detectCycles(
674674
}
675675

676676
if !visited[next] {
677-
if err := builder.detectCycles(next, steps, visited, recStack); err != nil {
677+
if err := detectCycles(next, steps, visited, recStack); err != nil {
678678
return err
679679
}
680680
} else if recStack[next] {
@@ -763,11 +763,11 @@ func (builder *Builder) traverseBranchForCondition(stepName string, visited map[
763763
}
764764

765765
// Also check Else branch if exists
766-
//if stepDef.Else != "" {
767-
// if result := builder.traverseBranchForCondition(stepDef.Else, visited); result != "" {
768-
// return result
769-
// }
770-
//}
766+
if stepDef.Else != "" {
767+
if result := builder.traverseBranchForCondition(stepDef.Else, visited); result != "" {
768+
return result
769+
}
770+
}
771771

772772
return ""
773773
}

engine.go

Lines changed: 50 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,10 @@ func (engine *Engine) Start(ctx context.Context, workflowID string, input json.R
164164
return fmt.Errorf("get workflow definition: %w", err)
165165
}
166166

167+
if err := engine.validateDefinition(def); err != nil {
168+
return fmt.Errorf("invalid workflow definition: %w", err)
169+
}
170+
167171
instance, err := engine.store.CreateInstance(ctx, workflowID, input)
168172
if err != nil {
169173
return fmt.Errorf("create instance: %w", err)
@@ -323,6 +327,17 @@ func (engine *Engine) ExecuteNext(ctx context.Context, workerID string) (empty b
323327
}
324328
}
325329

330+
// Do not execute skipped or paused steps
331+
if step.Status == StepStatusSkipped ||
332+
step.Status == StepStatusPaused ||
333+
step.Status == StepStatusRolledBack {
334+
if err := engine.store.RemoveFromQueue(ctx, step.ID); err != nil {
335+
return fmt.Errorf("remove step from queue: %w", err)
336+
}
337+
338+
return nil
339+
}
340+
326341
// Check if this is a compensation
327342
if step.Status == StepStatusCompensation {
328343
// For distributed setup: if local engine doesn't have the compensation handler,
@@ -369,8 +384,6 @@ func (engine *Engine) ExecuteNext(ctx context.Context, workerID string) (empty b
369384
}
370385

371386
return engine.executeCompensationStep(ctx, instance, step)
372-
} else if step.Status == StepStatusRolledBack {
373-
return nil
374387
}
375388

376389
// Distributed handlers: if this is a task step and no local handler is registered,
@@ -919,7 +932,11 @@ func (engine *Engine) executeCompensationStep(ctx context.Context, instance *Wor
919932
}
920933

921934
// Re-enqueue for retry
922-
if err := engine.store.EnqueueStep(ctx, step.InstanceID, &step.ID, PriorityHigh, stepDef.Delay); err != nil {
935+
retryDelay := CalculateRetryDelay(onFailureStep.RetryStrategy, onFailureStep.RetryDelay, newRetryCount)
936+
if retryDelay == 0 {
937+
retryDelay = onFailureStep.Delay
938+
}
939+
if err := engine.store.EnqueueStep(ctx, step.InstanceID, &step.ID, PriorityHigh, retryDelay); err != nil {
923940
return fmt.Errorf("enqueue compensation retry: %w", err)
924941
}
925942

@@ -1659,11 +1676,6 @@ func (engine *Engine) notifyJoinStepsForStep(
16591676
return err
16601677
}
16611678

1662-
steps, err := engine.store.GetStepsByInstance(ctx, instanceID)
1663-
if err != nil {
1664-
return err
1665-
}
1666-
16671679
// Get Join step definition to check strategy
16681680
def, err := engine.store.GetWorkflowDefinition(ctx, instance.WorkflowID)
16691681
if err != nil {
@@ -1681,9 +1693,15 @@ func (engine *Engine) notifyJoinStepsForStep(
16811693
return fmt.Errorf("update join state for %s: %w", joinStepName, err)
16821694
}
16831695

1696+
var readySteps []WorkflowStep
16841697
// Additional check: don't consider join ready if there are still pending/running steps
16851698
if isReady {
1686-
hasPendingSteps := engine.hasPendingStepsInParallelBranches(ctx, instanceID, stepDef, steps)
1699+
readySteps, err = engine.store.GetStepsByInstance(ctx, instanceID)
1700+
if err != nil {
1701+
return err
1702+
}
1703+
1704+
hasPendingSteps := engine.hasPendingStepsInParallelBranches(ctx, instanceID, stepDef, readySteps)
16871705
if hasPendingSteps {
16881706
isReady = false
16891707
_, _ = engine.store.UpdateJoinState(ctx, instanceID, joinStepName, completedStepName, success)
@@ -1699,8 +1717,8 @@ func (engine *Engine) notifyJoinStepsForStep(
16991717

17001718
if isReady {
17011719
joinStepExists := false
1702-
for _, s := range steps {
1703-
if s.StepName == joinStepName {
1720+
for _, readyStep := range readySteps {
1721+
if readyStep.StepName == joinStepName {
17041722
joinStepExists = true
17051723

17061724
break
@@ -1709,9 +1727,9 @@ func (engine *Engine) notifyJoinStepsForStep(
17091727

17101728
if !joinStepExists {
17111729
var joinInput json.RawMessage
1712-
for _, s := range steps {
1713-
if s.StepName == completedStepName {
1714-
joinInput = s.Input
1730+
for _, readyStep := range readySteps {
1731+
if readyStep.StepName == completedStepName {
1732+
joinInput = readyStep.Input
17151733

17161734
break
17171735
}
@@ -1767,11 +1785,6 @@ func (engine *Engine) notifyJoinSteps(
17671785
return err
17681786
}
17691787

1770-
steps, err := engine.store.GetStepsByInstance(ctx, instanceID)
1771-
if err != nil {
1772-
return err
1773-
}
1774-
17751788
for stepName, stepDef := range def.Definition.Steps {
17761789
if stepDef.Type != StepTypeJoin {
17771790
continue
@@ -1815,8 +1828,14 @@ func (engine *Engine) notifyJoinSteps(
18151828

18161829
// Additional check: don't consider join ready if there are still pending/running steps
18171830
// in parallel branches that could affect the join result
1831+
var readySteps []WorkflowStep
18181832
if isReady {
1819-
hasPendingSteps := engine.hasPendingStepsInParallelBranches(ctx, instanceID, stepDef, steps)
1833+
readySteps, err = engine.store.GetStepsByInstance(ctx, instanceID)
1834+
if err != nil {
1835+
return err
1836+
}
1837+
1838+
hasPendingSteps := engine.hasPendingStepsInParallelBranches(ctx, instanceID, stepDef, readySteps)
18201839
if hasPendingSteps {
18211840
isReady = false
18221841
// Update the join state to reflect that it's not ready
@@ -1833,8 +1852,8 @@ func (engine *Engine) notifyJoinSteps(
18331852

18341853
if isReady {
18351854
joinStepExists := false
1836-
for _, s := range steps {
1837-
if s.StepName == stepName {
1855+
for _, readyStep := range readySteps {
1856+
if readyStep.StepName == stepName {
18381857
joinStepExists = true
18391858

18401859
break
@@ -1843,9 +1862,9 @@ func (engine *Engine) notifyJoinSteps(
18431862

18441863
if !joinStepExists {
18451864
var joinInput json.RawMessage
1846-
for _, s := range steps {
1847-
if s.StepName == completedStepName {
1848-
joinInput = s.Input
1865+
for _, readyStep := range readySteps {
1866+
if readyStep.StepName == completedStepName {
1867+
joinInput = readyStep.Input
18491868

18501869
break
18511870
}
@@ -2010,7 +2029,7 @@ func (engine *Engine) enqueueCompletedStepsForRollback(ctx context.Context, inst
20102029
// Mark each step as requiring compensation and enqueue for processing
20112030
for idx, step := range stepsToRollback {
20122031
// Update step status to compensation with retry count = 0
2013-
if err := engine.store.UpdateStepCompensationRetry(ctx, step.ID, 1, StepStatusCompensation); err != nil {
2032+
if err := engine.store.UpdateStepCompensationRetry(ctx, step.ID, 0, StepStatusCompensation); err != nil {
20142033
slog.Warn("[floxy] failed to mark step for compensation", "step_id", step.ID, "error", err)
20152034
continue
20162035
}
@@ -2215,28 +2234,7 @@ func (engine *Engine) validateDefinition(def *WorkflowDefinition) error {
22152234
return fmt.Errorf("start step not found: %s", def.Definition.Start)
22162235
}
22172236

2218-
for stepName, stepDef := range def.Definition.Steps {
2219-
for _, nextStep := range stepDef.Next {
2220-
if _, ok := def.Definition.Steps[nextStep]; !ok {
2221-
return fmt.Errorf("step %s references unknown step: %s", stepName, nextStep)
2222-
}
2223-
}
2224-
2225-
if stepDef.OnFailure != "" {
2226-
if _, ok := def.Definition.Steps[stepDef.OnFailure]; !ok {
2227-
return fmt.Errorf("step %s references unknown compensation step: %s",
2228-
stepName, stepDef.OnFailure)
2229-
}
2230-
}
2231-
2232-
for _, parallelStep := range stepDef.Parallel {
2233-
if _, ok := def.Definition.Steps[parallelStep]; !ok {
2234-
return fmt.Errorf("step %s references unknown parallel step: %s", stepName, parallelStep)
2235-
}
2236-
}
2237-
}
2238-
2239-
return nil
2237+
return ValidateWorkflowDefinition(def)
22402238
}
22412239

22422240
func (engine *Engine) rollbackToSavePointOrRoot(
@@ -2448,7 +2446,10 @@ func (engine *Engine) rollbackStep(ctx context.Context, step *WorkflowStep, def
24482446
}
24492447

24502448
// Enqueue compensation step for execution
2451-
retryDelay := CalculateRetryDelay(stepDef.RetryStrategy, stepDef.RetryDelay, newRetryCount)
2449+
retryDelay := CalculateRetryDelay(onFailureStep.RetryStrategy, onFailureStep.RetryDelay, newRetryCount)
2450+
if retryDelay == 0 {
2451+
retryDelay = onFailureStep.Delay
2452+
}
24522453
if err := engine.store.EnqueueStep(ctx, step.InstanceID, &step.ID, PriorityHigh, retryDelay); err != nil {
24532454
return fmt.Errorf("enqueue compensation step: %w", err)
24542455
}

engine_chaos_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ WHERE status = 'failed'`
213213
nonRolledBackQuery := `
214214
SELECT id, step_name, status
215215
FROM workflows.workflow_steps
216-
WHERE instance_id = $1 AND status != 'rolled_back'`
216+
WHERE instance_id = $1 AND status != 'rolled_back' AND status != 'skipped'`
217217
stepsRows, err := v.pool.Query(ctx, nonRolledBackQuery, instanceID)
218218
if err != nil {
219219
return fmt.Errorf("failed to query steps for instance %d: %w", instanceID, err)
@@ -245,7 +245,7 @@ WHERE status = 'failed'`
245245
SELECT ws.id, ws.step_name, ws.status, ws.created_at::text
246246
FROM workflows.workflow_steps ws
247247
WHERE ws.instance_id = $1
248-
AND ws.status != 'rolled_back'
248+
AND ws.status != 'rolled_back' AND status != 'skipped'
249249
AND ws.created_at > (SELECT created_at FROM workflows.workflow_steps WHERE id = $2)
250250
ORDER BY ws.created_at`
251251
stepsRows, err := v.pool.Query(ctx, nonRolledBackQuery, instanceID, *savepointID)

engine_step_result_test.go

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,6 @@ func Test_handleStepSuccess_SimpleNextFlow(t *testing.T) {
5151
// 5) notifyJoinSteps path will need instance, def, steps list; provide empty steps list without joins
5252
store.EXPECT().GetInstance(mock.Anything, instance.ID).Return(instance, nil)
5353
store.EXPECT().GetWorkflowDefinition(mock.Anything, def.ID).Return(def, nil)
54-
store.EXPECT().GetStepsByInstance(mock.Anything, instance.ID).Return([]WorkflowStep{
55-
// include the just-finished step to provide input for potential join (not used here)
56-
{ID: step.ID, InstanceID: instance.ID, StepName: step.StepName, StepType: step.StepType, Status: StepStatusCompleted, Input: json.RawMessage(`{"in":1}`)},
57-
}, nil)
5854
// 6) Enqueue next step B via enqueueNextSteps -> CreateStep + EnqueueStep
5955
next := &WorkflowStep{
6056
InstanceID: instance.ID,
@@ -142,9 +138,6 @@ func Test_handleStepFailure_DLQEnabled_NoRetry(t *testing.T) {
142138
// 4) notifyJoinSteps (no joins) -> GetInstance, GetWorkflowDefinition, GetStepsByInstance
143139
store.EXPECT().GetInstance(mock.Anything, instance.ID).Return(instance, nil)
144140
store.EXPECT().GetWorkflowDefinition(mock.Anything, def.ID).Return(def, nil)
145-
store.EXPECT().GetStepsByInstance(mock.Anything, instance.ID).Return([]WorkflowStep{
146-
{ID: step.ID, InstanceID: instance.ID, StepName: step.StepName, StepType: step.StepType, Status: StepStatusPaused, Input: json.RawMessage(`{"in":1}`)},
147-
}, nil)
148141
// 5) Create DLQ record
149142
store.EXPECT().CreateDeadLetterRecord(mock.Anything, mock.MatchedBy(func(rec *DeadLetterRecord) bool {
150143
return rec != nil && rec.InstanceID == instance.ID && rec.StepID == step.ID && rec.WorkflowID == def.ID && rec.Reason != ""

0 commit comments

Comments
 (0)