Skip to content

perf(nodejs): derive a monorepo function's dependency closure from the lockfile - #938

Merged
bnusunny merged 1 commit into
aws:developfrom
bnusunny:fix/nodejs-closure-from-lockfile
Oct 15, 2026
Merged

bnusunny merged 1 commit into
aws:developfrom
bnusunny:fix/nodejs-closure-from-lockfile

Conversation

@bnusunny

@bnusunny bnusunny commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Issue #, if available: N/A — a performance change on the path #933 added. No linked issue.

Description of changes

Goal: stop spawning one npm ls per function when building a workspaces monorepo in source.

#935 narrows a function's artifacts to its own dependencies by asking npm ls --all --parseable --omit=dev what that function resolves. Correct, and it costs an npm process per function — twenty sequential node startups on a 20-function monorepo.

The install already wrote that answer down. lockfile_closure.production_closure reads npm's hidden lockfile, node_modules/.package-lock.json, whose packages map is keyed by install path — so resolving a dependency is the walk node itself performs: look for <prefix>/node_modules/<name>, strip a segment, repeat.

npm invocations median wall clock
8 functions, npm ls 26 6.11 s
8 functions, lockfile 19 3.68 s
20 functions, npm ls 62 14.77 s
20 functions, lockfile 43 8.31 s

Two generated fixture sets, npm 11.19.0, Linux; medians of repeated runs.

The hidden lockfile, not the project's own. The in-source install runs npm install --no-save, which leaves package-lock.json untouched — so a manifest naming a dependency that lockfile has not recorded installs the package while the file still denies it exists. Only the hidden one records the tree npm reified.

Peers are followed. npm 7+ installs peer dependencies and npm ls --all walks them, so skipping them drops a package the function resolves.

npm ls remains the fallback, taken whenever the hidden lockfile cannot answer: absent (npm 6, or a tree another package manager built), stale against node_modules by npm's own mtime rule, too old a lockfileVersion, unreadable, or silent about this install directory. Artifacts, and the experimental-flag gating, are unchanged.

Description of how you validated changes

The lockfile's answer is checked against npm's, not against a fixture. test_the_lockfile_closure_agrees_with_npm_ls_on_a_real_install runs a real npm install on the workspaces-monorepo fixture and asserts both endpoints' closures are set-equal with npm ls. A unit test cannot establish this — it would compare this code against a fixture written from this code. That fixture now carries a peer edge (packages/shared peer-depends on fake-http-request) precisely because its absence let the peer bug through a passing test.

test_a_manifest_the_project_lockfile_has_not_recorded_still_reaches_the_artifacts builds the stale-lockfile case end to end and asserts the module reaches the artifacts.

Each fix is mutation-checked — reverting it fails its test:

change test that catches its removal
walk-up in _resolve_key the npm ls equivalence test
peerDependencies followed the same, via the fixture's peer edge
hidden lockfile instead of the project's test_a_manifest_the_project_lockfile_has_not_recorded...
>= in the staleness guard test_a_hidden_lockfile_as_recent_as_the_tree_is_trusted

That last one matters more than it looks: a fresh install writes the hidden lockfile and node_modules in the same clock tick, so a strict > would send every first build down the fallback with every test still green.

Unit tests cover the resolver directly — a member's own dependencies plus its shared workspace package, a conflicting version resolving to the nested copy, a member wanting the hoisted version getting both, dev dependencies and declined optional peers excluded — and every way it declines to answer. Three wiring tests pin which source was used (assert_not_called / assert_called_once_with), without which the lockfile path could stop being taken silently.

  • 880 unit tests pass; ruff check and black --check clean.
  • nodejs + esbuild integration: 302 passed, npm 11.19.0, Linux.
  • Hidden lockfile verified present on npm 8.19.4, 9.9.4, 10.9.9 and 11.19.0 — npm 8 writes lockfileVersion: 2, not the 3 npm's docs claim, and the resolver's floor of 2 accepts both.

Checklist

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

@bnusunny
bnusunny requested a review from a team as a code owner October 9, 2026 21:44

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: dfe97f9..8abc789
Files: 6
Comments: 1

Comment thread aws_lambda_builders/workflows/nodejs_npm/lockfile_closure.py Outdated

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: dfe97f9..1b5b92b
Files: 8
Comments: 1

Comment thread aws_lambda_builders/workflows/nodejs_npm/actions.py Outdated
@bnusunny
bnusunny force-pushed the fix/nodejs-closure-from-lockfile branch from 1b5b92b to fb8fcca Compare October 9, 2026 23:09
…pm ls

The artifacts link asks npm which packages a workspace function resolves, and
`npm ls --all --parseable --omit=dev` costs 0.33s per function - the largest
remaining per-function cost once the install is no longer the bottleneck. The
lockfile the build already locates describes the same resolved tree, so read it
instead: walk the lockfileVersion 2/3 `packages` map from the member's own entry,
resolve each name the way node does (`<key>/node_modules/<name>`, walking up),
follow `link: true` entries to their source directory, and skip anything marked
`dev`. A version 1 lockfile has no path map, so that falls back to `npm ls`, as
does a lockfile that cannot be read.

An entry can be in the lockfile without being on disk: `fsevents` is
darwin-only and optional, so webpack's and jest's closures list it while Linux
never installs it. `npm ls` reports the installed tree, so the resolver keeps
only directories that exist; a required dependency missing there means the
install itself failed, which the install step already surfaces.

Behaviour is unchanged, which is the point - the existing integration tests that
assert each function's artifacts hold its own dependencies and not its sibling's
are the regression proof, and they pass unchanged. The new unit tests cover the
resolver itself: nested copies, scoped names, workspace links, dev entries,
version 1 fallback, an unreadable lockfile, and an entry that is not on disk.
@bnusunny
bnusunny force-pushed the fix/nodejs-closure-from-lockfile branch from fb8fcca to 8e2915f Compare October 15, 2026 17:20
@bnusunny
bnusunny requested a review from licjun October 15, 2026 18:06
@bnusunny
bnusunny merged commit 4152c60 into aws:develop Oct 15, 2026
102 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants