@@ -3,114 +3,68 @@ package expand
33import (
44 "context"
55
6+ "github.com/conductorone/baton-sdk/pkg/sync/expand/scc"
67 mapset "github.com/deckarep/golang-set/v2"
78)
89
9- const (
10- colorWhite uint8 = iota
11- colorGray
12- colorBlack
13- )
14-
15- // cycleDetector encapsulates coloring state for cycle detection on an
16- // EntitlementGraph. Node IDs are dense (1..NextNodeID), so slices are used for
17- // O(1) access and zero per-op allocations.
18- type cycleDetector struct {
19- g * EntitlementGraph
20- state []uint8
21- parent []int
22- }
23-
24- func newCycleDetector (g * EntitlementGraph ) * cycleDetector {
25- cd := & cycleDetector {
26- g : g ,
27- state : make ([]uint8 , g .NextNodeID + 1 ),
28- parent : make ([]int , g .NextNodeID + 1 ),
10+ // GetFirstCycle given an entitlements graph, return a cycle by node ID if it
11+ // exists. Returns nil if no cycle exists. If there is a single
12+ // node pointing to itself, that will count as a cycle.
13+ func (g * EntitlementGraph ) GetFirstCycle (ctx context.Context ) []int {
14+ if g .HasNoCycles {
15+ return nil
2916 }
30- for i := range cd .parent {
31- cd .parent [i ] = - 1
17+ comps := g .ComputeCyclicComponents (ctx )
18+ if len (comps ) == 0 {
19+ return nil
3220 }
33- return cd
21+ return comps [ 0 ]
3422}
3523
36- // dfs performs a coloring-based DFS from u, returning the first detected cycle
37- // as a slice of node IDs or nil if no cycle is reachable from u.
38- func (cd * cycleDetector ) dfs (u int ) ([]int , bool ) {
39- // Self-loop fast path.
40- if nbrs , ok := cd .g .SourcesToDestinations [u ]; ok {
41- if _ , ok := nbrs [u ]; ok {
42- return []int {u }, true
43- }
44- }
45-
46- cd .state [u ] = colorGray
47- if nbrs , ok := cd .g .SourcesToDestinations [u ]; ok {
48- for v := range nbrs {
49- switch cd .state [v ] {
50- case colorWhite :
51- cd .parent [v ] = u
52- if cyc , ok := cd .dfs (v ); ok {
53- return cyc , true
54- }
55- case colorGray :
56- // Back-edge to a node on the current recursion stack.
57- // Reconstruct cycle by walking parents from u back to v (inclusive), then reverse.
58- cycle := make ([]int , 0 , 8 )
59- for x := u ; ; x = cd .parent [x ] {
60- cycle = append (cycle , x )
61- if x == v || cd .parent [x ] == - 1 {
62- break
63- }
64- }
65- for i , j := 0 , len (cycle )- 1 ; i < j ; i , j = i + 1 , j - 1 {
66- cycle [i ], cycle [j ] = cycle [j ], cycle [i ]
67- }
68- return cycle , true
69- }
70- }
24+ // HasCycles returns true if the graph contains any cycle.
25+ func (g * EntitlementGraph ) HasCycles (ctx context.Context ) bool {
26+ if g .HasNoCycles {
27+ return false
7128 }
72- cd .state [u ] = colorBlack
73- return nil , false
29+ return len (g .ComputeCyclicComponents (ctx )) > 0
7430}
7531
76- // FindAny scans all nodes and returns the first detected cycle or nil if none exist.
77- func (cd * cycleDetector ) FindAny () []int {
78- for nodeID := range cd .g .Nodes {
79- if cd .state [nodeID ] != colorWhite {
80- continue
81- }
82- if cyc , ok := cd .dfs (nodeID ); ok {
83- return cyc
32+ func (g * EntitlementGraph ) cycleDetectionHelper (
33+ nodeID int ,
34+ ) ([]int , bool ) {
35+ reach := g .reachableFrom (nodeID )
36+ if len (reach ) == 0 {
37+ return nil , false
38+ }
39+ adj := g .toAdjacency (reach )
40+ groups := scc .CondenseFWBWGroupsFromAdj (context .Background (), adj , scc .DefaultOptions ())
41+ for _ , comp := range groups {
42+ if len (comp ) > 1 || (len (comp ) == 1 && adj [comp [0 ]][comp [0 ]] != 0 ) {
43+ return comp , true
8444 }
8545 }
86- return nil
46+ return nil , false
8747}
8848
89- // FindFrom starts cycle detection from a specific node and returns the first
90- // cycle reachable from that node, or nil,false if none.
91- func (cd * cycleDetector ) FindFrom (start int ) ([]int , bool ) {
92- return cd .dfs (start )
49+ func (g * EntitlementGraph ) FixCycles (ctx context.Context ) error {
50+ return g .FixCyclesFromComponents (ctx , g .ComputeCyclicComponents (ctx ))
9351}
9452
95- // GetFirstCycle given an entitlements graph, return a cycle by node ID if it
96- // exists. Returns nil if no cycle exists. If there is a single
97- // node pointing to itself, that will count as a cycle.
98- func (g * EntitlementGraph ) GetFirstCycle () []int {
53+ // ComputeCyclicComponents runs SCC once and returns only cyclic components.
54+ // A component is cyclic if len>1 or a singleton with a self-loop.
55+ func (g * EntitlementGraph ) ComputeCyclicComponents (ctx context.Context ) [][]int {
9956 if g .HasNoCycles {
10057 return nil
10158 }
102- cd := newCycleDetector (g )
103- return cd .FindAny ()
104- }
105-
106- func (g * EntitlementGraph ) cycleDetectionHelper (
107- nodeID int ,
108- ) ([]int , bool ) {
109- // Thin wrapper around the coloring-based DFS, starting from a specific node.
110- // The provided visited/currentCycle are ignored here; coloring provides the
111- // necessary state for correctness and performance.
112- cd := newCycleDetector (g )
113- return cd .FindFrom (nodeID )
59+ adj := g .toAdjacency (nil )
60+ groups := scc .CondenseFWBWGroupsFromAdj (ctx , adj , scc .DefaultOptions ())
61+ cyclic := make ([][]int , 0 )
62+ for _ , comp := range groups {
63+ if len (comp ) > 1 || (len (comp ) == 1 && adj [comp [0 ]][comp [0 ]] != 0 ) {
64+ cyclic = append (cyclic , comp )
65+ }
66+ }
67+ return cyclic
11468}
11569
11670// removeNode obliterates a node and all incoming/outgoing edges.
@@ -147,34 +101,33 @@ func (g *EntitlementGraph) removeNode(nodeID int) {
147101 delete (g .SourcesToDestinations , nodeID )
148102}
149103
150- // FixCycles if any cycles of nodes exist, merge all nodes in that cycle into a
151- // single node and then repeat. Iteration ends when there are no more cycles.
152- func (g * EntitlementGraph ) FixCycles (ctx context.Context ) error {
104+ // FixCyclesFromComponents merges all provided cyclic components in one pass.
105+ func (g * EntitlementGraph ) FixCyclesFromComponents (ctx context.Context , cyclic [][]int ) error {
153106 if g .HasNoCycles {
154107 return nil
155108 }
156- for {
109+ if len (cyclic ) == 0 {
110+ g .HasNoCycles = true
111+ return nil
112+ }
113+ for _ , comp := range cyclic {
157114 select {
158115 case <- ctx .Done ():
159116 return ctx .Err ()
160117 default :
161118 }
162- cycle := g .GetFirstCycle ()
163- if cycle == nil {
164- g .HasNoCycles = true
165- return nil
166- }
167-
168- if err := g .fixCycle (cycle ); err != nil {
119+ if err := g .fixCycle (comp ); err != nil {
169120 return err
170121 }
171122 }
123+ g .HasNoCycles = true
124+ return nil
172125}
173126
174127// fixCycle takes a list of Node IDs that form a cycle and merges them into a
175128// single, new node.
176129func (g * EntitlementGraph ) fixCycle (nodeIDs []int ) error {
177- entitlementIDs := mapset .NewSet [string ]()
130+ entitlementIDs := mapset .NewThreadUnsafeSet [string ]()
178131 outgoingEdgesToResourceTypeIDs := map [int ]mapset.Set [string ]{}
179132 incomingEdgesToResourceTypeIDs := map [int ]mapset.Set [string ]{}
180133 for _ , nodeID := range nodeIDs {
@@ -190,7 +143,7 @@ func (g *EntitlementGraph) fixCycle(nodeIDs []int) error {
190143 if edge , ok := g .Edges [edgeID ]; ok {
191144 resourceTypeIDs , ok := incomingEdgesToResourceTypeIDs [sourceNodeID ]
192145 if ! ok {
193- resourceTypeIDs = mapset .NewSet [string ]()
146+ resourceTypeIDs = mapset .NewThreadUnsafeSet [string ]()
194147 }
195148 for _ , resourceTypeID := range edge .ResourceTypeIDs {
196149 resourceTypeIDs .Add (resourceTypeID )
@@ -206,7 +159,7 @@ func (g *EntitlementGraph) fixCycle(nodeIDs []int) error {
206159 if edge , ok := g .Edges [edgeID ]; ok {
207160 resourceTypeIDs , ok := outgoingEdgesToResourceTypeIDs [destinationNodeID ]
208161 if ! ok {
209- resourceTypeIDs = mapset .NewSet [string ]()
162+ resourceTypeIDs = mapset .NewThreadUnsafeSet [string ]()
210163 }
211164 for _ , resourceTypeID := range edge .ResourceTypeIDs {
212165 resourceTypeIDs .Add (resourceTypeID )
0 commit comments