Skip to content

perf: faster package-lock tree creation - #20

Merged
miiila merged 10 commits into
masterfrom
perf/faster-npm-tree-parsing
Nov 5, 2018
Merged

perf: faster package-lock tree creation#20
miiila merged 10 commits into
masterfrom
perf/faster-npm-tree-parsing

Conversation

@miiila

@miiila miiila commented Sep 2, 2018

Copy link
Copy Markdown
Contributor

🚧 DO NOT MERGE 🚧

  • Tests written and linted ℹ︎
  • Documentation written ℹ︎
  • Commit history is tidy ℹ︎

What this does

Big package-lock.json parsing was slow. This PR introduces better parsing algorithm, which speeds up parsing a lot.

Instructions on how to run this locally, background context, what to review, questions…
Proper testing is still needed to be sure that functionality is the same. Tests are passing*, but they can't cover everything. However, it's pretty hard to verify what's right for the output with ~million lines.

* I am aware about one failing, it's a topic for the discussion about the meaning of depType property.

Before:

$ time parse-nodejs-lockfile > /dev/null
parse-nodejs-lockfile > /dev/null  30.25s user 1.24s system 123% cpu 25.522 total

After:

$ time node ../lockfile-parser/bin > /dev/null
node ../lockfile-parser/bin > /dev/null  0.39s user 0.06s system 103% cpu 0.436 total

30.25s vs 0.39s

@miiila miiila self-assigned this Sep 2, 2018
Comment thread lib/parsers/package-lock-parser.ts Outdated
}

// prepare a flat map, where keys are path, where dependencies are listed
const depMap: DepMap = await this.flattenLockfile(lockfile);

@michael-go michael-go Sep 3, 2018

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pretty sure all this async/await in this file is not needed. given no async IO is happening here, the async/await would still block the even-loop until done. So it only adds CPU overhead and complicates the code with various Promise.all()s and stuff

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, that's one of the thing I need your help and expertise - it's not needed now, but this package can end up being used in a deployed service, so we need to be sure we won't block event loop there. I know (from your talk) can push event loop to tick by setImmediate(), but I'd like to discuss the right approach.

Comment thread lib/parsers/package-lock-parser.ts Outdated

