Skip to content

feat(build): install nodejs workspace dependencies once per monorepo root - #9352

Draft
bnusunny wants to merge 8 commits into
aws:developfrom
bnusunny:feat/monorepo-install-once
Draft

bnusunny wants to merge 8 commits into
aws:developfrom
bnusunny:feat/monorepo-install-once

Conversation

@bnusunny

Copy link
Copy Markdown
Contributor

Which issue(s) does this change fix?

#6567

Draft: blocked on aws/aws-lambda-builders#941. This calls LambdaBuilder.prepare_shared_project, which does not exist in the pinned aws-lambda-builders==1.67.0. It needs #941 merged, a builders release carrying it, and a pin bump here. Opened early for review of the sam-cli side; the integration tests that assert the install count cannot pass until then. Against 1.67.0 the feature is inert rather than broken — the call site checks for the hook and declines when it is absent.

Why is this change necessary?

sam build --build-in-source on an npm workspaces monorepo is slow, and it gets slower for every function each time any function is added.

npm hoists a workspace member's dependencies into one node_modules at the monorepo root, so an install run inside any member reconciles the entire tree. sam build runs that once per function, which makes the install cost O(N × D_total) — quadratic in monorepo growth. Holding one function at exactly one dependency and adding only unrelated siblings around it, that function's own install goes from 0.32s at one function to 2.79s at eight: 8.7× slower to install one unchanged dependency, purely because neighbours exist.

How does it address the issue?

One install per workspace root instead of one per function.

WorkspaceAwareBuildStrategy groups the function build definitions by the npm project root npm prefix reports, runs LambdaBuilder.prepare_shared_project once per distinct root, and then sets download_dependencies=False on every function in that group. Each function's artifacts are populated from the already-installed shared tree, narrowed to that function's own dependency closure.

It wraps outside ParallelBuildStrategy on purpose: the shared install has to finish before any grouped function build starts.

whole build npm subprocesses
8 functions, today 10.08s
8 functions, grouped 3.73s ~55% fewer
20 functions, today 26.06s
20 functions, grouped 8.30s ~55% fewer

Measured against the only configuration that produces correct artifacts today.

What side effects does this change have?

Opt-in while it rolls out. Gated on ExperimentalFlag.NodejsMonorepo — --beta-features, SAM_CLI_BETA_NODEJS_MONOREPO=1, or SAM_CLI_BETA_FEATURES=1. With the flag off, ApplicationBuilder never wraps the strategy and every function installs for itself: the behaviour of every release so far, by the same code path. An integration test runs the monorepo fixture with beta features disabled and asserts both the per-function install count and the disjoint artifacts.

The same flags go to prepare_shared_project, which refuses without experimentalNodejsMonorepo. That matters: the per-function artifacts link in aws-lambda-builders is gated on the same flag, so preparing the root without it would ship artifacts carrying no dependencies at all. Enabling one half without the other is now refused rather than silently wrong.

Grouping is skipped, with a debug line, for:

why
--use-container a root install needs the whole monorepo mounted, not one function's CodeUri; container builds reach the builders over JSON-RPC
--cached it supplies dependencies from .aws-sam/deps and manages download_dependencies itself
non---build-in-source installs go into each function's own artifacts directory, so there is no shared tree
a standalone (non-workspace) project its npm root is its own directory, so nothing is shared
layers LayerBuildDefinition goes through a separate loop

The install's working directory moves from a function's codeuri to the workspace root. That root is inside the user's project and is the directory npm itself would choose; a scope check ("npm prefix resolves outside the codeuri") bounds it, compared through normcase so two spellings of one Windows path match.

A failure never fails the build. Anything the preparation raises is caught and the grouped functions fall back to per-function installs.

Mandatory Checklist

PRs will only be reviewed after checklist is complete

  • Review the generative AI contribution guidelines
  • Add input/output type hints to new functions/methods
  • Write design document if needed (Do I need to write a design document?)
  • Write/update unit tests
  • Write/update integration tests
  • Write/update functional tests if needed — N/A, no functional-test surface for the build strategy
  • make pr passes
  • make update-reproducible-reqs if dependencies were changed — pending the builders release this depends on
  • Write documentation — the --build-in-source docs need the workspace-root behaviour and the beta flag; tracked as a follow-up

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

…th --build-in-source

In an npm workspaces monorepo npm keeps one dependency tree, hoisted to the
workspace root, and every member's install reconciles that whole tree. Building
N functions with --build-in-source therefore reconciled the same tree N times
(aws#6567). WorkspaceAwareBuildStrategy wraps the strategy stack outermost,
resolves each nodejs function's npm project root (npm's own answer, cached),
installs each distinct root once through
LambdaBuilder.install_shared_dependencies, and marks the grouped functions with
download_dependencies=False - the lever IncrementalBuildStrategy already uses.

Scope: Zip nodejs functions on the npm or esbuild workflow, building in source,
not cached, not container builds, and only when npm resolves the project root
outside the function's own code directory (a workspace member). Everything
else, plus any group whose shared install fails or whose aws-lambda-builders
does not expose the API, keeps today's per-function install.

Sitting outside ParallelBuildStrategy makes the install a barrier: no grouped
function build starts before the shared tree is written.

