using w4c_workflows.Models.Nodes; namespace w4c_workflows.Services.Nodes; /// /// Loop support for the node graph. An edge that closes a cycle is a /// loop-back edge: it must target a node whose blueprint declares /// (currently core.splitInBatches), /// and at run time it re-triggers that node for the next iteration instead of /// counting toward its readiness. /// /// Classification is derived from the edge set, not from traversal order: an edge /// u → v is a loop-back exactly when it lies on a cycle (the target /// v can reach u) and v is loop-capable. This is /// order-independent, so the compiler and a rebuilt persisted graph always agree /// regardless of task declaration order. /// public static class NodeGraphLinks { /// /// Returns the edges with set, or an /// error when a cycle does not pass through a loop-capable node. /// public static (List Edges, string? Error) MarkLoopBackEdges( IReadOnlyList nodes, IReadOnlyList edges) { var byId = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal); var adjacency = new Dictionary>(StringComparer.Ordinal); foreach (var edge in edges) { if (!adjacency.TryGetValue(edge.FromNodeId, out var outgoing)) adjacency[edge.FromNodeId] = outgoing = new List(); outgoing.Add(edge.ToNodeId); } // reach[v] = every node reachable from v via one or more edges. Edge u→v // closes a cycle iff u is reachable from v. var reach = new Dictionary>(StringComparer.Ordinal); foreach (var node in nodes) { var seen = new HashSet(StringComparer.Ordinal); var stack = new Stack(); stack.Push(node.Id); while (stack.Count > 0) { var current = stack.Pop(); if (!adjacency.TryGetValue(current, out var next)) continue; foreach (var target in next) { if (seen.Add(target)) stack.Push(target); } } reach[node.Id] = seen; } bool ClosesCycle(NodeGraphEdge edge) => reach.TryGetValue(edge.ToNodeId, out var fromTarget) && fromTarget.Contains(edge.FromNodeId); var marked = new List(edges.Count); foreach (var edge in edges) { var isLoopBack = ClosesCycle(edge) && byId[edge.ToNodeId].Blueprint.LoopBack; marked.Add(edge with { IsLoopBack = isLoopBack }); } // A cycle is only valid when it re-enters a loop-capable node. Group the // mutually-reachable nodes into SCCs and require a loop node in every // cyclic component; otherwise the graph would deadlock or spin. var error = ValidateCyclesHaveLoopNodes(nodes, adjacency, reach, byId); return (marked, error); } private static string? ValidateCyclesHaveLoopNodes( IReadOnlyList nodes, IReadOnlyDictionary> adjacency, IReadOnlyDictionary> reach, IReadOnlyDictionary byId) { var ids = nodes.Select(n => n.Id).ToList(); var assigned = new HashSet(StringComparer.Ordinal); foreach (var id in ids) { if (!assigned.Add(id)) continue; var members = new List { id }; foreach (var other in ids) { if (other == id || assigned.Contains(other)) continue; if (reach[id].Contains(other) && reach[other].Contains(id)) { members.Add(other); assigned.Add(other); } } var isCyclic = members.Count > 1 || adjacency.TryGetValue(id, out var self) && self.Contains(id); if (!isCyclic) continue; if (members.Any(m => byId[m].Blueprint.LoopBack)) continue; return $"a cycle involving {string.Join(", ", members)} is not allowed: " + "none of these nodes is a loop node (e.g. core.splitInBatches)"; } return null; } }