await Promise.all(_.entries(depQueue).map(async ([depKey, dep]) => {
let i = 0;
while (i < dep.dependenciesPathsToProcess.length) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's a while inside a Promise.all - map inside a while - maybe we can simplify it?

@miiila
miiila force-pushed the perf/faster-npm-tree-parsing branch from b007b66 to 0effcfe Compare September 3, 2018 09:37
@miiila
miiila force-pushed the perf/faster-npm-tree-parsing branch from 0effcfe to a9b6598 Compare October 3, 2018 19:51
@CLAassistant

CLAassistant commented Oct 3, 2018

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

Comment thread lib/parsers/package-lock-parser.ts Outdated
function setImmediatePromise() {
return new Promise((resolve, reject) => {
return setImmediate((err) => {
if (err) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setImmediate can't call callback with error. So, function setImmediatePromise() { return new Promise(resolve => setImmediate(resolve)) }

Comment thread lib/parsers/package-lock-parser.ts
@miiila
miiila force-pushed the perf/faster-npm-tree-parsing branch 2 times, most recently from 2950aec to 15afacc Compare October 11, 2018 12:05
depTree.dependencies[dep.name] = await this.buildSubTreeRecursiveFromPackageLock(
dep.name, ['dependencies'], packageLock, [], dep.dev);
}));
// prepare a flat map, where dependency path is a key to dependency object

@michael-go michael-go Oct 14, 2018

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A comment about the if (._isEmpty(...)) above, unrelated to this PR: what will happen if both dependencies and devDepenedencies are empty?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would bail out, but it's been fixed in #23

@michael-go michael-go left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is really great 👏

I added mostly minor comments.

let's also add a test case where the cycle involves the root node (an example real pkg where it kinda happens: https://www.npmjs.com/package/trucolor)

Comment thread lib/parsers/package-lock-parser.ts Outdated

private createGraphOfDependencies(depMap: DepMap): graphlib.Graph {
const depGraph = new graphlib.Graph();
for (const depName of Object.keys(depMap)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's better to name depName as depPath, like below, right? And actually depKey as used in flattenLockfile is even better I think.

Comment thread lib/parsers/package-lock-parser.ts Outdated
private createGraphOfDependencies(depMap: DepMap): graphlib.Graph {
const depGraph = new graphlib.Graph();
for (const depName of Object.keys(depMap)) {
depGraph.setNode(depName, depName);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the label (second param) is optional & I think you don't use it anywhere

Comment thread lib/parsers/package-lock-parser.ts Outdated
Once completed for all nodes in a cycle, original cyclic nodes can
be removed.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove this blank line? 🙈 sorry

Comment thread lib/parsers/package-lock-parser.ts Outdated
const depGraph: graphlib.Graph = this.createGraphOfDependencies(depMap);

// topological sort will be applied and it requires acyclic graphs
let cycleStarts = {}; // cycle starts are need for top level dependencies

@michael-go michael-go Oct 14, 2018

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please try to improve this comment about cycleStarts. And typing it will help too.

Comment thread lib/parsers/package-lock-parser.ts Outdated
const newNode = start + uuid();
cycleStarts[start] = newNode;
depMap[newNode] = depMap[start];
// update depMap with new node

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

guess the comment is for one line above?

Comment thread lib/parsers/package-lock-parser.ts Outdated
// 1. Create a uniqe duplicate of entry node (without edges)
const newNode = start + uuid();
cycleStarts[start] = newNode;
depMap[newNode] = depMap[start];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why here the value is not _.cloneDeep-ed like done in similar operation in walkCycleRec() ?

depTree.dependencies[dep.name] = dep.dev ?
this.setDevDepRec(_.cloneDeep(depTrees[depName])) : depTrees[depName];
}
}

@michael-go michael-go Oct 14, 2018

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would add the seImmediatePromise() call in this loop too, as the setDevDepRec might be costly (maybe call it only if dep.dev)

Comment thread lib/parsers/package-lock-parser.ts Outdated
for (const dep of topLevelDeps) {
// if any of top level dependencies is a part of cycle
// it now has a different item in the map
const depName = cycleStarts[dep.name] ? cycleStarts[dep.name] : dep.name;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can be simplified to:

const depName = cycleStarts[dep.name] || dep.name;

Comment thread lib/parsers/package-lock-parser.ts Outdated

if (!depMap[depName]) {
throw new Error(`Dependency ${depName} was not found in package-lock.json.
Your package.json and package-lock.json are probably out of sync.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when playing with some packages, this error happened to me not because the files were out-of-sync, but because one transitive dep failed to install. So this can happen sometimes not related to top-level deps. FYI

Comment thread lib/parsers/package-lock-parser.ts Outdated
depNode.requires = Object.keys(dep.requires);
}

const depKey: string[] = [...path, depName];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would call this depPath/depPathArray, and depKey would be the join-ed string

@michael-go michael-go left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the improvements @miiila . approved this PR. Still worry a-little that this won't handle a case where there is a cycle that involves the root package, and it would be great if you can add a test case for this.

Comment thread lib/parsers/package-lock-parser.ts Outdated
// Since this code doesn't handle any I/O or network, we need to force
// event loop to tick while being used in server for request processing
await setImmediatePromise();
await this.setImmediatePromise();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's a minor thing, but would personally prefer the setImmediatePromise to be a static or even global function as it doesn't use any internal state of the the class instance.

Comment thread lib/parsers/package-lock-parser.ts Outdated
To keep an order of algorithm steps readable, function is defined on-the-fly
Arrow function is used for calling `this` without .bind(this) in the end
*/
const acyclicDuplicationRec = (node, traversed, currentCycle, nodeCopy) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is great - and it will be even more readable if this definition moves 2 lines up, outside of the for loop. This way the algorithm can be read more outside-in.

Comment thread lib/parsers/package-lock-parser.ts Outdated
delete dep.requires;
depTrees[depKey] = {...dep as PkgTree};
const pkgTree: PkgTree = {
cyclic: dep.cyclic,

@michael-go michael-go Oct 26, 2018

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI, I see tests fail as cyclic & hasDevDependencies are always set although they are optional and sometimes not defined on dep.

@miiila
miiila force-pushed the perf/faster-npm-tree-parsing branch from 621ba49 to e6ad71c Compare November 2, 2018 16:45
@miiila

miiila commented Nov 2, 2018

Copy link
Copy Markdown
Contributor Author

@michael-go I did my best to add a test with self-reference cycle. This was so far the best I was able to create, because I'm not aware about any existing package, which would transitively require itself (trucolor doesn't do it anymore, it transitively requires older version of itself 😂); since it has an impact on how packages are hoisted, it's not easy to manually adjuste package-lock.json to simulate this situation.

So I created that kind of package by myself and algorithm doesn't break - it identifies cycle one level lower than it really is, but that's the limitation I'm happy to live with.

@miiila
miiila merged commit 1255d8f into master Nov 5, 2018
@miiila
miiila deleted the perf/faster-npm-tree-parsing branch November 5, 2018 09:31
@snyksec

snyksec commented Nov 5, 2018

Copy link
Copy Markdown

🎉 This PR is included in version 1.7.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants