diff --git a/pkg/devcontainer/graph/graph.go b/pkg/devcontainer/graph/graph.go index a48ccd5c1..f19a2618e 100644 --- a/pkg/devcontainer/graph/graph.go +++ b/pkg/devcontainer/graph/graph.go @@ -284,21 +284,22 @@ func (g *Graph[T]) HasCircularDependency() bool { func (g *Graph[T]) sortNodeIDs() ([]string, error) { workingInDegree := copyInDegreeMap(g.inDegree) - zeroInDegreeQueue := initializeQueue(workingInDegree) sortedResult := make([]string, 0, len(g.nodes)) - for len(zeroInDegreeQueue) > 0 { - currentNode := dequeue(&zeroInDegreeQueue) - sortedResult = append(sortedResult, currentNode) - processNeighbors(g.edges, currentNode, workingInDegree, &zeroInDegreeQueue) + for { + round := collectZeroInDegree(workingInDegree) + if len(round) == 0 { + break + } + sort.Strings(round) + sortedResult = append(sortedResult, round...) + advanceRound(round, g.edges, workingInDegree) } if len(sortedResult) != len(g.nodes) { - remainingNodes := []string{} - for nodeID, degree := range workingInDegree { - if degree > 0 { - remainingNodes = append(remainingNodes, nodeID) - } + remainingNodes := make([]string, 0, len(workingInDegree)) + for nodeID := range workingInDegree { + remainingNodes = append(remainingNodes, nodeID) } sort.Strings(remainingNodes) return nil, fmt.Errorf("circular dependency detected among nodes: %v", remainingNodes) @@ -307,6 +308,25 @@ func (g *Graph[T]) sortNodeIDs() ([]string, error) { return sortedResult, nil } +func collectZeroInDegree(inDegree map[string]int) []string { + round := make([]string, 0) + for nodeID, degree := range inDegree { + if degree == 0 { + round = append(round, nodeID) + } + } + return round +} + +func advanceRound(round []string, edges map[string][]string, inDegree map[string]int) { + for _, nodeID := range round { + delete(inDegree, nodeID) + for _, neighbor := range edges[nodeID] { + inDegree[neighbor]-- + } + } +} + func (g *Graph[T]) collectChildren(id string, nodesToRemove *[]string) { stack := []string{id} visited := make(map[string]bool) diff --git a/pkg/devcontainer/graph/graph_test.go b/pkg/devcontainer/graph/graph_test.go index 16b91a72e..55c0f108a 100644 --- a/pkg/devcontainer/graph/graph_test.go +++ b/pkg/devcontainer/graph/graph_test.go @@ -523,6 +523,39 @@ func (suite *GraphTestSuite) TestLargeGraph() { suite.Len(result, nodeCount) } +func (suite *GraphTestSuite) TestSortNodeIDsRoundBased() { + // A→B edge means B depends on A. + // Round 1: A and C both have in-degree 0 → sorted alpha → [A, C] + // Round 2: B (now in-degree 0 after A processed) → [B] + // Final: [A, C, B] + suite.Require().NoError(suite.graph.AddNode("A", "dataA")) + suite.Require().NoError(suite.graph.AddNode("B", "dataB")) + suite.Require().NoError(suite.graph.AddNode("C", "dataC")) + suite.Require().NoError(suite.graph.AddEdge("A", "B")) + + result, err := suite.graph.SortNodeIDs() + suite.NoError(err) + suite.Equal([]string{"A", "C", "B"}, result) +} + +func (suite *GraphTestSuite) TestSortNodeIDsRoundBasedMultiLevel() { + // A→B→D, C→D (independent chains with shared sink) + // Round 1: A, C (in-degree 0) → [A, C] + // Round 2: B (depends on A only) → [B] + // Round 3: D (depends on B and C, but C was processed in round 1) → [D] + suite.Require().NoError(suite.graph.AddNode("A", "dataA")) + suite.Require().NoError(suite.graph.AddNode("B", "dataB")) + suite.Require().NoError(suite.graph.AddNode("C", "dataC")) + suite.Require().NoError(suite.graph.AddNode("D", "dataD")) + suite.Require().NoError(suite.graph.AddEdge("A", "B")) + suite.Require().NoError(suite.graph.AddEdge("B", "D")) + suite.Require().NoError(suite.graph.AddEdge("C", "D")) + + result, err := suite.graph.SortNodeIDs() + suite.NoError(err) + suite.Equal([]string{"A", "C", "B", "D"}, result) +} + func (suite *GraphTestSuite) TestTopologicalSortAdvanced() { testCases := []struct { name string