Measured on an 8-function esbuild monorepo (~300 packages): 21.9s -> 1.5s;
20 functions: 57.8s -> 3.0s (bench_monorepo.py, median of 3).
…-build-in-source

An npm workspaces fixture with two functions holding disjoint dependencies
plus a shared workspace package; the lockfile pins lodash 4.17.20 below its
^4.17.20 range so a lockfile-ignoring build is detectable, and the shared
package pins lodash 4.17.15 exactly so a hoisting mistake is detectable.
An npm shim on PATH logs every invocation, asserting the whole build runs
exactly one install (also under --parallel), without --omit=dev, leaving
the lockfile bytes untouched; handlers execute from the artifact dirs and
see the nested conflicting version; a standalone project keeps its single
per-function 'npm update --omit=dev'; --use-container is mutually exclusive
with --build-in-source so container builds can never group.
The gate in ApplicationBuilder.build - build_in_source and not cached and not
container_manager - is what keeps every other build mode on today's behaviour,
and nothing covered it: the grouping strategy had unit tests, its wiring had
none.

Five tests: --build-in-source wraps the delegate strategy and passes the two npm
callbacks; with --parallel the wrapper sits OUTSIDE ParallelBuildStrategy, since
the shared install must finish before any grouped function build starts; and
--cached, a container build, and a plain build each leave the wrapper out
entirely.

Verified as guards rather than decoration: weakening the gate to
`if self._build_in_source:` fails exactly the --cached and container tests.
aws#6567 is an esbuild report, but the integration coverage was plain
npm only. The esbuild workflow reaches the same grouping - both nodejs workflows
funnel their installs through the same npm project tree - while its artifact side
differs: it bundles what each entry point imports instead of linking a
node_modules, so the functions build with no dependency directory at all and the
bundle has to have resolved through the tree npm hoisted to the workspace root.

The NodeWorkspacesEsbuild fixture carries `esbuild` as the ROOT's own
devDependency, which is exactly what the shared install's missing --omit=dev
protects, and gives the shared package a conflicting lodash npm keeps nested. The
test asserts one install for the whole workspace, that the root's esbuild
survived it, that each artifact is a bundle with no node_modules, and that each
bundle reports the hoisted 4.17.20 alongside the shared package's nested 4.17.15.

The fixture proves its own point: with grouping disabled, the per-function
installs prune the root's esbuild and EsbuildBundle fails outright.
_workspace_root_for decides whether a function is a workspace member by comparing
npm's reported project root against the code directory, and the two are spelled
independently. On Windows two spellings differing only in case - a drive letter
included - name the same directory, so reading them as different roots would group
a project that is not a workspace at all. normcase is a no-op off Windows; 310 unit
and 6 workspace integration tests unchanged.
The workspace grouping changes how every function in an npm workspaces monorepo
gets its dependencies, so it ships opt-in: ExperimentalFlag.NodejsMonorepo,
enabled by SAM_CLI_BETA_NODEJS_MONOREPO=1, by --beta-features, or by
SAM_CLI_BETA_FEATURES=1 with the rest. get_all_experimental() discovers members
reflectively, so the new flag registers itself in the beta prompts, the env-var
list and telemetry with no other wiring.

With the flag off, ApplicationBuilder never wraps WorkspaceAwareBuildStrategy and
every function installs for itself - the behaviour of every release so far.

Two test changes beyond the new flag-off unit test. The five gating tests now
patch the flag ON, so each still tests the gate it names rather than passing
because the flag is off; and the integration tests that exercise the grouping ask
for --beta-features, including the standalone-project one, where what must keep a
per-function install is its own npm root rather than a disabled flag. One new
integration test pins the rollout guarantee end to end: the same monorepo fixture
with beta features off takes one install per function and still produces correct,
disjoint artifacts.
…t hook

The call site invoked the hook with the project root alone. aws-lambda-builders#941
makes prepare_shared_project refuse without experimentalNodejsMonorepo - the
per-function artifacts link is gated on that same flag, so preparing the root
without it would ship artifacts carrying no dependencies. Unflagged, the refusal
arrives as a LambdaBuilderError, which this method already catches and turns into a
silent fall back to per-function installs: correct artifacts by the slow path, with
the feature inert and nothing red to show it.

Renamed to follow the hook: install_shared_dependencies became
prepare_shared_project upstream, because the same phase for a Cargo workspace
compiles every member and installs nothing.

The defect survived because nothing tested this method - one assertion pinned the
callable into the strategy and stopped there. Adds a test class over what it hands
the builders: the enabled flags forwarded, an empty list forwarded rather than
omitted, an aws-lambda-builders too old to have the hook declined, and both failure
paths falling back. Removing the kwarg again fails two of them.
@github-actions github-actions Bot added area/build sam build command pr/internal labels Oct 19, 2026
mypy's no-any-return flagged both helpers: json.loads returns Any against a
declared -> dict. Annotating the local satisfies it without a cast or a new
import, and keeps the return type the reader wants.

Caught by the repository's own lint target, which type-checks tests/ as well as
samcli/ - I had run mypy over the two source modules I changed and missed it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/build sam build command pr/internal

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant