Skip to content

Commit ec66d5f

Browse files
committed
feat: implement zombie task recovery with configurable threshold and add chaos resilience tests
1 parent f3b460b commit ec66d5f

2 files changed

Lines changed: 151 additions & 5 deletions

File tree

internal/storage/memory/memory_storage.go

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,15 @@ type MemoryStorage struct {
1414
workflows map[string]*models.Workflow
1515
executions map[string]*models.Execution
1616
executionSteps map[string]*models.ExecutionStep
17+
ZombieThreshold time.Duration
1718
}
1819

1920
func NewMemoryStorage() *MemoryStorage {
2021
return &MemoryStorage{
2122
workflows: make(map[string]*models.Workflow),
2223
executions: make(map[string]*models.Execution),
2324
executionSteps: make(map[string]*models.ExecutionStep),
25+
ZombieThreshold: 5 * time.Minute,
2426
}
2527
}
2628

@@ -132,13 +134,18 @@ func (s *MemoryStorage) ClaimReadyStep(ctx context.Context, namespace string, wo
132134
defer s.mu.Unlock()
133135
now := time.Now()
134136
for _, step := range s.executionSteps {
135-
if step.Status == models.TaskPending {
137+
isZombie := step.Status == models.TaskRunning && s.ZombieThreshold > 0 && time.Since(step.UpdatedAt) > s.ZombieThreshold
138+
139+
if step.Status == models.TaskPending || isZombie {
136140
if namespace == "" || step.Namespace == namespace {
137-
if step.ScheduledAt == nil || step.ScheduledAt.Before(now) {
138-
step.Status = models.TaskRunning
139-
step.WorkerID = workerID
140-
return step, nil
141+
if step.Status == models.TaskPending && (step.ScheduledAt != nil && step.ScheduledAt.After(now)) {
142+
continue
141143
}
144+
145+
step.Status = models.TaskRunning
146+
step.WorkerID = workerID
147+
step.UpdatedAt = now // Refresh heart-beat
148+
return step, nil
142149
}
143150
}
144151
}

tests/chaos_test.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package tests
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"sync"
7+
"testing"
8+
"time"
9+
10+
"github.com/parevo/flow/internal/engine"
11+
"github.com/parevo/flow/internal/models"
12+
"github.com/parevo/flow/internal/storage/memory"
13+
)
14+
15+
func TestChaosResilience(t *testing.T) {
16+
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
17+
defer cancel()
18+
19+
memStore := memory.NewMemoryStorage()
20+
reg := engine.NewRegistry()
21+
reg.Register("mock", &MockNode{})
22+
eng := engine.NewEngine(memStore, reg)
23+
24+
t.Run("Worker Crash and Zombie Recovery", func(t *testing.T) {
25+
// Set threshold to 1s for fast testing
26+
memStore.ZombieThreshold = 1 * time.Second
27+
28+
wf := &models.Workflow{
29+
ID: "resilient-wf",
30+
Nodes: []models.Node{
31+
{ID: "step1", Type: "mock", Name: "Step 1"},
32+
{ID: "step2", Type: "mock", Name: "Step 2"},
33+
},
34+
Edges: []models.Edge{
35+
{ID: "e1", SourceID: "step1", TargetID: "step2"},
36+
},
37+
}
38+
memStore.SaveWorkflow(ctx, "chaos", wf)
39+
40+
// Start execution
41+
execID, _ := eng.StartWorkflow(ctx, "chaos", "resilient-wf", "input")
42+
43+
// Create a worker that will "crash" after starting step 1
44+
_ = engine.NewWorker("w-crashing", eng, reg, 50*time.Millisecond)
45+
46+
// Manually claim and mark as running to simulate a crash
47+
// instead of full goroutine management for simplicity
48+
steps, _ := eng.GetExecutionSteps(ctx, "chaos", execID)
49+
var step1 models.ExecutionStep
50+
for _, s := range steps {
51+
if s.NodeID == "step1" {
52+
step1 = *s
53+
break
54+
}
55+
}
56+
57+
step1.Status = models.TaskRunning
58+
step1.WorkerID = "w-dead"
59+
step1.UpdatedAt = time.Now().Add(-2 * time.Second) // Set back in time so it's a zombie
60+
memStore.UpdateExecutionStep(ctx, "chaos", &step1)
61+
62+
// Now start a healthy worker
63+
w2 := engine.NewWorker("w-survivor", eng, reg, 50*time.Millisecond)
64+
go w2.Start(ctx)
65+
66+
// Wait for completion
67+
deadline := time.Now().Add(10 * time.Second)
68+
success := false
69+
for time.Now().Before(deadline) {
70+
exec, _ := eng.GetExecutionStatus(ctx, "chaos", execID)
71+
if exec.Status == models.TaskCompleted {
72+
success = true
73+
break
74+
}
75+
time.Sleep(200 * time.Millisecond)
76+
}
77+
78+
if !success {
79+
t.Fatal("Workflow failed to recover from 'dead' worker and complete")
80+
}
81+
82+
// Verify step 1 was eventually processed by w-survivor
83+
finalSteps, _ := eng.GetExecutionSteps(ctx, "chaos", execID)
84+
for _, s := range finalSteps {
85+
if s.NodeID == "step1" && s.WorkerID != "w-survivor" {
86+
t.Errorf("Step 1 was NOT recovered by survivor worker, workerID: %s", s.WorkerID)
87+
}
88+
}
89+
})
90+
91+
t.Run("High Concurrency Stress Test", func(t *testing.T) {
92+
// 100 parallel workflows
93+
count := 100
94+
var wg sync.WaitGroup
95+
wg.Add(count)
96+
97+
wf := &models.Workflow{
98+
ID: "stress-wf",
99+
Nodes: []models.Node{
100+
{ID: "s1", Type: "mock", Name: "Fast Step"},
101+
},
102+
}
103+
memStore.SaveWorkflow(ctx, "stress", wf)
104+
105+
// 5 Workers handling the load
106+
for i := 0; i < 5; i++ {
107+
w := engine.NewWorker(fmt.Sprintf("stress-w-%d", i), eng, reg, 10*time.Millisecond)
108+
go w.Start(ctx)
109+
}
110+
111+
execIDs := make([]string, count)
112+
for i := 0; i < count; i++ {
113+
id, _ := eng.StartWorkflow(ctx, "stress", "stress-wf", "")
114+
execIDs[i] = id
115+
}
116+
117+
// Check for completion of all
118+
deadline := time.Now().Add(15 * time.Second)
119+
completedCount := 0
120+
121+
for time.Now().Before(deadline) {
122+
completedCount = 0
123+
for _, id := range execIDs {
124+
ex, _ := eng.GetExecutionStatus(ctx, "stress", id)
125+
if ex.Status == models.TaskCompleted {
126+
completedCount++
127+
}
128+
}
129+
if completedCount == count {
130+
break
131+
}
132+
time.Sleep(500 * time.Millisecond)
133+
}
134+
135+
if completedCount < count {
136+
t.Fatalf("Stress test failed: only %d/%d completed", completedCount, count)
137+
}
138+
})
139+
}

0 commit comments

Comments
 (0)