packages/aws-cdk-lib/pipelines/lib/helpers-internal/graph.ts (386 lines of code) (raw):
/**
* A library for nested graphs
*/
import { topoSort } from './toposort';
import { UnscopedValidationError } from '../../../core';
import { addAll, extract, flatMap, isDefined } from '../private/javascript';
export interface GraphNodeProps<A> {
readonly displayName?: string;
readonly data?: A;
}
export class GraphNode<A> {
public static of<A>(id: string, data: A, displayName?: string) {
return new GraphNode(id, { data, displayName });
}
public readonly dependencies: GraphNode<A>[] = [];
public readonly data?: A;
public readonly displayName?: string;
private _parentGraph?: Graph<A>;
constructor(public readonly id: string, props: GraphNodeProps<A> = {}) {
this.displayName = props.displayName;
this.data = props.data;
}
/**
* A graph-wide unique identifier for this node. Rendered by joining the IDs
* of all ancestors with hyphens.
*/
public get uniqueId(): string {
return this.ancestorPath(this.root).map(x => x.id).join('-');
}
/**
* The union of all dependencies of this node and the dependencies of all
* parent graphs.
*/
public get allDeps(): GraphNode<A>[] {
const fromParent = this.parentGraph?.allDeps ?? [];
return Array.from(new Set([...this.dependencies, ...fromParent]));
}
public dependOn(...dependencies: Array<GraphNode<A> | undefined>) {
if (dependencies.includes(this)) {
throw new UnscopedValidationError(`Cannot add dependency on self: ${this}`);
}
this.dependencies.push(...dependencies.filter(isDefined));
}
public ancestorPath(upTo: GraphNode<A>): GraphNode<A>[] {
let x: GraphNode<A> = this;
const ret = [x];
while (x.parentGraph && x.parentGraph !== upTo) {
x = x.parentGraph;
ret.unshift(x);
}
return ret;
}
public rootPath(): GraphNode<A>[] {
let x: GraphNode<A> = this;
const ret = [x];
while (x.parentGraph) {
x = x.parentGraph;
ret.unshift(x);
}
return ret;
}
public get root() {
let x: GraphNode<A> = this;
while (x.parentGraph) {
x = x.parentGraph;
}
return x;
}
public get rootGraph(): Graph<A> {
const root = this.root;
if (!(root instanceof Graph)) {
throw new UnscopedValidationError(`Expecting a graph as root, got: ${root}`);
}
return root;
}
public get parentGraph() {
return this._parentGraph;
}
/**
* @internal
*/
public _setParentGraph(parentGraph: Graph<A>) {
if (this._parentGraph) {
throw new UnscopedValidationError('Node already has a parent');
}
this._parentGraph = parentGraph;
}
public toString() {
return `${this.constructor.name}(${this.id})`;
}
}
/**
* A dependency set that is constructed over time
*
* It doesn't matter in what order sources and targets for the dependency
* relationship(s) get added. This class can serve as a synchronization
* point if the order in which graph nodes get added to the graph is not
* well-defined.
*
* You can think of a DependencyBuilder as a vertex that doesn't actually exist in the tree:
*
* ┌────┐ ┌────┐
* │ P1 │◀─┐ ┌──│ S1 │
* └────┘ │ .─. │ └────┘
* ├──( B )◀─┤
* ┌────┐ │ `─' │ ┌────┐
* │ P2 │◀─┘ └──│ S2 │
* └────┘ └────┘
*
* Ultimately leads to: { S1 -> P1, S1 -> P2, S2 -> P1, S2 -> P2 }.
*/
export class DependencyBuilder<A> {
private readonly _producers: GraphNode<A>[] = [];
private readonly _consumers: GraphNode<A>[] = [];
/**
* Add a producer: make all nodes added by 'dependBy' depend on these
*/
public dependOn(...targets: GraphNode<A>[]) {
for (const target of targets) {
for (const source of this._consumers) {
source.dependOn(target);
}
this._producers.push(target);
}
return this;
}
/**
* Add a consumer: make these nodes depend on all nodes added by 'dependOn'.
*/
public dependBy(...sources: GraphNode<A>[]) {
for (const source of sources) {
for (const target of this._producers) {
source.dependOn(target);
}
this._consumers.push(source);
}
return this;
}
/**
* Whether there are any consumers (nodes added by 'dependBy') but no producers (nodes added by 'dependOn')
*/
public get hasUnsatisfiedConsumers() {
return this._consumers.length > 0 && this._producers.length === 0;
}
public get consumers(): ReadonlyArray<GraphNode<A>> {
return this._consumers;
}
public consumersAsString() {
return this.consumers.map(c => `${c}`).join(',');
}
}
/**
* A set of dependency builders identified by a given key.
*/
export class DependencyBuilders<K, A=any> {
private readonly builders = new Map<K, DependencyBuilder<A>>();
public for(key: K) {
const b = this.builders.get(key);
if (b) { return b; }
const ret = new DependencyBuilder<A>();
this.builders.set(key, ret);
return ret;
}
/**
* @deprecated Use 'for'
*/
public get(key: K) {
return this.for(key);
}
public unsatisfiedBuilders() {
const ret = new Array<[K, DependencyBuilder<A>]>();
for (const [k, builder] of this.builders.entries()) {
if (builder.hasUnsatisfiedConsumers) {
ret.push([k, builder]);
}
}
return ret;
}
}
export interface GraphProps<A> extends GraphNodeProps<A> {
/**
* Initial nodes in the workflow
*/
readonly nodes?: GraphNode<A>[];
}
export class Graph<A> extends GraphNode<A> {
/**
* The 3rd parameter looks weird because it has to be structurally compatible with `GraphNode.of()`,
* but we want to add `displayName` at the end, really.
*/
public static override of<A, B>(id: string, data: A, displayNameOrNodes?: string | GraphNode<B>[], displayName?: string) {
const nodes = Array.isArray(displayNameOrNodes) ? displayNameOrNodes : undefined;
const displayName_ = Array.isArray(displayNameOrNodes) ? displayName : displayNameOrNodes;
return new Graph<A | B>(id, { data, nodes, displayName: displayName_ });
}
private readonly children = new Map<string, GraphNode<A>>();
constructor(name: string, props: GraphProps<A>={}) {
super(name, props);
if (props.nodes) {
this.add(...props.nodes);
}
}
public get nodes() {
return new Set(this.children.values());
}
public tryGetChild(name: string) {
return this.children.get(name);
}
public containsId(id: string) {
return this.tryGetChild(id) !== undefined;
}
public contains(node: GraphNode<A>) {
return this.nodes.has(node);
}
public add(...nodes: Array<GraphNode<A>>) {
for (const node of nodes) {
if (this.children.has(node.id)) {
throw new UnscopedValidationError(`Node with duplicate id: ${node.id}`);
}
node._setParentGraph(this);
this.children.set(node.id, node);
}
}
public absorb(other: Graph<A>) {
this.add(...other.nodes);
}
/**
* Return topologically sorted tranches of nodes at this graph level
*/
public sortedChildren(fail=true): GraphNode<A>[][] {
// Project dependencies to current children
const nodes = this.nodes;
const projectedDependencies = projectDependencies(this.deepDependencies(), (node) => {
while (!nodes.has(node) && node.parentGraph) {
node = node.parentGraph;
}
return nodes.has(node) ? [node] : [];
});
return topoSort(nodes, projectedDependencies, fail);
}
/**
* Return a topologically sorted list of non-Graph nodes in the entire subgraph
*/
public sortedLeaves(): GraphNode<A>[][] {
// Project dependencies to leaf nodes
const descendantsMap = new Map<GraphNode<A>, GraphNode<A>[]>();
findDescendants(this);
function findDescendants(node: GraphNode<A>): GraphNode<A>[] {
const ret: GraphNode<A>[] = [];
if (node instanceof Graph) {
for (const child of node.nodes) {
ret.push(...findDescendants(child));
}
} else {
ret.push(node);
}
descendantsMap.set(node, ret);
return ret;
}
const projectedDependencies = projectDependencies(this.deepDependencies(), (node) => descendantsMap.get(node) ?? []);
return topoSort(new Set(projectedDependencies.keys()), projectedDependencies);
}
public render() {
const lines = new Array<string>();
recurse(this, '', true);
return lines.join('\n');
function recurse(x: GraphNode<A>, indent: string, last: boolean) {
const bullet = last ? '└─' : '├─';
const follow = last ? ' ' : '│ ';
lines.push(`${indent} ${bullet} ${x}${depString(x)}`);
if (x instanceof Graph) {
let i = 0;
const sortedNodes = Array.prototype.concat.call([], ...x.sortedChildren(false));
for (const child of sortedNodes) {
recurse(child, `${indent} ${follow} `, i++ == x.nodes.size - 1);
}
}
}
function depString(node: GraphNode<A>) {
if (node.dependencies.length > 0) {
return ` -> ${Array.from(node.dependencies).join(', ')}`;
}
return '';
}
}
public renderDot() {
const lines = new Array<string>();
lines.push('digraph G {');
lines.push(' # Arrows represent an "unlocks" relationship (opposite of dependency). So chosen');
lines.push(' # because the layout looks more natural that way.');
lines.push(' # To represent subgraph dependencies, subgraphs are represented by BEGIN/END nodes.');
lines.push(' # To render: `dot -Tsvg input.dot > graph.svg`, open in a browser.');
lines.push(' node [shape="box"];');
for (const child of this.nodes) {
recurse(child);
}
lines.push('}');
return lines.join('\n');
function recurse(node: GraphNode<A>) {
let dependencySource;
if (node instanceof Graph) {
lines.push(`${graphBegin(node)} [shape="cds", style="filled", fillcolor="#b7deff"];`);
lines.push(`${graphEnd(node)} [shape="cds", style="filled", fillcolor="#b7deff"];`);
dependencySource = graphBegin(node);
} else {
dependencySource = nodeLabel(node);
lines.push(`${nodeLabel(node)};`);
}
for (const dep of node.dependencies) {
const dst = dep instanceof Graph ? graphEnd(dep) : nodeLabel(dep);
lines.push(`${dst} -> ${dependencySource};`);
}
if (node instanceof Graph && node.nodes.size > 0) {
for (const child of node.nodes) {
recurse(child);
}
// Add dependency arrows between the "subgraph begin" and the first rank of
// the children, and the last rank of the children and "subgraph end" nodes.
const sortedChildren = node.sortedChildren(false);
for (const first of sortedChildren[0]) {
const src = first instanceof Graph ? graphBegin(first) : nodeLabel(first);
lines.push(`${graphBegin(node)} -> ${src};`);
}
for (const last of sortedChildren[sortedChildren.length - 1]) {
const dst = last instanceof Graph ? graphEnd(last) : nodeLabel(last);
lines.push(`${dst} -> ${graphEnd(node)};`);
}
}
}
function id(node: GraphNode<A>) {
return node.rootPath().slice(1).map(n => n.id).join('.');
}
function nodeLabel(node: GraphNode<A>) {
return `"${id(node)}"`;
}
function graphBegin(node: Graph<A>) {
return `"BEGIN ${id(node)}"`;
}
function graphEnd(node: Graph<A>) {
return `"END ${id(node)}"`;
}
}
public consoleLog(_indent: number = 0) {
process.stdout.write(this.render() + '\n');
}
/**
* Return the union of all dependencies of the descendants of this graph
*/
private deepDependencies() {
const ret = new Map<GraphNode<A>, Set<GraphNode<A>>>();
for (const node of this.nodes) {
recurse(node);
}
return ret;
function recurse(node: GraphNode<A>) {
let deps = ret.get(node);
if (!deps) {
ret.set(node, deps = new Set());
}
for (let dep of node.dependencies) {
deps.add(dep);
}
if (node instanceof Graph) {
for (const child of node.nodes) {
recurse(child);
}
}
}
}
/**
* Return all non-Graph nodes
*/
public allLeaves(): GraphNodeCollection<A> {
const ret: GraphNode<A>[] = [];
recurse(this);
return new GraphNodeCollection(ret);
function recurse(node: GraphNode<A>) {
if (node instanceof Graph) {
for (const child of node.nodes) {
recurse(child);
}
} else {
ret.push(node);
}
}
}
}
/**
* A collection of graph nodes
*/
export class GraphNodeCollection<A> {
public readonly nodes: GraphNode<A>[];
constructor(nodes: Iterable<GraphNode<A>>) {
this.nodes = Array.from(nodes);
}
/**
* Add one or more dependencies to all nodes in the collection
*/
public dependOn(...dependencies: Array<GraphNode<A> | undefined>) {
for (const node of this.nodes) {
node.dependOn(...dependencies.filter(isDefined));
}
}
/**
* Return the topographically first node in the collection
*/
public first() {
const nodes = new Set(this.nodes);
const sorted = this.nodes[0].rootGraph.sortedLeaves();
for (const tranche of sorted) {
for (const node of tranche) {
if (nodes.has(node)) {
return node;
}
}
}
throw new UnscopedValidationError(`Could not calculate first node between ${this}`);
}
/**
* Returns the graph node that's shared between these nodes
*/
public commonAncestor() {
const paths = new Array<GraphNode<A>[]>();
for (const x of this.nodes) {
paths.push(x.rootPath());
}
if (paths.length === 0) {
throw new UnscopedValidationError('Cannot find common ancestor between an empty set of nodes');
}
if (paths.length === 1) {
const path = paths[0];
if (path.length < 2) {
throw new UnscopedValidationError(`Cannot find ancestor of node without ancestor: ${path[0]}`);
}
return path[path.length - 2];
}
const originalPaths = [...paths];
// Remove the first element of every path as long as the 2nd elements are all
// the same -- this leaves the shared element in first place.
//
// A, B, C, 1, 2 }---> C
// A, B, C, 3 }
while (paths.every(path => paths[0].length >= 2 && path.length >= 2 && path[1] === paths[0][1])) {
for (const path of paths) {
path.shift();
}
}
// If any of the paths are left with 1 element, there's no shared parent.
if (paths.some(path => path.length < 2)) {
throw new UnscopedValidationError(`Could not determine a shared parent between nodes: ${originalPaths.map(nodes => nodes.map(n => n.id).join('/'))}`);
}
return paths[0][0];
}
public toString() {
return this.nodes.map(n => `${n}`).join(', ');
}
}
/**
* Dependency map of nodes in this graph, taking into account dependencies between nodes in subgraphs
*
* Guaranteed to return an entry in the map for every node in the current graph.
*/
function projectDependencies<A>(dependencies: Map<GraphNode<A>, Set<GraphNode<A>>>, project: (x: GraphNode<A>) => GraphNode<A>[]) {
// Project keys
for (const node of dependencies.keys()) {
const projectedNodes = project(node);
if (projectedNodes.length === 1 && projectedNodes[0] === node) { continue; } // Nothing to do, just for efficiency
const deps = extract(dependencies, node)!;
for (const projectedNode of projectedNodes) {
addAll(dependencies.get(projectedNode)!, deps);
}
}
// Project values. Ignore self-dependencies, they were just between nodes that were collapsed into the same node.
for (const [node, deps] of dependencies.entries()) {
const depset = new Set(flatMap(deps, project));
depset.delete(node);
dependencies.set(node, depset);
}
return dependencies;
}
export function isGraph<A>(x: GraphNode<A>): x is Graph<A> {
return x instanceof Graph;
}