Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit fa7004b

Browse files
perf(npm-resolver): cache meta in memory for exact version matches. (#12458)
On the exact-version disk fast path in pickPackage(), promote the parsed packument into the in-memory metaCache so later resolutions of the same package in one install skip the disk read and parse. In large monorepos this brings adding a package down from minutes to seconds. The in-memory cache key is now registry-qualified via a shared getPkgMetaCacheKey(registry, name, fullMetadata) helper. Previously the key was only the package name (plus a `:full` suffix) while the on-disk mirror was registry-qualified, so a package of the same name served by two registries in one install could share one cache slot and resolve the wrong tarball/integrity. The registry is threaded through createNpmResolutionVerifier's shared-meta reads (readSharedMeta / readSharedMetaForTrust), which already noted that a registry-qualified read required the resolver's metaCache key shape to change first. Added a regression test that resolves the same package name from two registries and asserts each gets its own tarball. The Rust pacquet port already implements both behaviors (registry-scoped cache key and disk-fast-path cache population), so no port is required. --------- Co-authored-by: Zoltan Kochan <[email protected]>
1 parent 0f11c40 commit fa7004b

7 files changed

Lines changed: 314 additions & 52 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@pnpm/resolving.npm-resolver": patch
3+
"pnpm": patch
4+
---
5+
6+
The in-memory package metadata cache is now populated on the exact-version disk fast path, so repeated resolutions of the same package within one install no longer re-read and re-parse the on-disk metadata. In large monorepos this brings the time for adding a new package down from minutes to seconds. The in-memory cache key now also includes the registry, so a package of the same name served by two different registries in a single install can no longer share a cache slot and resolve the wrong tarball.

pacquet/crates/resolving-npm-resolver/src/pick_package.rs

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -287,9 +287,14 @@ pub struct PickPackageOptions<'a> {
287287
/// either knob set to `true` makes the pick request full
288288
/// metadata.
289289
pub optional: bool,
290-
/// `true` skips the on-disk exact-version fast path so a stale
291-
/// disk packument can't satisfy the call without a conditional
292-
/// registry request. Mirrors pnpm's `--update-checksums`.
290+
/// `true` forces a conditional registry request so a stale disk
291+
/// packument can't satisfy the call: the on-disk exact-version
292+
/// fast path is skipped, and the in-memory cache is bypassed too.
293+
/// The fast path now promotes disk-loaded packuments into the
294+
/// in-memory cache, so an entry there can no longer be assumed to
295+
/// come from this install's own fresh network fetch — on a shared
296+
/// resolver it might be disk-sourced, which would short-circuit the
297+
/// revalidation. Mirrors pnpm's `--update-checksums`.
293298
pub update_checksums: bool,
294299
}
295300

@@ -427,8 +432,13 @@ pub async fn pick_package<Cache: PackageMetaCache>(
427432
format!("{}\x00{}", opts.registry, spec.name)
428433
};
429434

435+
// updateChecksums must reach the conditional registry request below, so it
436+
// can't be served from the in-memory cache — which may hold a disk-promoted
437+
// entry rather than a fresh network fetch (see the `update_checksums` doc).
438+
let use_mem_cache = !opts.update_checksums;
439+
430440
// 1. In-memory cache.
431-
if let Some(cached) = ctx.meta_cache.get(&cache_key) {
441+
if use_mem_cache && let Some(cached) = ctx.meta_cache.get(&cache_key) {
432442
return handle_cache_hit(
433443
ctx,
434444
spec,
@@ -457,7 +467,7 @@ pub async fn pick_package<Cache: PackageMetaCache>(
457467
// this re-check, every duplicate caller would still fall
458468
// through to the disk + network path even though they were
459469
// waiting precisely for the winner's fetch to complete.
460-
if let Some(cached) = ctx.meta_cache.get(&cache_key) {
470+
if use_mem_cache && let Some(cached) = ctx.meta_cache.get(&cache_key) {
461471
return handle_cache_hit(
462472
ctx,
463473
spec,

pacquet/crates/resolving-npm-resolver/src/pick_package/tests.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1087,3 +1087,58 @@ async fn concurrent_picks_for_same_key_share_one_network_fetch() {
10871087
}
10881088
mock.assert_async().await;
10891089
}
1090+
1091+
#[tokio::test]
1092+
async fn update_checksums_bypasses_warm_in_memory_cache() {
1093+
let mut server = mockito::Server::new_async().await;
1094+
// Only the update_checksums revalidation should hit the network; the first,
1095+
// non-update_checksums pick is served from the on-disk mirror.
1096+
let mock = server
1097+
.mock("GET", "/acme")
1098+
.with_status(200)
1099+
.with_body(PACKAGE_BODY)
1100+
.expect(1)
1101+
.create_async()
1102+
.await;
1103+
1104+
let cache_dir = TempDir::new().expect("tempdir");
1105+
let registry = format!("{}/", server.url());
1106+
let preloaded: pacquet_registry::Package =
1107+
serde_json::from_str(PACKAGE_BODY).expect("parse packument");
1108+
persist_meta_to_mirror(cache_dir.path(), ABBREVIATED_META_DIR, &registry, &preloaded)
1109+
.expect("warm mirror");
1110+
1111+
let http_client = ThrottledClient::default();
1112+
let auth_headers = AuthHeaders::default();
1113+
let meta_cache = InMemoryPackageMetaCache::default();
1114+
let fetch_locker = shared_packument_fetch_locker();
1115+
let ctx = PickPackageContext {
1116+
http_client: &http_client,
1117+
auth_headers: &auth_headers,
1118+
meta_cache: &meta_cache,
1119+
fetch_locker: &fetch_locker,
1120+
cache_dir: Some(cache_dir.path()),
1121+
offline: false,
1122+
prefer_offline: false,
1123+
ignore_missing_time_field: false,
1124+
full_metadata: false,
1125+
filter_metadata: false,
1126+
retry_opts: RetryOpts::default(),
1127+
};
1128+
1129+
// Normal pick takes the on-disk fast path and promotes the packument into
1130+
// the in-memory cache.
1131+
let first = pick_package(&ctx, &version_spec("acme", "1.0.0"), &default_opts(&registry))
1132+
.await
1133+
.expect("ok");
1134+
assert_eq!(first.picked_package.expect("picked").version.to_string(), "1.0.0");
1135+
assert!(meta_cache.get(&format!("{registry}\x00acme")).is_some(), "in-memory cache populated");
1136+
1137+
// update_checksums must still revalidate against the registry despite the
1138+
// warm in-memory cache holding a disk-sourced entry.
1139+
let update_opts = PickPackageOptions { update_checksums: true, ..default_opts(&registry) };
1140+
let second =
1141+
pick_package(&ctx, &version_spec("acme", "1.0.0"), &update_opts).await.expect("ok");
1142+
assert_eq!(second.picked_package.expect("picked").version.to_string(), "1.0.0");
1143+
mock.assert_async().await;
1144+
}

resolving/npm-resolver/src/createNpmResolutionVerifier.ts

Lines changed: 43 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
import { normalizeRegistryUrl } from './normalizeRegistryUrl.js'
2222
import { BUILTIN_NAMED_REGISTRIES } from './parseBareSpecifier.js'
2323
import type { PackageMetaCache } from './pickPackage.js'
24-
import { getPkgMirrorPath, loadMeta, warnMissingTimeFieldOnce } from './pickPackage.js'
24+
import { getPkgMetaCacheKey, getPkgMirrorPath, loadMeta, warnMissingTimeFieldOnce } from './pickPackage.js'
2525
import { failIfTrustDowngraded } from './trustChecks.js'
2626
import {
2727
MINIMUM_RELEASE_AGE_VIOLATION_CODE,
@@ -455,20 +455,13 @@ function fetchFullMetaForTrust (
455455
let cachedPromise = context.fullMetaForTrustCache.get(cacheKey)
456456
if (cachedPromise == null) {
457457
// Fast path: if the resolver already upgraded to full meta for this
458-
// name during the same install (e.g. minimumReleaseAge active),
459-
// reuse that document. Abbreviated meta is rejected here — it lacks
460-
// per-version `time` and per-version trust evidence, both required
461-
// by failIfTrustDowngraded.
462-
//
463-
// Limitation: the resolver's `metaCache` keys by `${name}:full` —
464-
// it doesn't include the registry (pickPackage.ts cacheKey shape).
465-
// If two registries serve packages of the same name in one install
466-
// the resolver itself silently keeps the first fetch; the verifier
467-
// here inherits that scope. The name check below is a defensive
468-
// guard against accidental cache mixups; tightening this to a
469-
// registry-qualified read needs the resolver's `metaCache` key
470-
// shape to change first.
471-
const shared = readSharedMetaForTrust(context.sharedMetaCache, name)
458+
// (registry, name) during the same install (e.g. minimumReleaseAge
459+
// active), reuse that document. Abbreviated meta is rejected here —
460+
// it lacks per-version `time` and per-version trust evidence, both
461+
// required by failIfTrustDowngraded. The read is registry-qualified
462+
// (see `getPkgMetaCacheKey`), so a package of the same name served by
463+
// a different registry can't be returned here.
464+
const shared = readSharedMetaForTrust(context.sharedMetaCache, registry, name)
472465
if (shared != null) {
473466
cachedPromise = Promise.resolve(projectTrustMeta(shared))
474467
} else {
@@ -565,11 +558,12 @@ interface PublishedAtLookupContext {
565558
*/
566559
cutoffMs: number
567560
/**
568-
* Resolver-owned LRU (per-install) keyed by `${name}` (abbreviated)
569-
* or `${name}:full` (full meta). When the resolver has already
570-
* fetched a package during this install, the verifier reuses that
571-
* packument instead of re-paying the disk/network round-trip — the
572-
* fresh-install path otherwise fetches every entry twice. Optional:
561+
* Resolver-owned LRU (per-install) keyed via `getPkgMetaCacheKey`
562+
* (registry + name, with a `:full` suffix for full meta). When the
563+
* resolver has already fetched a package during this install, the
564+
* verifier reuses that packument instead of re-paying the disk/network
565+
* round-trip — the fresh-install path otherwise fetches every entry
566+
* twice. Optional:
573567
* the frozen-install path runs without a resolver and never
574568
* populates this cache, so the verifier's own fetch chain still
575569
* carries the cold case.
@@ -721,10 +715,9 @@ function fetchAbbreviatedMeta (
721715
// Fast path: the resolver's per-install LRU already holds this
722716
// packument from its own pickPackage pass — abbreviated or full.
723717
// Project it for the shortcut and skip the disk/network round-trip.
724-
// Mismatch on `name` is the same risk the resolver carries today
725-
// (its cache key omits the registry), so reuse is no less correct
726-
// than the resolver's own get.
727-
const shared = readSharedMeta(context.sharedMetaCache, name)
718+
// The read is registry-qualified (see `getPkgMetaCacheKey`), so it
719+
// can only return this registry's own packument.
720+
const shared = readSharedMeta(context.sharedMetaCache, registry, name)
728721
if (shared != null) {
729722
cachedPromise = Promise.resolve(projectAbbreviatedMeta(shared))
730723
} else {
@@ -741,35 +734,47 @@ function fetchAbbreviatedMeta (
741734

742735
function readSharedMeta (
743736
cache: PackageMetaCache | undefined,
737+
registry: string,
744738
name: string
745739
): PackageMeta | undefined {
746740
if (cache == null) return undefined
747-
// Prefer the full entry — a `name:full` hit subsumes the abbreviated
748-
// hit (full meta carries every field the abbreviated form does, plus
749-
// `time` and per-version trust evidence the trust check needs). The
750-
// resolver only populates `name:full` when the install ran with
751-
// `minimumReleaseAge` configured, otherwise the bare `name` key holds
741+
// Prefer a full entry — it carries every field the abbreviated form
742+
// does, plus `time` and per-version trust evidence the trust check
743+
// needs. The resolver only populates a full key when the install ran
744+
// with `minimumReleaseAge` configured, otherwise the bare key holds
752745
// the abbreviated form.
753-
return validateSharedMeta(cache.get(`${name}:full`), name) ??
754-
validateSharedMeta(cache.get(name), name)
746+
return readSharedFullMeta(cache, registry, name) ??
747+
validateSharedMeta(cache.get(getPkgMetaCacheKey(registry, name, false, false)), name)
755748
}
756749

757750
function readSharedMetaForTrust (
758751
cache: PackageMetaCache | undefined,
752+
registry: string,
759753
name: string
760754
): PackageMeta | undefined {
761755
if (cache == null) return undefined
762756
// Abbreviated meta is rejected for the trust check — it lacks
763757
// per-version `time` and per-version trust evidence.
764-
return validateSharedMeta(cache.get(`${name}:full`), name)
758+
return readSharedFullMeta(cache, registry, name)
759+
}
760+
761+
// The resolver keys full metadata as either filtered or unfiltered
762+
// depending on its own `filterMetadata` setting; the verifier doesn't
763+
// know which, and a filtered full packument keeps everything the
764+
// verifier reads (`time`, per-version `_npmUser`, `dist`), so try both.
765+
function readSharedFullMeta (
766+
cache: PackageMetaCache,
767+
registry: string,
768+
name: string
769+
): PackageMeta | undefined {
770+
return validateSharedMeta(cache.get(getPkgMetaCacheKey(registry, name, true, false)), name) ??
771+
validateSharedMeta(cache.get(getPkgMetaCacheKey(registry, name, true, true)), name)
765772
}
766773

767-
// Defensive guard against the resolver's `name`-only cache key
768-
// returning something unexpected. The known correctness gap (two
769-
// registries serving the same package name share one cache slot)
770-
// is inherited from the resolver itself — see the `pickPackage.ts`
771-
// cacheKey shape; the verifier can't be stricter than the resolver
772-
// without changing both. This name check at least catches accidental
774+
// Defensive guard against the resolver's `metaCache` returning an
775+
// unexpected entry. The cache key is registry-qualified (see
776+
// `getPkgMetaCacheKey`), so a package of the same name from another
777+
// registry can't be returned; this name check catches accidental
773778
// returns of a different package (cache corruption, factory misuse)
774779
// rather than silently feeding wrong data to the trust / age check.
775780
function validateSharedMeta (meta: PackageMeta | undefined, name: string): PackageMeta | undefined {

resolving/npm-resolver/src/pickPackage.ts

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,14 @@ export interface PickPackageOptions extends PickPackageFromMetaOptions {
7171
includeLatestTag?: boolean
7272
optional?: boolean
7373
/**
74-
* When true, skip the on-disk exact-version cache fast path so a
75-
* stale on-disk packument can't satisfy the call without a
76-
* conditional registry request. The in-memory cache is left alone:
77-
* its entries can only be populated by this install's own fresh
78-
* network fetches, so they're authoritative for second-and-onward
79-
* lookups within the same install.
74+
* When true, force a conditional registry request so a stale on-disk
75+
* packument can't satisfy the call: the on-disk exact-version fast
76+
* path is skipped, and the in-memory cache is bypassed too. The fast
77+
* path now promotes disk-loaded packuments into the in-memory cache,
78+
* so an entry there can no longer be assumed to come from this
79+
* install's own fresh network fetch — on a shared or long-lived
80+
* resolver it might be disk-sourced, which would short-circuit the
81+
* revalidation updateChecksums exists to force.
8082
*/
8183
updateChecksums?: boolean
8284
}
@@ -211,10 +213,16 @@ export async function pickPackage (
211213
const metaDir = fullMetadata
212214
? (ctx.filterMetadata ? FULL_FILTERED_META_DIR : FULL_META_DIR)
213215
: ABBREVIATED_META_DIR
214-
// Cache key includes fullMetadata to avoid returning abbreviated metadata when full metadata is requested.
215-
const cacheKey = fullMetadata ? `${spec.name}:full` : spec.name
216+
// Cache key includes the registry so a package of the same name served by two
217+
// registries in one install can't share a slot (which would resolve the wrong
218+
// tarball/integrity), plus fullMetadata/filterMetadata so a request is never
219+
// served a less-detailed or differently-stripped document than it asked for.
220+
const cacheKey = getPkgMetaCacheKey(opts.registry, spec.name, fullMetadata, ctx.filterMetadata === true)
216221
const pkgMirror = getPkgMirrorPath(ctx.cacheDir, metaDir, opts.registry, spec.name)
217-
const cachedMeta = ctx.metaCache.get(cacheKey)
222+
// updateChecksums must reach the conditional registry request below, so it
223+
// can't be served from the in-memory cache — which may hold a disk-promoted
224+
// entry rather than a fresh network fetch (see the updateChecksums doc).
225+
const cachedMeta = opts.updateChecksums ? undefined : ctx.metaCache.get(cacheKey)
218226
if (cachedMeta != null) {
219227
// The in-memory cache may hold abbreviated metadata from an earlier call
220228
// that didn't need `time` (no publishedBy then). If this call has
@@ -281,6 +289,7 @@ export async function pickPackage (
281289
try {
282290
const pickedPackage = pickMatchingVersionFast(pickerOpts, spec, metaCachedInStore)
283291
if (pickedPackage) {
292+
ctx.metaCache.set(cacheKey, metaCachedInStore)
284293
return {
285294
meta: metaCachedInStore,
286295
pickedPackage,
@@ -587,6 +596,43 @@ export function encodePkgName (pkgName: string): string {
587596
return pkgName
588597
}
589598

599+
/**
600+
* Key for the in-memory `metaCache` holding a package's registry metadata. The
601+
* registry is part of the key so that a package of the same name served by two
602+
* registries in one install can't collide on a single slot (which would resolve
603+
* the wrong tarball/integrity). `fullMetadata` and `filterMetadata` keep the
604+
* abbreviated, full, and filtered-full documents in distinct slots, mirroring
605+
* the on-disk `metaDir` split: a `filterMetadata` resolver stores a `clearMeta`-
606+
* stripped packument, so it must not share a slot with an unfiltered full one
607+
* (reachable only when a `metaCache` is shared across resolvers with different
608+
* settings). `filterMetadata` only narrows the full slot — abbreviated metadata
609+
* shares one on-disk mirror regardless, so its key carries no filtered variant.
610+
* `\x00` can't appear in a registry URL or a package name, so it's an
611+
* unambiguous separator. The verifier reads this same cache and must build the
612+
* key with this function.
613+
*
614+
* The registry is canonicalized to its origin plus a trailing-slashed path, so
615+
* the resolver (which may pass a configured named-registry URL verbatim) and
616+
* the verifier (which routes through trailing-slashed prefixes) converge on one
617+
* key for the same logical registry instead of creating duplicate slots. Origin
618+
* and path are preserved, so two registries that genuinely differ never collapse.
619+
*/
620+
export function getPkgMetaCacheKey (registry: string, pkgName: string, fullMetadata: boolean, filterMetadata: boolean): string {
621+
const key = `${canonicalizeRegistry(registry)}\x00${pkgName}`
622+
if (!fullMetadata) return key
623+
return filterMetadata ? `${key}:full:filtered` : `${key}:full`
624+
}
625+
626+
function canonicalizeRegistry (registry: string): string {
627+
try {
628+
const parsed = new URL(registry)
629+
const pathname = parsed.pathname.endsWith('/') ? parsed.pathname : `${parsed.pathname}/`
630+
return `${parsed.origin}${pathname}`
631+
} catch {
632+
return registry
633+
}
634+
}
635+
590636
/**
591637
* Path of the on-disk JSONL document where pnpm mirrors a package's registry
592638
* metadata. `metaDir` selects between abbreviated and full caches.

0 commit comments

Comments
 (0)