Skip to content

Commit 01c5d96

Browse files
authored
refactor: implement unconditional cycle detection in workflow validation. (#811)
1 parent 8c48068 commit 01c5d96

2 files changed

Lines changed: 152 additions & 0 deletions

File tree

workflow/validation.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ var ErrNodePointsToStart = errors.New("node points to start node")
3232
// ErrMultipleDefaultRoutes is returned when a node has more than one default route.
3333
var ErrMultipleDefaultRoutes = errors.New("node has more than one default route")
3434

35+
// ErrUnconditionalCycle is returned when a cycle is detected that does not
36+
// contain any conditional edges.
37+
var ErrUnconditionalCycle = errors.New("unconditional cycle detected")
38+
3539
// validateNodes executes a set of edges validation checks.
3640
func validateNodes(edges []Edge) error {
3741
if err := validateUniqueNames(edges); err != nil {
@@ -97,6 +101,9 @@ func validateWorkflow(workflow *Workflow) error {
97101
if err := validateDefaultRoute(workflow); err != nil {
98102
return err
99103
}
104+
if err := validateCycles(workflow); err != nil {
105+
return err
106+
}
100107
return nil
101108
}
102109

@@ -114,3 +121,48 @@ func validateDefaultRoute(workflow *Workflow) error {
114121
}
115122
return nil
116123
}
124+
125+
// validateCycles checks that there are no unconditional cycles in the workflow.
126+
// It performs a depth first search for every node in the workflow and checks
127+
// for cycles where all edges in the cycle have nil routes.
128+
// Default routes (where Route == Default) are treated as conditional edges
129+
// and are ignored during unconditional cycle detection.
130+
func validateCycles(workflow *Workflow) error {
131+
visited := make(map[Node]struct{})
132+
133+
var traverse func(n Node, inStack map[Node]struct{}) error
134+
traverse = func(n Node, inStack map[Node]struct{}) error {
135+
if _, ok := inStack[n]; ok {
136+
return fmt.Errorf("%w: %q", ErrUnconditionalCycle, n.Name())
137+
}
138+
139+
if _, ok := visited[n]; ok {
140+
return nil
141+
}
142+
143+
inStack[n] = struct{}{}
144+
visited[n] = struct{}{}
145+
146+
for _, edge := range workflow.graph.successors[n] {
147+
if edge.Route == nil {
148+
if err := traverse(edge.To, inStack); err != nil {
149+
return err
150+
}
151+
}
152+
}
153+
154+
delete(inStack, n)
155+
return nil
156+
}
157+
158+
for node := range workflow.graph.successors {
159+
if _, ok := visited[node]; !ok {
160+
inStack := make(map[Node]struct{})
161+
if err := traverse(node, inStack); err != nil {
162+
return err
163+
}
164+
}
165+
}
166+
167+
return nil
168+
}

workflow/validation_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,3 +183,103 @@ func TestDefaultRoute(t *testing.T) {
183183
})
184184
}
185185
}
186+
187+
func TestValidateCycles(t *testing.T) {
188+
tests := []struct {
189+
name string
190+
edges func() []Edge
191+
expectErr bool
192+
}{
193+
{
194+
name: "no cycles",
195+
edges: func() []Edge {
196+
nodeA := &dummyNode{name: "A"}
197+
nodeB := &dummyNode{name: "B"}
198+
return []Edge{
199+
{From: Start, To: nodeA},
200+
{From: nodeA, To: nodeB},
201+
}
202+
},
203+
expectErr: false,
204+
},
205+
{
206+
name: "no cycles diamond graph",
207+
edges: func() []Edge {
208+
nodeA := &dummyNode{name: "A"}
209+
nodeB := &dummyNode{name: "B"}
210+
nodeC := &dummyNode{name: "C"}
211+
nodeD := &dummyNode{name: "D"}
212+
return []Edge{
213+
{From: Start, To: nodeA},
214+
{From: nodeA, To: nodeB},
215+
{From: nodeA, To: nodeC},
216+
{From: nodeB, To: nodeD},
217+
{From: nodeC, To: nodeD},
218+
}
219+
},
220+
expectErr: false,
221+
},
222+
{
223+
name: "only conditional cycle",
224+
edges: func() []Edge {
225+
nodeA := &dummyNode{name: "A"}
226+
nodeB := &dummyNode{name: "B"}
227+
return []Edge{
228+
{From: Start, To: nodeA},
229+
{From: nodeA, To: nodeB},
230+
{From: nodeB, To: nodeA, Route: StringRoute("back")},
231+
}
232+
},
233+
expectErr: false,
234+
},
235+
{
236+
name: "both conditional and unconditional cycles",
237+
edges: func() []Edge {
238+
nodeA := &dummyNode{name: "A"}
239+
nodeB := &dummyNode{name: "B"}
240+
nodeC := &dummyNode{name: "C"}
241+
nodeD := &dummyNode{name: "D"}
242+
return []Edge{
243+
{From: Start, To: nodeA},
244+
{From: nodeA, To: nodeB},
245+
{From: nodeB, To: nodeA, Route: StringRoute("back")},
246+
{From: Start, To: nodeC},
247+
{From: nodeC, To: nodeD},
248+
{From: nodeD, To: nodeC}, // Unconditional
249+
}
250+
},
251+
expectErr: true,
252+
},
253+
{
254+
name: "cycle with default route",
255+
edges: func() []Edge {
256+
nodeA := &dummyNode{name: "A"}
257+
nodeB := &dummyNode{name: "B"}
258+
return []Edge{
259+
{From: Start, To: nodeA},
260+
{From: nodeA, To: nodeB},
261+
{From: nodeB, To: nodeA, Route: Default},
262+
}
263+
},
264+
expectErr: false,
265+
},
266+
{
267+
name: "empty graph",
268+
edges: func() []Edge { return []Edge{} },
269+
expectErr: false,
270+
},
271+
}
272+
273+
for _, tc := range tests {
274+
t.Run(tc.name, func(t *testing.T) {
275+
w := &Workflow{graph: newGraph(tc.edges())}
276+
277+
err := validateCycles(w)
278+
if tc.expectErr && err == nil {
279+
t.Errorf("expected error, got none")
280+
} else if !tc.expectErr && err != nil {
281+
t.Errorf("expected no error, got %v", err)
282+
}
283+
})
284+
}
285+
}

0 commit comments

Comments
 (0)