Skip to content

Commit 4db54f4

Browse files
fix(flows): bound trace flow enqueue growth
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent 2f9f5af commit 4db54f4

2 files changed

Lines changed: 104 additions & 6 deletions

File tree

internal/analysis/flows/flows.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,6 @@ func (t *Tracer) TraceFlowBounded(ctx context.Context, startNodeID uint, opts Tr
7777
for len(queue) > 0 {
7878
current := queue[0]
7979
queue = queue[1:]
80-
if opts.MaxNodes > 0 && len(members) >= opts.MaxNodes {
81-
truncated = true
82-
break
83-
}
8480

8581
members = append(members, model.FlowMembership{
8682
Namespace: ns,
@@ -95,6 +91,10 @@ func (t *Tracer) TraceFlowBounded(ctx context.Context, startNodeID uint, opts Tr
9591
}
9692
for _, e := range edges {
9793
if e.Kind == model.EdgeKindCalls && !visited[e.ToNodeID] {
94+
if opts.MaxNodes > 0 && len(visited) >= opts.MaxNodes {
95+
truncated = true
96+
break
97+
}
9898
visited[e.ToNodeID] = true
9999
queue = append(queue, e.ToNodeID)
100100
}

internal/analysis/flows/flows_test.go

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,13 @@ import (
1818
var flowBuilderTestDBSeq atomic.Int64
1919

2020
type mockStore struct {
21-
nodes map[uint]*model.Node
22-
edges map[uint][]model.Edge
21+
nodes map[uint]*model.Node
22+
edges map[uint][]model.Edge
23+
fetchedNodeIDs []uint
2324
}
2425

2526
func (m *mockStore) GetEdgesFrom(_ context.Context, nodeID uint) ([]model.Edge, error) {
27+
m.fetchedNodeIDs = append(m.fetchedNodeIDs, nodeID)
2628
return m.edges[nodeID], nil
2729
}
2830

@@ -42,6 +44,26 @@ func callEdge(from, to uint, idx int) model.Edge {
4244
return model.Edge{ID: uint(idx), FromNodeID: from, ToNodeID: to, Kind: model.EdgeKindCalls, Fingerprint: fmt.Sprintf("e%d", idx)}
4345
}
4446

47+
func flowMemberIDs(flow *model.Flow) []uint {
48+
ids := make([]uint, 0, len(flow.Members))
49+
for _, member := range flow.Members {
50+
ids = append(ids, member.NodeID)
51+
}
52+
return ids
53+
}
54+
55+
func assertUintSliceEqual(t *testing.T, got, want []uint) {
56+
t.Helper()
57+
if len(got) != len(want) {
58+
t.Fatalf("expected IDs %v, got %v", want, got)
59+
}
60+
for i := range want {
61+
if got[i] != want[i] {
62+
t.Fatalf("expected IDs %v, got %v", want, got)
63+
}
64+
}
65+
}
66+
4567
func setupFlowBuilderTestDB(t *testing.T) (*gorm.DB, *gormstore.Store) {
4668
t.Helper()
4769
dsn := fmt.Sprintf("file:flows-builder-%s-%d?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "-"), flowBuilderTestDBSeq.Add(1))
@@ -155,6 +177,82 @@ func TestTraceFlow_PropagatesNamespace(t *testing.T) {
155177
}
156178
}
157179

180+
func TestTraceFlowBounded_ExactMaxNodesReturnsCapAndSignalsTruncation(t *testing.T) {
181+
ms := &mockStore{
182+
nodes: map[uint]*model.Node{
183+
1: newNode(1, "A"),
184+
2: newNode(2, "B"),
185+
3: newNode(3, "C"),
186+
4: newNode(4, "D"),
187+
},
188+
edges: map[uint][]model.Edge{1: {callEdge(1, 2, 1), callEdge(1, 3, 2), callEdge(1, 4, 3)}},
189+
}
190+
tracer := New(ms)
191+
192+
result, err := tracer.TraceFlowBounded(context.Background(), 1, TraceOptions{MaxNodes: 3})
193+
if err != nil {
194+
t.Fatal(err)
195+
}
196+
197+
assertUintSliceEqual(t, flowMemberIDs(result.Flow), []uint{1, 2, 3})
198+
if !result.Truncated {
199+
t.Fatalf("expected truncated result when an additional call was reachable beyond MaxNodes")
200+
}
201+
if result.ReturnedNodes != 3 {
202+
t.Fatalf("expected ReturnedNodes 3, got %d", result.ReturnedNodes)
203+
}
204+
}
205+
206+
func TestTraceFlowBounded_DoesNotEnqueueBeyondMaxNodesInHighFanout(t *testing.T) {
207+
nodes := map[uint]*model.Node{1: newNode(1, "Root")}
208+
edges := map[uint][]model.Edge{}
209+
for id := uint(2); id <= 101; id++ {
210+
nodes[id] = newNode(id, fmt.Sprintf("N%d", id))
211+
edges[1] = append(edges[1], callEdge(1, id, int(id)))
212+
}
213+
ms := &mockStore{nodes: nodes, edges: edges}
214+
tracer := New(ms)
215+
216+
result, err := tracer.TraceFlowBounded(context.Background(), 1, TraceOptions{MaxNodes: 3})
217+
if err != nil {
218+
t.Fatal(err)
219+
}
220+
221+
assertUintSliceEqual(t, flowMemberIDs(result.Flow), []uint{1, 2, 3})
222+
assertUintSliceEqual(t, ms.fetchedNodeIDs, []uint{1, 2, 3})
223+
if !result.Truncated {
224+
t.Fatalf("expected high fan-out traversal to report truncation")
225+
}
226+
}
227+
228+
func TestTraceFlowBounded_PreservesBFSMemberOrderWhenBounded(t *testing.T) {
229+
ms := &mockStore{
230+
nodes: map[uint]*model.Node{
231+
1: newNode(1, "A"),
232+
2: newNode(2, "B"),
233+
3: newNode(3, "C"),
234+
4: newNode(4, "D"),
235+
5: newNode(5, "E"),
236+
},
237+
edges: map[uint][]model.Edge{
238+
1: {callEdge(1, 2, 1), callEdge(1, 3, 2)},
239+
2: {callEdge(2, 4, 3)},
240+
3: {callEdge(3, 5, 4)},
241+
},
242+
}
243+
tracer := New(ms)
244+
245+
result, err := tracer.TraceFlowBounded(context.Background(), 1, TraceOptions{MaxNodes: 4})
246+
if err != nil {
247+
t.Fatal(err)
248+
}
249+
250+
assertUintSliceEqual(t, flowMemberIDs(result.Flow), []uint{1, 2, 3, 4})
251+
if !result.Truncated {
252+
t.Fatalf("expected truncated result when node 5 could not be enqueued")
253+
}
254+
}
255+
158256
func TestFlowBuilder_Rebuild_PersistsFlowPerEntrypoint(t *testing.T) {
159257
db, st := setupFlowBuilderTestDB(t)
160258

0 commit comments

Comments
 (0)