backend-app-api: refactor dependency tree to use values instead of IDs

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-08-11 14:20:59 +02:00
parent dd89ede423
commit 6ed4759db1
3 changed files with 87 additions and 88 deletions
@@ -17,11 +17,13 @@
import { DependencyTree } from './DependencyTree';
describe('DependencyTree', () => {
it('should be empty', () => {
it('should be empty', async () => {
const empty = DependencyTree.fromMap({});
expect(Array.from(empty.nodes)).toEqual([]);
expect(empty.findUnsatisfiedDeps()).toEqual([]);
expect(empty.detectCircularDependency()).toBeUndefined();
await expect(
empty.parallelTopologicalTraversal(async id => id),
).resolves.toEqual([]);
});
it('should detect circular dependencies', () => {
@@ -89,14 +91,14 @@ describe('DependencyTree', () => {
DependencyTree.fromMap({
1: { consumes: ['a'] },
}).findUnsatisfiedDeps(),
).toEqual([{ id: '1', unsatisfied: ['a'] }]);
).toEqual([{ value: '1', unsatisfied: ['a'] }]);
expect(
DependencyTree.fromMap({
1: { produces: ['a'], consumes: ['b'] },
2: { produces: ['b'], consumes: ['a', 'd', 'e'] },
}).findUnsatisfiedDeps(),
).toEqual([{ id: '2', unsatisfied: ['d', 'e'] }]);
).toEqual([{ value: '2', unsatisfied: ['d', 'e'] }]);
expect(
DependencyTree.fromMap({
@@ -106,8 +108,8 @@ describe('DependencyTree', () => {
4: { produces: [], consumes: ['c', 'a'] },
}).findUnsatisfiedDeps(),
).toEqual([
{ id: '2', unsatisfied: ['d', 'e'] },
{ id: '4', unsatisfied: ['c'] },
{ value: '2', unsatisfied: ['d', 'e'] },
{ value: '4', unsatisfied: ['c'] },
]);
});
@@ -14,111 +14,109 @@
* limitations under the License.
*/
import { ForwardedError, InputError } from '@backstage/errors';
interface NodeInput {
id: string;
interface NodeInput<T> {
value: T;
consumes?: Iterable<string>;
produces?: Iterable<string>;
}
/** @internal */
class Node {
static from(input: NodeInput) {
return new Node(
input.id,
class Node<T> {
static from<T>(input: NodeInput<T>) {
return new Node<T>(
input.value,
input.consumes ? new Set(input.consumes) : new Set(),
input.produces ? new Set(input.produces) : new Set(),
);
}
private constructor(
readonly id: string,
readonly value: T,
readonly consumes: Set<string>,
readonly produces: Set<string>,
) {}
}
/** @internal */
export class DependencyTree {
static fromMap(nodes: Record<string, Omit<NodeInput, 'id'>>) {
export class DependencyTree<T> {
static fromMap(
nodes: Record<string, Omit<NodeInput<unknown>, 'value'>>,
): DependencyTree<string> {
return this.fromIterable(
Object.entries(nodes).map(([id, node]) => ({ id, ...node })),
Object.entries(nodes).map(([key, node]) => ({
value: String(key),
...node,
})),
);
}
static fromIterable(nodeInputs: Iterable<NodeInput>) {
const nodes = new Map<string, Node>();
static fromIterable<T>(
nodeInputs: Iterable<NodeInput<T>>,
): DependencyTree<T> {
const nodes = new Array<Node<T>>();
for (const nodeInput of nodeInputs) {
const node = Node.from(nodeInput);
if (nodes.has(node.id)) {
throw new InputError(`Duplicate node with id ${node.id}`);
}
nodes.set(node.id, node);
nodes.push(Node.from(nodeInput));
}
return new DependencyTree(nodes);
}
#nodes: Array<Node<T>>;
#allProduced: Set<string>;
#allConsumed: Set<string>;
#consumedBy: Map<string, Set<string>>;
private constructor(readonly nodes: Map<string, Node>) {
private constructor(nodes: Array<Node<T>>) {
this.#nodes = nodes;
this.#allProduced = new Set();
this.#allConsumed = new Set();
this.#consumedBy = new Map();
for (const node of this.nodes.values()) {
for (const node of this.#nodes.values()) {
for (const produced of node.produces) {
this.#allProduced.add(produced);
}
for (const consumed of node.consumes) {
this.#allConsumed.add(consumed);
if (!this.#consumedBy.get(consumed)?.add(node.id)) {
this.#consumedBy.set(consumed, new Set([node.id]));
}
}
}
}
findUnsatisfiedDeps(): Array<{ id: string; unsatisfied: string[] }> {
findUnsatisfiedDeps(): Array<{ value: T; unsatisfied: string[] }> {
const unsatisfiedDependencies = [];
for (const node of this.nodes.values()) {
for (const node of this.#nodes.values()) {
const unsatisfied = Array.from(node.consumes).filter(
id => !this.#allProduced.has(id),
);
if (unsatisfied.length > 0) {
unsatisfiedDependencies.push({ id: node.id, unsatisfied });
unsatisfiedDependencies.push({ value: node.value, unsatisfied });
}
}
return unsatisfiedDependencies;
}
detectCircularDependency(): string[] | undefined {
for (const nodeId of this.nodes.keys()) {
const visited = new Set<string>();
const stack = new Array<[id: string, path: string[]]>([nodeId, [nodeId]]);
detectCircularDependency(): T[] | undefined {
for (const startNode of this.#nodes) {
const visited = new Set<Node<T>>();
const stack = new Array<[node: Node<T>, path: T[]]>([
startNode,
[startNode.value],
]);
while (stack.length > 0) {
const [id, path] = stack.pop()!;
if (visited.has(id)) {
const [node, path] = stack.pop()!;
if (visited.has(node)) {
continue;
}
visited.add(id);
const node = this.nodes.get(id);
if (node) {
for (const produced of node.produces) {
const consumers = this.#consumedBy.get(produced);
if (consumers) {
for (const consumer of consumers) {
if (consumer === nodeId) {
return [...path, nodeId];
}
if (!visited.has(consumer)) {
stack.push([consumer, [...path, consumer]]);
}
}
visited.add(node);
for (const produced of node.produces) {
const consumerNodes = this.#nodes.filter(other =>
other.consumes.has(produced),
);
for (const consumer of consumerNodes) {
if (consumer === startNode) {
return [...path, startNode.value];
}
if (!visited.has(consumer)) {
stack.push([consumer, [...path, consumer.value]]);
}
}
}
@@ -127,14 +125,14 @@ export class DependencyTree {
return undefined;
}
async parallelTopologicalTraversal<T>(
fn: (nodeId: string) => Promise<T>,
): Promise<T[]> {
async parallelTopologicalTraversal<TResult>(
fn: (value: T) => Promise<TResult>,
): Promise<TResult[]> {
const allProduced = this.#allProduced;
const producedSoFar = new Set<string>();
const waiting = new Set(this.nodes.values());
const visited = new Set<string>();
const results = new Array<T>();
const waiting = new Set(this.#nodes.values());
const visited = new Set<Node<T>>();
const results = new Array<TResult>();
let inFlight = 0;
async function processMoreNodes() {
@@ -168,15 +166,13 @@ export class DependencyTree {
await Promise.all(nodesToProcess.map(processNode));
}
async function processNode(node: Node) {
visited.add(node.id);
async function processNode(node: Node<T>) {
visited.add(node);
inFlight += 1;
try {
const result = await fn(node.id);
results.push(result);
} catch (error) {
throw new ForwardedError(`Failed at ${node.id}`, error);
}
const result = await fn(node.value);
results.push(result);
node.produces.forEach(produced => producedSoFar.add(produced));
inFlight -= 1;
await processMoreNodes();
@@ -214,36 +214,37 @@ export class BackendInitializer {
const modules = moduleInits.get(pluginId);
if (modules) {
const tree = DependencyTree.fromIterable(
Array.from(modules).map(([id, { provides, consumes }]) => ({
id,
Array.from(modules).map(([moduleId, moduleInit]) => ({
value: { moduleId, moduleInit },
// Relationships are reversed at this point since we're only interested in the extension points.
// If a modules provides extension point A we want it to be initialized AFTER all modules
// that depend on extension point A, so that they can provide their extensions.
consumes: Array.from(provides).map(p => p.id),
produces: Array.from(consumes).map(c => c.id),
consumes: Array.from(moduleInit.provides).map(p => p.id),
produces: Array.from(moduleInit.consumes).map(c => c.id),
})),
);
const circular = tree.detectCircularDependency();
if (circular) {
throw new ConflictError(
`Circular dependency detected for modules of plugin '${pluginId}', '${circular.join(
"' -> '",
)}'`,
`Circular dependency detected for modules of plugin '${pluginId}', ${circular
.map(({ moduleId }) => `'${moduleId}'`)
.join(' -> ')}`,
);
}
await tree.parallelTopologicalTraversal(async moduleId => {
const moduleInit = modules.get(moduleId)!;
const moduleDeps = await this.#getInitDeps(
moduleInit.init.deps,
pluginId,
);
await moduleInit.init.func(moduleDeps).catch(error => {
throw new ForwardedError(
`Module '${moduleId}' for plugin '${pluginId}' startup failed`,
error,
await tree.parallelTopologicalTraversal(
async ({ moduleId, moduleInit }) => {
const moduleDeps = await this.#getInitDeps(
moduleInit.init.deps,
pluginId,
);
});
});
await moduleInit.init.func(moduleDeps).catch(error => {
throw new ForwardedError(
`Module '${moduleId}' for plugin '${pluginId}' startup failed`,
error,
);
});
},
);
}
// Once all modules have been initialized, we can initialize the plugin itself