feat(spec): Task 1 — DAG engine with topological sort and cycle detection

pull/31992/head
caretak3r 8 months ago
parent 2daac031c6
commit 52b62ea750

@ -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
}

@ -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)
}
Loading…
Cancel
Save