Skip to content

Commit c9a520f

Browse files
authored
Merge pull request #149 from snyk/feature/count-paths-to-root-perf-improvements
fix(ts): improve runtime performance of countPathsToRoot
2 parents 14b8045 + 7c31989 commit c9a520f

3 files changed

Lines changed: 133 additions & 19 deletions

File tree

src/core/dep-graph.ts

Lines changed: 57 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -153,16 +153,7 @@ class DepGraphImpl implements types.DepGraphInternal {
153153
let count = 0;
154154
const limit = opts?.limit;
155155
for (const nodeId of this.getPkgNodeIds(pkg)) {
156-
if (this._countNodePathsToRootCache.has(nodeId)) {
157-
count += this._countNodePathsToRootCache.get(nodeId)!;
158-
} else {
159-
const c = this.countNodePathsToRoot(nodeId, limit);
160-
// don't cache if a limit was supplied
161-
if (!limit) {
162-
this._countNodePathsToRootCache.set(nodeId, c);
163-
}
164-
count += c;
165-
}
156+
count += this.countNodePathsToRoot(nodeId, limit);
166157
if (limit && count >= limit) {
167158
return limit;
168159
}
@@ -379,7 +370,50 @@ class DepGraphImpl implements types.DepGraphInternal {
379370
return allPaths;
380371
}
381372

382-
private countNodePathsToRoot(
373+
private countNodePathsToRoot(nodeId: string, limit?: number): number {
374+
if (this._countNodePathsToRootCache.has(nodeId)) {
375+
return this._countNodePathsToRootCache.get(nodeId)!;
376+
}
377+
378+
let count: number;
379+
if (nodeId === this._rootNodeId) {
380+
count = 1;
381+
} else if (this.hasCycles()) {
382+
// When cycles exist, we must walk every path individually to avoid
383+
// counting paths that loop back on themselves.
384+
count = this.countNodePathsToRootBacktracking(nodeId, limit);
385+
} else {
386+
// Without cycles, we can compute path counts by summing each parent's
387+
// count. This avoids the exponential cost of enumerating every path.
388+
count = this.countNodePathsToRootMemoized(nodeId, limit);
389+
}
390+
391+
if (!limit) {
392+
this._countNodePathsToRootCache.set(nodeId, count);
393+
}
394+
return count;
395+
}
396+
397+
/**
398+
* Memoized path counting, the count is the sum of the counts of the parents.
399+
* Only valid for acyclic graphs.
400+
*/
401+
private countNodePathsToRootMemoized(nodeId: string, limit = 0): number {
402+
let count = 0;
403+
for (const parentNodeId of this.getNodeParentsNodeIds(nodeId)) {
404+
count += this.countNodePathsToRoot(parentNodeId, limit);
405+
if (limit && count >= limit) {
406+
return limit;
407+
}
408+
}
409+
return count;
410+
}
411+
412+
/**
413+
* Backtracking approach that enumerates simple paths.
414+
* Required for cyclic graphs where memoization is not applicable.
415+
*/
416+
private countNodePathsToRootBacktracking(
383417
nodeId: string,
384418
limit = 0,
385419
count = 0,
@@ -390,12 +424,18 @@ class DepGraphImpl implements types.DepGraphInternal {
390424
}
391425
visited.add(nodeId);
392426
for (const parentNodeId of this.getNodeParentsNodeIds(nodeId)) {
393-
if (!visited.has(parentNodeId)) {
394-
count = this.countNodePathsToRoot(parentNodeId, limit, count, visited);
395-
if (limit && count >= limit) {
396-
visited.delete(nodeId);
397-
return limit;
398-
}
427+
if (visited.has(parentNodeId)) {
428+
continue;
429+
}
430+
count = this.countNodePathsToRootBacktracking(
431+
parentNodeId,
432+
limit,
433+
count,
434+
visited,
435+
);
436+
if (limit && count >= limit) {
437+
visited.delete(nodeId);
438+
return limit;
399439
}
400440
}
401441
visited.delete(nodeId);

src/graphlib/graph.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ export class Graph {
137137
}
138138

139139
nodeCount(): number {
140-
return this.nodes.length;
140+
return this.nodes().length;
141141
}
142142

143143
nodes(): string[] {
@@ -378,7 +378,7 @@ export class Graph {
378378
}
379379

380380
edgeCount(): number {
381-
return this.edges.length;
381+
return this.edges().length;
382382
}
383383

384384
edges(): Edge[] {

test/core/count-paths-to-root.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,35 @@
1+
import { performance } from 'perf_hooks';
12
import * as depGraphLib from '../../src';
23
import * as helpers from '../helpers';
34

5+
function buildDiamondChain(layers: number): depGraphLib.DepGraph {
6+
const builder = new depGraphLib.DepGraphBuilder(
7+
{ name: 'npm' },
8+
{ name: 'root', version: '1.0.0' },
9+
);
10+
const rootNodeId = 'root-node';
11+
12+
let prevNodeId = rootNodeId;
13+
for (let i = 0; i < layers; i++) {
14+
const leftId = `left-${i}`;
15+
const rightId = `right-${i}`;
16+
const joinId = `join-${i}`;
17+
18+
builder.addPkgNode({ name: leftId, version: '1.0.0' }, leftId);
19+
builder.addPkgNode({ name: rightId, version: '1.0.0' }, rightId);
20+
builder.addPkgNode({ name: joinId, version: '1.0.0' }, joinId);
21+
22+
builder.connectDep(prevNodeId, leftId);
23+
builder.connectDep(prevNodeId, rightId);
24+
builder.connectDep(leftId, joinId);
25+
builder.connectDep(rightId, joinId);
26+
27+
prevNodeId = joinId;
28+
}
29+
30+
return builder.build();
31+
}
32+
433
describe('countPathsToRoot', () => {
534
describe('basic', () => {
635
const depGraphData = helpers.loadFixture('plain-dep-graph.json');
@@ -122,4 +151,49 @@ describe('countPathsToRoot', () => {
122151
);
123152
});
124153
});
154+
155+
describe('diamond chain (exponential paths)', () => {
156+
it('computes correct count for a small diamond chain', () => {
157+
const graph = buildDiamondChain(5);
158+
const leafPkg = { name: 'join-4', version: '1.0.0' };
159+
expect(graph.countPathsToRoot(leafPkg)).toBe(Math.pow(2, 5));
160+
});
161+
162+
it('handles 50-layer diamond chain (2^50 paths) efficiently', () => {
163+
const layers = 50;
164+
const graph = buildDiamondChain(layers);
165+
const leafPkg = { name: `join-${layers - 1}`, version: '1.0.0' };
166+
167+
const start = performance.now();
168+
const count = graph.countPathsToRoot(leafPkg);
169+
const elapsed = performance.now() - start;
170+
171+
expect(count).toBe(Math.pow(2, layers));
172+
expect(elapsed).toBeLessThan(5000);
173+
});
174+
175+
it('respects limit on exponential-path graph', () => {
176+
const graph = buildDiamondChain(50);
177+
const leafPkg = { name: 'join-49', version: '1.0.0' };
178+
expect(graph.countPathsToRoot(leafPkg, { limit: 100 })).toBe(100);
179+
});
180+
181+
it('counts scale linearly with layers, not exponentially', () => {
182+
const small = buildDiamondChain(100);
183+
const large = buildDiamondChain(400);
184+
185+
const startSmall = performance.now();
186+
small.countPathsToRoot({ name: 'join-99', version: '1.0.0' });
187+
const timeSmall = performance.now() - startSmall || 1;
188+
189+
const startLarge = performance.now();
190+
large.countPathsToRoot({ name: 'join-399', version: '1.0.0' });
191+
const timeLarge = performance.now() - startLarge || 1;
192+
193+
// With DP, 4x the layers should take ~4x the time (linear).
194+
// With backtracking, it would be 2^300 times slower (never finish).
195+
// Use a generous bound to avoid flakiness.
196+
expect(timeLarge).toBeLessThan(timeSmall * 50);
197+
});
198+
});
125199
});

0 commit comments

Comments
 (0)