backend-app-api: initial dependency tree helper

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-08-11 09:56:22 +02:00
parent e2b6396a12
commit 983a2388e7
2 changed files with 263 additions and 0 deletions
@@ -0,0 +1,124 @@
/*
* Copyright 2023 The Backstage 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.
*/
import { DependencyTree } from './DependencyTree';
describe('DependencyTree', () => {
it('should be empty', () => {
const empty = DependencyTree.fromMap({});
expect(Array.from(empty.nodes)).toEqual([]);
expect(empty.findUnsatisfiedDeps()).toEqual([]);
expect(empty.detectCircularDependency()).toBeUndefined();
});
it('should reject multiple producers', () => {
expect(() =>
DependencyTree.fromMap({
1: { produces: ['a'] },
2: { produces: ['a'] },
}),
).toThrow(
"Dependency conflict detected, 'a' may not be produced by both '1' and '2'",
);
});
it('should detect circular dependencies', () => {
expect(
DependencyTree.fromMap({
1: {},
2: {},
3: {},
4: {},
}).detectCircularDependency(),
).toBeUndefined();
expect(
DependencyTree.fromMap({
1: { produces: ['a'] },
2: { consumes: ['a'], produces: ['b', 'c'] },
3: { consumes: ['b'] },
4: { consumes: ['c'] },
}).detectCircularDependency(),
).toBeUndefined();
expect(
DependencyTree.fromMap({
1: { produces: ['a'], consumes: ['a'] },
}).detectCircularDependency(),
).toEqual(['1', '1']);
expect(
DependencyTree.fromMap({
1: { produces: ['a'], consumes: ['b'] },
2: { produces: ['b'], consumes: ['a'] },
}).detectCircularDependency(),
).toEqual(['1', '2', '1']);
expect(
DependencyTree.fromMap({
1: { produces: ['a'] },
2: { produces: ['b'], consumes: ['a', 'e'] },
3: { produces: ['c'], consumes: ['b'] },
4: { produces: ['d', 'e'], consumes: ['c', 'a'] },
}).detectCircularDependency(),
).toEqual(['2', '3', '4', '2']);
});
it('should find unsatisfied dependencies', () => {
expect(
DependencyTree.fromMap({
1: {},
2: {},
3: {},
4: {},
}).findUnsatisfiedDeps(),
).toEqual([]);
expect(
DependencyTree.fromMap({
1: { produces: ['a'] },
2: { consumes: ['a'], produces: ['b', 'c'] },
3: { consumes: ['b'] },
4: { consumes: ['c'] },
}).findUnsatisfiedDeps(),
).toEqual([]);
expect(
DependencyTree.fromMap({
1: { consumes: ['a'] },
}).findUnsatisfiedDeps(),
).toEqual([{ id: '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'] }]);
expect(
DependencyTree.fromMap({
1: { produces: ['a'] },
2: { produces: ['b'], consumes: ['a', 'd', 'e'] },
3: { produces: [], consumes: ['b'] },
4: { produces: [], consumes: ['c', 'a'] },
}).findUnsatisfiedDeps(),
).toEqual([
{ id: '2', unsatisfied: ['d', 'e'] },
{ id: '4', unsatisfied: ['c'] },
]);
});
});
@@ -0,0 +1,139 @@
/*
* Copyright 2023 The Backstage 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.
*/
import { ConflictError, InputError } from '@backstage/errors';
interface NodeInput {
id: string;
consumes?: Iterable<string>;
produces?: Iterable<string>;
}
/** @internal */
class Node {
static from(input: NodeInput) {
return new Node(
input.id,
input.consumes ? new Set(input.consumes) : new Set(),
input.produces ? new Set(input.produces) : new Set(),
);
}
private constructor(
readonly id: string,
readonly consumes: Set<string>,
readonly produces: Set<string>,
) {}
}
/** @internal */
export class DependencyTree {
static fromMap(nodes: Record<string, Omit<NodeInput, 'id'>>) {
return this.fromIterable(
Object.entries(nodes).map(([id, node]) => ({ id, ...node })),
);
}
static fromIterable(nodeInputs: Iterable<NodeInput>) {
const nodes = new Map<string, Node>();
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);
}
return new DependencyTree(nodes);
}
#allProduced: Set<string>;
#allConsumed: Set<string>;
#producedBy: Map<string, string>;
#consumedBy: Map<string, Set<string>>;
private constructor(readonly nodes: Map<string, Node>) {
this.#allProduced = new Set();
this.#allConsumed = new Set();
this.#producedBy = new Map();
this.#consumedBy = new Map();
for (const node of this.nodes.values()) {
for (const produced of node.produces) {
this.#allProduced.add(produced);
if (this.#producedBy.has(produced)) {
throw new ConflictError(
`Dependency conflict detected, '${produced}' may not be produced by both '${this.#producedBy.get(
produced,
)}' and '${node.id}'`,
);
}
this.#producedBy.set(produced, node.id);
}
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[] }> {
const unsatisfiedDependencies = [];
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 });
}
}
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]]);
while (stack.length > 0) {
const [id, path] = stack.pop()!;
if (visited.has(id)) {
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]]);
}
}
}
}
}
}
}
return undefined;
}
}