perf(package-manager): route fresh-lockfile install through CreateVirtualStore#11867
Conversation
📝 WalkthroughWalkthroughThe PR refactors ChangesPhased virtual-store pipeline for fresh-lockfile installs
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related issues
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Micro-Benchmark ResultsLinux |
Integrated-Benchmark Report (Linux)Scenario: Frozen Lockfile
BENCHMARK_REPORT.json{
"results": [
{
"command": "pacquet@HEAD",
"mean": 2.42960893732,
"stddev": 0.08640549667101624,
"median": 2.41933346372,
"user": 2.7965896199999998,
"system": 3.52233868,
"min": 2.31793268172,
"max": 2.63717444172,
"times": [
2.43615235272,
2.44399411272,
2.35079190072,
2.41110585172,
2.47983151872,
2.63717444172,
2.4275610757200003,
2.31793268172,
2.39874292272,
2.39280251472
]
},
{
"command": "pacquet@main",
"mean": 2.3562108533200004,
"stddev": 0.051783183802449034,
"median": 2.3373867717200003,
"user": 2.80470942,
"system": 3.4972793799999997,
"min": 2.30225118872,
"max": 2.46089439272,
"times": [
2.3440774547200003,
2.46089439272,
2.3811883587200002,
2.41887397872,
2.30225118872,
2.37097032672,
2.32038866072,
2.3306960887200003,
2.30540662672,
2.3273614567200003
]
},
{
"command": "pnpm",
"mean": 4.67757897242,
"stddev": 0.041747684152010425,
"median": 4.66846471222,
"user": 7.977493619999999,
"system": 4.102910280000001,
"min": 4.612231265719999,
"max": 4.743086926719999,
"times": [
4.66210566772,
4.68358465572,
4.743086926719999,
4.72902104772,
4.6516098887199995,
4.64401968472,
4.65455334172,
4.72075348872,
4.612231265719999,
4.6748237567199995
]
}
]
}Scenario: Frozen Lockfile (Hot Cache)
BENCHMARK_REPORT.json{
"results": [
{
"command": "pacquet@HEAD",
"mean": 0.6784672075400001,
"stddev": 0.02527358540269241,
"median": 0.66843638004,
"user": 0.37750093999999995,
"system": 1.4841141800000002,
"min": 0.6625723640400001,
"max": 0.7473171160400001,
"times": [
0.7473171160400001,
0.6645411200400001,
0.66814107304,
0.67408404704,
0.68306812504,
0.6834026080400001,
0.6625723640400001,
0.6687316870400001,
0.6648499990400001,
0.66796393604
]
},
{
"command": "pacquet@main",
"mean": 0.6900188277400001,
"stddev": 0.028568654210454483,
"median": 0.67965650154,
"user": 0.38108884,
"system": 1.4874496799999999,
"min": 0.66497592904,
"max": 0.7439004400400001,
"times": [
0.7439004400400001,
0.66705411804,
0.73763803504,
0.6792843700400001,
0.68002863304,
0.66497592904,
0.67576170804,
0.70088042904,
0.67020833704,
0.6804562780400001
]
},
{
"command": "pnpm",
"mean": 2.46600325294,
"stddev": 0.13670066151984686,
"median": 2.4169119865399997,
"user": 3.0102091399999997,
"system": 2.2289747799999997,
"min": 2.36304330604,
"max": 2.80722217904,
"times": [
2.50762302004,
2.4020706820399997,
2.3678004750399997,
2.80722217904,
2.36304330604,
2.44319956804,
2.3733044950399997,
2.39572116604,
2.5682943470399997,
2.43175329104
]
}
]
}Scenario: Clean Install
BENCHMARK_REPORT.json{
"results": [
{
"command": "pacquet@HEAD",
"mean": 8.46588464942,
"stddev": 0.1377472568750655,
"median": 8.44118915852,
"user": 10.368364359999998,
"system": 3.6063653600000003,
"min": 8.27727132802,
"max": 8.77117320202,
"times": [
8.77117320202,
8.51041439502,
8.540498165019999,
8.427286214019999,
8.27727132802,
8.45509210302,
8.34639014602,
8.549587831019998,
8.400702944019999,
8.38043016602
]
},
{
"command": "pacquet@main",
"mean": 8.755569649119998,
"stddev": 0.08841568102101824,
"median": 8.75173770352,
"user": 10.377194459999998,
"system": 4.366853059999999,
"min": 8.613832453019999,
"max": 8.86951575502,
"times": [
8.828321470019999,
8.73514039202,
8.72902684602,
8.816102321019999,
8.62507569002,
8.86951575502,
8.76833501502,
8.613832453019999,
8.720909902019999,
8.84943664702
]
},
{
"command": "pnpm",
"mean": 6.452506251220001,
"stddev": 0.1245432944476589,
"median": 6.44628045152,
"user": 10.564793859999998,
"system": 4.423525759999999,
"min": 6.29269698402,
"max": 6.74033441302,
"times": [
6.40322800602,
6.29269698402,
6.3112914280200005,
6.74033441302,
6.40513190102,
6.49964836802,
6.424300376020001,
6.46826052702,
6.47410987402,
6.50606063502
]
}
]
}Scenario: Full Resolution
BENCHMARK_REPORT.json{
"results": [
{
"command": "pacquet@HEAD",
"mean": 7.3379370253000005,
"stddev": 0.13340419637605103,
"median": 7.3183016287000004,
"user": 7.725842299999998,
"system": 2.2213437,
"min": 7.2173433367,
"max": 7.6697250727,
"times": [
7.343357543700001,
7.2661083817000005,
7.3327581797,
7.4297736717000005,
7.6697250727,
7.3438769167,
7.2322236757,
7.2173433367,
7.3038450777,
7.2403583967000005
]
},
{
"command": "pacquet@main",
"mean": 7.7083771836000015,
"stddev": 0.11324884008750248,
"median": 7.6742979472,
"user": 7.763950999999999,
"system": 2.9594302000000003,
"min": 7.6024456987,
"max": 7.9106495007,
"times": [
7.6024456987,
7.644466624700001,
7.904007039700001,
7.618666627700001,
7.6173656557000005,
7.7347510147000005,
7.9106495007,
7.7028237797,
7.7021927327,
7.6464031617
]
},
{
"command": "pnpm",
"mean": 4.2215889773,
"stddev": 0.040502359779975995,
"median": 4.221235650700001,
"user": 5.240822499999998,
"system": 2.6703834,
"min": 4.1553377817,
"max": 4.2789841307000005,
"times": [
4.2789841307000005,
4.2471733967,
4.1553377817,
4.2081408877,
4.1725659497,
4.1968855347,
4.2507108867,
4.2343304137,
4.2057747837,
4.2659860077000005
]
}
]
} |
…tualStore Replace `install_with_fresh_lockfile`'s recursive per-package `install_subtree` walk with the phased warm/cold-batch pipeline `install_frozen_lockfile` already uses (`CreateVirtualStore` + `SymlinkDirectDependencies` + `LinkVirtualStoreBins`). The recursive shape pinned one tokio worker per in-flight package on its own rayon `par_iter` for the per-package link step; replacing it with a single phased `par_iter` over every snapshot closes most of the gap to pnpm on the no-lockfile install paths. 5-run hyperfine against the verdaccio mock, 10-core M-series Mac: | scenario | before | after | pnpm | who wins | |---------------------------|------:|------:|------:|----------| | clean-install, cold cache | 25.5s | 13.7s | 24.6s | **pacquet 1.79×** | | full-resolution, warm | 22.1s | ~10s | 9.4s | pnpm 1.07× (was 1.94×) | | frozen, cold cache | 21.1s | 19.6s | 21.7s | pacquet (unchanged) | | frozen, warm cache | 7.5s | 7.6s | 9.6s | pacquet (unchanged) | The `install_with_fresh_lockfile` path already builds the v9 lockfile shape from the resolved graph (`built_lockfile`) and constructs `VirtualStoreLayout::new` — `CreateVirtualStore` consumes exactly that shape, so the refactor is mostly plumbing. `install_subtree` and `InstallCtx` go away with it. `dependencies_graph_to_lockfile` is also updated so auto-installed peers (resolved under `autoInstallPeers: true` but not present in the manifest) make it into the importer's `dependencies` map with a synthetic specifier matching the resolved version. The recursive walker surfaced these as top-level symlinks by reading `direct_dependencies_by_alias` directly; `SymlinkDirectDependencies` reads `built_lockfile.importers["."]`, so without recording the hoisted peers there, the `auto_install_peers_hoists_missing_peers_at_importer` regression test fails. The `specifiers:` map skips the synthetic entry — only manifest-authored specifiers belong there. Refs #11866, #11857.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #11867 +/- ##
==========================================
+ Coverage 87.63% 87.80% +0.17%
==========================================
Files 205 205
Lines 24486 24429 -57
==========================================
- Hits 21458 21451 -7
+ Misses 3028 2978 -50 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
Qodo reviews are paused for this user.Troubleshooting steps vary by plan Learn more → On a Teams plan? Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center? |
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
pacquet/crates/package-manager/src/install_with_fresh_lockfile.rs (1)
731-809:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy liftHonor
config.node_linkerinstead of forcing isolated mode.This new path always goes through the isolated virtual-store flow because
CreateVirtualStoreis hard-coded withNodeLinker::Isolated, and the direct-dependency symlinking right after it assumes the same layout. WithnodeLinker=hoisted, fresh installs will still materialize.pacquetand link as isolated, which breaks the config contract. Please mirror the frozen-lockfile branching here and only take this path when the configured linker is isolated.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@pacquet/crates/package-manager/src/install_with_fresh_lockfile.rs` around lines 731 - 809, The code forces isolated mode by constructing CreateVirtualStore with NodeLinker::Isolated regardless of configuration; change the flow to check config.node_linker and only use the isolated virtual-store + SymlinkDirectDependencies path when config.node_linker == NodeLinker::Isolated (mirroring the branching used in the frozen-lockfile path / install_frozen_lockfile), otherwise follow the non-isolated/hoisted branch used elsewhere so the virtual store and direct-dep symlinks respect config.node_linker; look for CreateVirtualStore, NodeLinker::Isolated, SymlinkDirectDependencies and config.node_linker to implement the conditional.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@pacquet/crates/package-manager/src/install_with_fresh_lockfile.rs`:
- Around line 726-730: The fresh-lockfile path currently destructures
CreateVirtualStoreOutput and discards side_effects_maps_by_snapshot, skipping
the post-link build phase; update the fresh-lockfile flow to preserve
side_effects_maps_by_snapshot from the CreateVirtualStoreOutput and invoke the
same BuildModules step used by the frozen-lockfile path (use the same call/site
that runs BuildModules before saving the lockfile) after linking and before
emitting ImportingDone so that postinstall/build lifecycle scripts run on fresh
installs; ensure you thread the preserved side_effects_maps_by_snapshot into
whatever function BuildModules expects and only mark the install complete
(ImportingDone) after BuildModules finishes.
---
Outside diff comments:
In `@pacquet/crates/package-manager/src/install_with_fresh_lockfile.rs`:
- Around line 731-809: The code forces isolated mode by constructing
CreateVirtualStore with NodeLinker::Isolated regardless of configuration; change
the flow to check config.node_linker and only use the isolated virtual-store +
SymlinkDirectDependencies path when config.node_linker == NodeLinker::Isolated
(mirroring the branching used in the frozen-lockfile path /
install_frozen_lockfile), otherwise follow the non-isolated/hoisted branch used
elsewhere so the virtual store and direct-dep symlinks respect
config.node_linker; look for CreateVirtualStore, NodeLinker::Isolated,
SymlinkDirectDependencies and config.node_linker to implement the conditional.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 84cb7b49-4415-4013-a0e8-7f6960e29cec
📒 Files selected for processing (2)
pacquet/crates/package-manager/src/dependencies_graph_to_lockfile.rspacquet/crates/package-manager/src/install_with_fresh_lockfile.rs
📜 Review details
🧰 Additional context used
📓 Path-based instructions (1)
pacquet/**/*.rs
📄 CodeRabbit inference engine (pacquet/AGENTS.md)
pacquet/**/*.rs: When porting a function that firespnpm:<channel>events throughglobalLogger,logger.debug(), orstreamParser.write(), mirror the call site, payload, and ordering so the reporter parses pacquet's NDJSON the same way it parses pnpm's.
Declare a newtype wrapper for branded string types. Do not collapse the brand into a plainStringor&str.
If upstream always validates before construction, validate in pacquet's wrapper too. The wrapper must construct only viaTryFrom<String>and/orFromStr. Do not provide an infallible public constructor.
If upstream never validates, just brand for type-safety. Expose an infallibleFrom<String>(andFrom<&str>when convenient).
If upstream occasionally constructs without validation, exposefrom_str_uncheckedas an escape hatch alongside the validating constructor.
Match upstream serde behavior for branded types that cross JSON, YAML, or INI boundaries. Use#[serde(try_from = "String")]for deserialization and#[serde(into = "String")]for serialization.
Use#[derive(derive_more::From)]and#[derive(derive_more::Into)]for mechanical conversion impls. Fall back to manualimplonly when conversion needs custom logic.
String-literal unions should becomeenums, not newtype wrappers. Model closed sets of valid string values as enums.
Template literal types should be treated as branded strings with validation discipline from rules 2-5.
Choose owned vs. borrowed parameters to minimize copies. Widen to the most encompassing type (&Pathover&PathBuf,&strover&String) when it doesn't force extra copies.
PreferArc::clone(&x)/Rc::clone(&x)overx.clone()for reference-counted types, so the cost is visible at the call site.
Follow Rust API Guidelines for naming conventions.
Do not use star imports inside module bodies. Writeuse super::{Foo, bar}instead ofuse super::*;. Two forms stay allowed: external-crate preludes likeuse rayon::prelude::*;and root-of-module re-...
Files:
pacquet/crates/package-manager/src/dependencies_graph_to_lockfile.rspacquet/crates/package-manager/src/install_with_fresh_lockfile.rs
🧠 Learnings (3)
📚 Learning: 2026-05-20T19:40:55.051Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11774
File: pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs:0-0
Timestamp: 2026-05-20T19:40:55.051Z
Learning: In the pacquet Rust code, ensure the semver implementation uses the `node-semver` crate (not `nodejs-semver`). `node-semver`’s public API does not include a `satisfies_with_prerelease`-style method; prerelease-tolerant matching should be implemented inline by first calling `Range::satisfies`, and when it rejects a prerelease version, retry matching against a stripped `MAJOR.MINOR.PATCH` base of the prerelease version.
Applied to files:
pacquet/crates/package-manager/src/dependencies_graph_to_lockfile.rspacquet/crates/package-manager/src/install_with_fresh_lockfile.rs
📚 Learning: 2026-05-22T00:08:44.646Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11837
File: pacquet/crates/resolving-npm-resolver/src/pick_package.rs:33-51
Timestamp: 2026-05-22T00:08:44.646Z
Learning: In the pnpm/pnpm repo’s pacquet Rust crates, do not flag Unicode ellipsis characters (U+2026, `…`) in Rust doc comments (`///` / `/** */`) as a lint violation. The pacquet crate’s `dylint.toml` only enables `perfectionist::derive_ordering`, and the Dylint `unicode-ellipsis` rule is not enabled for this project—so `…` in doc comments is an intentional, repo-consistent style.
Applied to files:
pacquet/crates/package-manager/src/dependencies_graph_to_lockfile.rspacquet/crates/package-manager/src/install_with_fresh_lockfile.rs
📚 Learning: 2026-05-20T23:07:58.444Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11784
File: pacquet/crates/resolving-deps-resolver/src/hoist_peers.rs:120-133
Timestamp: 2026-05-20T23:07:58.444Z
Learning: When reviewing code in this pacquet Rust port, follow the upstream pnpm compatibility rule: only match pnpm’s behavior exactly. Do not propose review changes that intentionally deviate from pnpm’s documented/observed behavior, even if pnpm appears buggy. If you identify a real bug in pnpm behavior, the review should prioritize fixing it upstream in pnpm first, and avoid implementing a pnpm-behavior workaround here unless the same fix has already landed upstream.
Applied to files:
pacquet/crates/package-manager/src/dependencies_graph_to_lockfile.rspacquet/crates/package-manager/src/install_with_fresh_lockfile.rs
🔇 Additional comments (2)
pacquet/crates/package-manager/src/dependencies_graph_to_lockfile.rs (1)
119-148: LGTM!pacquet/crates/package-manager/src/install_with_fresh_lockfile.rs (1)
137-141: LGTM!Also applies to: 272-280, 538-545
Pre-existing constraint, enforced one level up. if matches!(node_linker, NodeLinker::Hoisted) {
return Err(InstallError::UnsupportedFreshInstallNodeLinker { node_linker });
}The error type itself spells out the reason: hoisted needs the lockfile snapshot graph ( So
(Visible on Adding Written by an agent (Claude Code, claude-opus-4-7). |
|
@coderabbitai approve |
✅ Actions performedComments resolved and changes approved. |
…r lockfile (#11877) Aligns `dependencies_graph_to_lockfile` with pnpm's `addDirectDependenciesToLockfile`, which only writes manifest-declared aliases into `importers["."].dependencies` / `importers["."].specifiers`. The earlier synthetic-specifier branch (added in #11867 to keep the top-level symlinks the old recursive walker happened to produce) wrote the resolver's transitive auto-installed peers into the importer block with the resolved version as their specifier, so `pnpm-lock.yaml` ended up listing entries the on-disk `package.json` never declared. `satisfies_package_manifest` then read those entries on the next install, flagged 17 "removed" specifiers against the alotta-files fixture, and the auto-frozen fast path (`preferFrozenLockfile: true`) fell through to the full fresh-resolve pipeline — turning the bench's `withWarmCacheAndLockfile` / `withWarmModulesAndLockfile` / `repeatInstall` scenarios from sub-second no-ops into ~4.5 s re-resolutions in pacquet@0.2.5. Verified against pnpm itself (`pnpm install react-dom@18.2.0` → lockfile lists only `react-dom` in `importers["."].dependencies`, no top-level `react` symlink). Pacquet now produces the same shape: transitive auto-installed peers live in `snapshots:` / `packages:` only, consumers reach them through their parent's slot's `node_modules`, and the freshness gate stays clean across installs. Local reproduction on the alotta-files fixture: - importer.dependencies count: 126 → 109 (matches the manifest exactly) - `pacquet install --frozen-lockfile`: was failing with "17 dependencies were removed", now succeeds - `repeatInstall`: ~22 s → ~0.5 s Test updates: - `auto_install_peers_hoists_missing_peers_at_importer` keeps the `.pnpm/` virtual-store assertions (the actual hoist-loop contract) and drops the top-level `node_modules/<peer>` symlink loop, which was asserting behavior pnpm itself doesn't produce. - `should_install_dependencies` snapshot drops the three `node_modules/@pnpm/{x,y,z}` entries for the same reason — those are `@pnpm/xyz`'s transitive peers and never reach the importer's `node_modules` under pnpm. Refs #11867. --- Written by an agent (Claude Code, claude-opus-4-7).
Closes #11866.
Summary
The fresh-lockfile install path (
install_with_fresh_lockfile→install_subtree) was a recursive per-package async tree walk. After the resolver produces the dep graph andbuild_fresh_lockfilematerialises the v9 lockfile shape, the install pass now routes through the same phased pipelineinstall_frozen_lockfileuses: oneCreateVirtualStore::run(warm/cold-batchpar_iterover every snapshot) + oneSymlinkDirectDependencies::run+ oneLinkVirtualStoreBins::run.install_subtreeandInstallCtxgo away with the change.Net diff: +155 / -257 lines.
Honest CI numbers
Posted by the integrated-benchmark workflow on
ubuntu-latest:The refactor lands a real, statistically-meaningful 3–8 % wall-time win on the no-lockfile paths, and is no-op on the frozen path (already routed through
CreateVirtualStore). It does not close the headline gap to pnpm on those paths — that gap is in the resolve phase, not the install pipeline this PR touched.Where the remaining gap lives
Subtracting frozen-cold from clean-install (resolve-only delta on
ubuntu-latest):Pacquet's resolve phase eats ~3× more user CPU and ~3.5× more wall time than pnpm's for the same dep graph. That's the next thing to attack and is out of scope for this PR — tracking separately.
A
sample(1)profile of the resolve window points at:resolve_peers::Walker::resolve_node+resolve_dependency_tree::resolve_node(~885 samples) — heavy.clone()onDependenciesTreeNode/ResolvedPackage/ParentRefsper visitpick_package*family (~870 samples) — semver matching, packument lookupmirror::load_meta(~86 samples) — packument disk read + JSON parseSnapshot impact
One supporting fix in
dependencies_graph_to_lockfile: auto-installed peers (resolved underautoInstallPeers: truebut not in the manifest) are recorded in the importer'sdependenciesmap with a synthetic specifier (the resolved version). The old recursive walker surfaced them as top-level symlinks by readingdirect_dependencies_by_aliasdirectly;SymlinkDirectDependenciesreadsbuilt_lockfile.importers["."], so we need the synthetic entry there to materialise the hoisted-peer symlinksauto_install_peers_hoists_missing_peers_at_importer(and theshould_install_dependenciessnapshot) expect. Thespecifiers:map skips the synthetic entry — only manifest-authored specifiers belong there.Test plan
cargo nextest run(workspace) — 1 995 tests passcargo clippy --locked --workspace --all-targets -- --deny warnings— cleancargo fmt --check— cleanRUSTDOCFLAGS='-D warnings' cargo doc --no-deps --workspace --document-private-items— cleanNotes for review
InstallPackageFromRegistry,InstallPackageFromRegistryError::FirstWriterAborted, and theResolvedPackages-typedresolved_packagesfield onInstallWithFreshLockfileare no longer exercised. Left in place to keep this commit focused on the architectural change; a follow-up can prune them along with the ~14 test sites ininstall/tests.rsstill passingresolved_packages: &Default::default().symlink_rootanchor forSymlinkDirectDependenciesusesconfig.modules_dir.parent()instead oflockfile_dir. In the common case whereconfig.modules_dir == <lockfile_dir>/node_modulesthey coincide; for tests that relocateconfig.modules_diraway from the manifest dir (acknowledged in the test code as// TODO: we shouldn't have to define this), it recovers the oldinstall_subtreebehaviour of writing symlinks where the rest of pacquet's install code already writes.Written by an agent (Claude Code, claude-opus-4-7).