From 52b62ea750cdf0820ff462a77ef139d0eac55e29 Mon Sep 17 00:00:00 2001 From: caretak3r <50377477+caretak3r@users.noreply.github.com> Date: Wed, 18 Feb 2026 21:29:13 -0500 Subject: [PATCH] =?UTF-8?q?feat(spec):=20Task=201=20=E2=80=94=20DAG=20engi?= =?UTF-8?q?ne=20with=20topological=20sort=20and=20cycle=20detection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/chart/v2/util/dag.go | 146 ++++++++++++++++++++++ pkg/chart/v2/util/dag_test.go | 224 ++++++++++++++++++++++++++++++++++ 2 files changed, 370 insertions(+) create mode 100644 pkg/chart/v2/util/dag.go create mode 100644 pkg/chart/v2/util/dag_test.go diff --git a/pkg/chart/v2/util/dag.go b/pkg/chart/v2/util/dag.go new file mode 100644 index 000000000..61407d98e --- /dev/null +++ b/pkg/chart/v2/util/dag.go @@ -0,0 +1,146 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +import ( + "fmt" + "sort" + "strings" +) + +// DAG is a directed acyclic graph of string-keyed nodes, used for resource-group +// and subchart dependency ordering. +// +// Edges are directed: AddEdge("a", "b") means "b depends on a" (a must come before b). +type DAG struct { + nodes map[string]struct{} + edges map[string][]string // from -> []to (dependents) + inDegree map[string]int // node -> number of prerequisites +} + +// NewDAG creates an empty DAG. +func NewDAG() *DAG { + return &DAG{ + nodes: make(map[string]struct{}), + edges: make(map[string][]string), + inDegree: make(map[string]int), + } +} + +// AddNode registers a node in the DAG. Duplicate adds are idempotent. +func (d *DAG) AddNode(name string) { + if _, ok := d.nodes[name]; ok { + return + } + d.nodes[name] = struct{}{} + d.edges[name] = nil + d.inDegree[name] = 0 +} + +// AddEdge adds a directed edge: "to" depends on "from" (from is deployed before to). +// Returns an error if either node is unknown or if a self-loop is requested. +func (d *DAG) AddEdge(from, to string) error { + if from == to { + return fmt.Errorf("self-loop not allowed: %q", from) + } + if _, ok := d.nodes[from]; !ok { + return fmt.Errorf("unknown node %q", from) + } + if _, ok := d.nodes[to]; !ok { + return fmt.Errorf("unknown node %q", to) + } + d.edges[from] = append(d.edges[from], to) + d.inDegree[to]++ + return nil +} + +// GetBatches performs a topological sort using Kahn's algorithm and returns +// the nodes grouped into deployment batches. Each batch contains nodes that +// can be deployed in parallel. Batches are ordered: batch 0 has no prerequisites, +// batch 1 depends only on batch 0, etc. +// +// Returns an error if a cycle is detected, including the names of the nodes +// involved in the cycle. +func (d *DAG) GetBatches() ([][]string, error) { + if len(d.nodes) == 0 { + return nil, nil + } + + // Copy in-degrees so we don't mutate the DAG. + inDeg := make(map[string]int, len(d.inDegree)) + for k, v := range d.inDegree { + inDeg[k] = v + } + + var batches [][]string + processed := 0 + + for { + // Collect all nodes with zero in-degree. + var batch []string + for n := range d.nodes { + if inDeg[n] == 0 { + batch = append(batch, n) + inDeg[n] = -1 // mark as queued + } + } + if len(batch) == 0 { + break + } + // Sort for deterministic output. + sort.Strings(batch) + batches = append(batches, batch) + processed += len(batch) + + // Reduce in-degree for all dependents. + for _, n := range batch { + for _, dep := range d.edges[n] { + inDeg[dep]-- + } + } + } + + if processed < len(d.nodes) { + // Nodes still with positive in-degree are part of a cycle. + var cycleNodes []string + for n := range d.nodes { + if inDeg[n] > 0 { + cycleNodes = append(cycleNodes, n) + } + } + sort.Strings(cycleNodes) + return nil, fmt.Errorf("cycle detected among nodes: %s", strings.Join(cycleNodes, ", ")) + } + + return batches, nil +} + +// Nodes returns a sorted slice of all node names in the DAG. +func (d *DAG) Nodes() []string { + ns := make([]string, 0, len(d.nodes)) + for n := range d.nodes { + ns = append(ns, n) + } + sort.Strings(ns) + return ns +} + +// HasNode reports whether the DAG contains a node with the given name. +func (d *DAG) HasNode(name string) bool { + _, ok := d.nodes[name] + return ok +} diff --git a/pkg/chart/v2/util/dag_test.go b/pkg/chart/v2/util/dag_test.go new file mode 100644 index 000000000..3b3756608 --- /dev/null +++ b/pkg/chart/v2/util/dag_test.go @@ -0,0 +1,224 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +import ( + "strings" + "testing" +) + +func TestDAGGetBatches_Empty(t *testing.T) { + d := NewDAG() + batches, err := d.GetBatches() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(batches) != 0 { + t.Errorf("expected 0 batches, got %d", len(batches)) + } +} + +func TestDAGGetBatches_SingleNode(t *testing.T) { + d := NewDAG() + d.AddNode("a") + batches, err := d.GetBatches() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(batches) != 1 { + t.Fatalf("expected 1 batch, got %d", len(batches)) + } + if len(batches[0]) != 1 || batches[0][0] != "a" { + t.Errorf("expected batch [a], got %v", batches[0]) + } +} + +func TestDAGGetBatches_Linear(t *testing.T) { + // a → b → c (a must be deployed before b, b before c) + d := NewDAG() + d.AddNode("a") + d.AddNode("b") + d.AddNode("c") + // AddEdge(from, to) means "to depends on from" (from must come before to) + if err := d.AddEdge("a", "b"); err != nil { + t.Fatalf("AddEdge: %v", err) + } + if err := d.AddEdge("b", "c"); err != nil { + t.Fatalf("AddEdge: %v", err) + } + batches, err := d.GetBatches() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(batches) != 3 { + t.Fatalf("expected 3 batches, got %d: %v", len(batches), batches) + } + if !containsExactly(batches[0], "a") { + t.Errorf("batch 0 should be [a], got %v", batches[0]) + } + if !containsExactly(batches[1], "b") { + t.Errorf("batch 1 should be [b], got %v", batches[1]) + } + if !containsExactly(batches[2], "c") { + t.Errorf("batch 2 should be [c], got %v", batches[2]) + } +} + +func TestDAGGetBatches_Diamond(t *testing.T) { + // a → b, a → c, b → d, c → d + d := NewDAG() + for _, n := range []string{"a", "b", "c", "d"} { + d.AddNode(n) + } + if err := d.AddEdge("a", "b"); err != nil { + t.Fatal(err) + } + if err := d.AddEdge("a", "c"); err != nil { + t.Fatal(err) + } + if err := d.AddEdge("b", "d"); err != nil { + t.Fatal(err) + } + if err := d.AddEdge("c", "d"); err != nil { + t.Fatal(err) + } + batches, err := d.GetBatches() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(batches) != 3 { + t.Fatalf("expected 3 batches, got %d: %v", len(batches), batches) + } + if !containsExactly(batches[0], "a") { + t.Errorf("batch 0 should be [a], got %v", batches[0]) + } + if !containsAll(batches[1], "b", "c") { + t.Errorf("batch 1 should be [b, c], got %v", batches[1]) + } + if !containsExactly(batches[2], "d") { + t.Errorf("batch 2 should be [d], got %v", batches[2]) + } +} + +func TestDAGGetBatches_MultipleRoots(t *testing.T) { + // a and b are roots, both → c + d := NewDAG() + for _, n := range []string{"a", "b", "c"} { + d.AddNode(n) + } + if err := d.AddEdge("a", "c"); err != nil { + t.Fatal(err) + } + if err := d.AddEdge("b", "c"); err != nil { + t.Fatal(err) + } + batches, err := d.GetBatches() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(batches) != 2 { + t.Fatalf("expected 2 batches, got %d: %v", len(batches), batches) + } + if !containsAll(batches[0], "a", "b") { + t.Errorf("batch 0 should be [a, b], got %v", batches[0]) + } + if !containsExactly(batches[1], "c") { + t.Errorf("batch 1 should be [c], got %v", batches[1]) + } +} + +func TestDAGGetBatches_Cycle(t *testing.T) { + d := NewDAG() + for _, n := range []string{"a", "b", "c"} { + d.AddNode(n) + } + if err := d.AddEdge("a", "b"); err != nil { + t.Fatal(err) + } + if err := d.AddEdge("b", "c"); err != nil { + t.Fatal(err) + } + if err := d.AddEdge("c", "a"); err != nil { + t.Fatal(err) + } + _, err := d.GetBatches() + if err == nil { + t.Fatal("expected cycle error, got nil") + } + if !strings.Contains(err.Error(), "cycle") { + t.Errorf("error should mention cycle, got: %v", err) + } +} + +func TestDAGAddEdge_UnknownNode(t *testing.T) { + d := NewDAG() + d.AddNode("a") + // "b" is not registered + err := d.AddEdge("a", "b") + if err == nil { + t.Fatal("expected error for unknown node, got nil") + } +} + +func TestDAGAddEdge_SelfLoop(t *testing.T) { + d := NewDAG() + d.AddNode("a") + err := d.AddEdge("a", "a") + if err == nil { + t.Fatal("expected error for self-loop, got nil") + } +} + +// TestDAGGetBatches_NodesWithoutEdges tests that isolated nodes are put in batch 0. +func TestDAGGetBatches_NodesWithoutEdges(t *testing.T) { + d := NewDAG() + d.AddNode("a") + d.AddNode("b") + // a and b have no edges — both root nodes + batches, err := d.GetBatches() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(batches) != 1 { + t.Fatalf("expected 1 batch, got %d: %v", len(batches), batches) + } + if !containsAll(batches[0], "a", "b") { + t.Errorf("batch 0 should contain [a, b], got %v", batches[0]) + } +} + +// helper: checks slice contains exactly these elements (order-independent) +func containsExactly(slice []string, items ...string) bool { + if len(slice) != len(items) { + return false + } + return containsAll(slice, items...) +} + +// helper: checks slice contains all given items +func containsAll(slice []string, items ...string) bool { + set := make(map[string]bool) + for _, s := range slice { + set[s] = true + } + for _, item := range items { + if !set[item] { + return false + } + } + return len(slice) == len(items) +}