perf(npm-resolver): stop retaining raw registry bodies in the memoized fetch cache (#12870)

The npm-resolver memoizes its metadata fetch for the whole resolution phase
and clears it only once, via clearResolutionCache(), after resolution ends.
Each memoized FetchMetadataResult carried jsonText, the raw registry response
body kept only to mirror the response to disk without re-serializing meta.
With an unbounded memo cache living for the entire phase, every raw body
stayed resident until the end. On large cold-cache graphs that fetch full
metadata (minimumReleaseAge / trustPolicy) these bodies reached hundreds of
MB and OOM-killed 3GB CI runners. v10 did not keep the raw text on the
memoized result, so this was a v10-to-v11 regression.

Enforce the invariant at the cache boundary: the memoized fetch
(memoizeFetchMetadata, replacing p-memoize, which has no hook between compute
and store) caches a body-less shallow clone of each result, so jsonText
reaches only the caller that initiated the fetch — the one that writes the
disk mirror — and dies with that call. The cache is structurally incapable of
pinning a body regardless of who consumes the fetch. Peak RSS drops by ~30%
(back to the v10 level) with a byte-identical lockfile. Cache-hit callers see
jsonText undefined and fall back to JSON.stringify(meta) in
prepareJsonForDisk, which is functionally equivalent because loadMeta
re-derives etag from the headers line on read. The replacement preserves
p-memoize's semantics: in-flight dedup, eviction of rejections, and cache
clearing. Follows the projection applied to the verifier caches in
pnpm/pnpm#11878.

Closes pnpm/pnpm#12868.
This commit is contained in:
maroKanatani
2026-07-10 02:06:06 +09:00
committed by GitHub
parent c70e33e887
commit 3067e4f6b4
10 changed files with 209 additions and 24 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/resolving.npm-resolver": patch
"pnpm": patch
---
Reduced peak memory usage during cold-cache dependency resolution. The metadata fetch is memoized for the whole resolution phase, and it was retaining each package's raw registry response body (used only to mirror the response to disk) for that entire time. The memoized cache now holds a body-less copy, so the raw body only lives as long as the call that writes the disk mirror. On large graphs that fetch full metadata (e.g. with `minimumReleaseAge` or `trustPolicy` enabled) this cuts peak RSS by roughly 30%, back in line with pnpm 10. The resolved lockfile is unchanged.

15
pnpm-lock.yaml generated
View File

@@ -809,9 +809,6 @@ catalogs:
p-map-values:
specifier: ^0.1.0
version: 0.1.0
p-memoize:
specifier: 8.0.0
version: 8.0.0
p-queue:
specifier: ^9.3.0
version: 9.3.0
@@ -8816,9 +8813,6 @@ importers:
p-limit:
specifier: 'catalog:'
version: 7.3.0
p-memoize:
specifier: 'catalog:'
version: 8.0.0
parse-npm-tarball-url:
specifier: 'catalog:'
version: 5.0.0
@@ -15746,10 +15740,6 @@ packages:
resolution: {integrity: sha512-km0sP12uE0dOZ5qP+s7kGVf07QngxyG0gS8sYFvFWhqlgzOsSy+m71aUejf/0akxj5W7gE//2G74qTv6b4iMog==}
engines: {node: '>=10'}
p-memoize@8.0.0:
resolution: {integrity: sha512-jdZ10MCxavHoIHwJ5oweOtYy6ElPixEHaMkz0AuaEMovR1MRpVvYFzIEHRxgMEpXYzNpRVByFAniAzwmd1/uug==}
engines: {node: '>=20'}
p-queue@6.6.2:
resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==}
engines: {node: '>=8'}
@@ -24015,11 +24005,6 @@ snapshots:
mem: 6.1.1
mimic-fn: 3.1.0
p-memoize@8.0.0:
dependencies:
mimic-function: 5.0.1
type-fest: 4.41.0
p-queue@6.6.2:
dependencies:
eventemitter3: 4.0.7

View File

@@ -263,7 +263,6 @@ catalog:
p-filter: ^4.1.0
p-limit: ^7.3.0
p-map-values: ^0.1.0
p-memoize: 8.0.0
p-queue: ^9.3.0
parse-json: ^8.3.0
parse-npm-tarball-url: ^5.0.0

View File

@@ -55,7 +55,6 @@
"lru-cache": "catalog:",
"normalize-path": "catalog:",
"p-limit": "catalog:",
"p-memoize": "catalog:",
"parse-npm-tarball-url": "catalog:",
"path-temp": "catalog:",
"ramda": "catalog:",

View File

@@ -38,7 +38,14 @@ interface RegistryResponse {
export interface FetchMetadataResult {
meta: PackageMeta
jsonText: string
/**
* The raw registry response body, used only to mirror the response to disk
* without re-serializing `meta`. A fresh fetch always sets it, but it
* reaches only the caller that initiated the request: the phase-long memo
* cache holds a body-less clone (see memoizeFetchMetadata.ts), so cache
* hits see `undefined` and the cache never pins the body.
*/
jsonText: string | undefined
etag?: string
notModified?: false
}

View File

@@ -43,13 +43,13 @@ import {
import { resolveWorkspaceRange } from '@pnpm/workspace.range-resolver'
import { LRUCache } from 'lru-cache'
import normalize from 'normalize-path'
import pMemoize, { pMemoizeClear } from 'p-memoize'
import { clone } from 'ramda'
import semver from 'semver'
import ssri from 'ssri'
import versionSelectorType from 'version-selector-type'
import { fetchMetadataFromFromRegistry, type FetchMetadataFromFromRegistryOptions, RegistryResponseError } from './fetch.js'
import { memoizeFetchMetadata } from './memoizeFetchMetadata.js'
import { normalizeRegistryUrl } from './normalizeRegistryUrl.js'
import {
BUILTIN_NAMED_REGISTRIES,
@@ -217,9 +217,7 @@ export function createNpmResolver (
timeout: opts.timeout ?? 60000,
fetchWarnTimeoutMs: opts.fetchWarnTimeoutMs ?? 10 * 1000, // 10 sec
}
const fetch = pMemoize(fetchMetadataFromFromRegistry.bind(null, fetchOpts), {
cacheKey: (...args) => JSON.stringify(args),
})
const { fetch, clear: clearFetchCache } = memoizeFetchMetadata(fetchMetadataFromFromRegistry.bind(null, fetchOpts))
// Track ownership so `clearCache()` below only wipes the in-memory
// cache when this factory created it. A caller-supplied
// `opts.metaCache` may be shared with another resolver instance (or
@@ -296,7 +294,7 @@ export function createNpmResolver (
if (ownsMetaCache && 'clear' in metaCache && typeof metaCache.clear === 'function') {
metaCache.clear()
}
pMemoizeClear(fetch)
clearFetchCache()
},
}
}

View File

@@ -0,0 +1,50 @@
import type {
FetchMetadataNotModifiedResult,
FetchMetadataOptions,
FetchMetadataResult,
} from './fetch.js'
export type FetchMetadata = (pkgName: string, opts: FetchMetadataOptions) => Promise<FetchMetadataResult | FetchMetadataNotModifiedResult>
export interface MemoizedFetchMetadata {
fetch: FetchMetadata
clear: () => void
}
/**
* Memoizes metadata fetches for the whole resolution phase (cleared via
* `clear`, see `clearResolutionCache`), deduplicating concurrent and repeat
* requests for the same package.
*
* Unlike plain memoization, the cache holds a body-less clone of each result:
* `jsonText` — the raw registry response body, up to tens of MB for a popular
* package — reaches only the caller that initiated the fetch, which is the
* caller that writes the disk mirror. A phase-long cache that kept the bodies
* would pin hundreds of MB on large cold-cache graphs. A cache-hit caller
* that also writes the mirror falls back to `JSON.stringify(meta)` in
* `prepareJsonForDisk`, which is equivalent on read: `loadMeta` re-derives
* `etag` from the headers line.
*
* A rejected fetch is evicted so a transient network failure is retried by
* the next request instead of being cached for the rest of the phase.
*/
export function memoizeFetchMetadata (fetch: FetchMetadata): MemoizedFetchMetadata {
const cache = new Map<string, ReturnType<FetchMetadata>>()
return {
fetch: (pkgName, opts) => {
const key = JSON.stringify([pkgName, opts])
const cached = cache.get(key)
if (cached != null) return cached
const pending = fetch(pkgName, opts)
const bodiless = pending.then((result) =>
result.notModified ? result : { ...result, jsonText: undefined }
)
bodiless.catch(() => cache.delete(key))
cache.set(key, bodiless)
return pending
},
clear: () => {
cache.clear()
},
}
}

View File

@@ -458,7 +458,7 @@ export async function pickPackage (
if (!isModifiedValid || modifiedDate > opts.publishedBy) {
// Save the abbreviated metadata to the abbreviated cache before re-fetching full.
if (!opts.dryRun) {
const abbreviatedJson = prepareJsonForDisk(fetchResult.meta, fetchResult.etag, fetchResult.jsonText)
const abbreviatedJson = prepareJsonForDisk(resultToSave.meta, resultToSave.etag, resultToSave.jsonText)
// Fire-and-forget save to the abbreviated cache path (pkgMirror).
runLimited(pkgMirror, (limit) => limit(async () => {
try {

View File

@@ -0,0 +1,104 @@
import { expect, test } from '@jest/globals'
import type { PackageMeta } from '@pnpm/resolving.registry.types'
import type { FetchMetadataResult } from '../src/fetch.js'
import { memoizeFetchMetadata } from '../src/memoizeFetchMetadata.js'
const REGISTRY = 'https://registry.npmjs.org/'
function fooFetchResult (): FetchMetadataResult {
return {
meta: { name: 'foo' } as PackageMeta,
jsonText: '{"name":"foo"}',
etag: '"abc"',
}
}
test('the initiating caller receives the raw body; cache hits get a body-less clone sharing the same meta', async () => {
const result = fooFetchResult()
let calls = 0
const { fetch } = memoizeFetchMetadata(async () => {
calls++
return result
})
const first = await fetch('foo', { registry: REGISTRY })
expect(first).toBe(result)
if (first.notModified) throw new Error('expected a fresh fetch result')
expect(first.jsonText).toBe('{"name":"foo"}')
const second = await fetch('foo', { registry: REGISTRY })
expect(calls).toBe(1)
if (second.notModified) throw new Error('expected a cached fetch result')
expect(second.jsonText).toBeUndefined()
expect(second.meta).toBe(result.meta)
expect(second.etag).toBe(result.etag)
// The clone keeps the original intact for the initiating caller.
expect(result.jsonText).toBe('{"name":"foo"}')
})
test('a caller arriving while the fetch is in flight gets the body-less clone', async () => {
let release!: (result: FetchMetadataResult) => void
const { fetch } = memoizeFetchMetadata(async () => new Promise<FetchMetadataResult>((resolve) => {
release = resolve
}))
const firstPromise = fetch('foo', { registry: REGISTRY })
const secondPromise = fetch('foo', { registry: REGISTRY })
release(fooFetchResult())
const [first, second] = await Promise.all([firstPromise, secondPromise])
if (first.notModified || second.notModified) throw new Error('expected fresh fetch results')
expect(first.jsonText).toBe('{"name":"foo"}')
expect(second.jsonText).toBeUndefined()
})
test('requests with different options are cached separately', async () => {
const fetchedOpts: boolean[] = []
const { fetch } = memoizeFetchMetadata(async (pkgName, opts) => {
fetchedOpts.push(opts.fullMetadata === true)
return fooFetchResult()
})
await fetch('foo', { registry: REGISTRY })
await fetch('foo', { registry: REGISTRY, fullMetadata: true })
await fetch('foo', { registry: REGISTRY })
expect(fetchedOpts).toEqual([false, true])
})
test('a rejected fetch is evicted so the next request retries', async () => {
let calls = 0
const { fetch } = memoizeFetchMetadata(async () => {
calls++
if (calls === 1) throw new Error('network down')
return fooFetchResult()
})
await expect(fetch('foo', { registry: REGISTRY })).rejects.toThrow('network down')
const retried = await fetch('foo', { registry: REGISTRY })
if (retried.notModified) throw new Error('expected a fresh fetch result')
expect(retried.meta.name).toBe('foo')
expect(calls).toBe(2)
})
test('clear() empties the cache', async () => {
let calls = 0
const { fetch, clear } = memoizeFetchMetadata(async () => {
calls++
return fooFetchResult()
})
await fetch('foo', { registry: REGISTRY })
clear()
await fetch('foo', { registry: REGISTRY })
expect(calls).toBe(2)
})
test('notModified results pass through unchanged', async () => {
const { fetch } = memoizeFetchMetadata(async () => ({ notModified: true as const }))
const first = await fetch('foo', { registry: REGISTRY })
const second = await fetch('foo', { registry: REGISTRY })
expect(first.notModified).toBe(true)
expect(second).toBe(first)
})

View File

@@ -1,4 +1,5 @@
import { rmSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { expect, test } from '@jest/globals'
import { ABBREVIATED_META_DIR } from '@pnpm/constants'
@@ -17,6 +18,16 @@ import {
const REGISTRY = 'https://registry.npmjs.org/'
async function readMirrorWithRetry (pkgMirror: string, attempts: number): Promise<string | undefined> {
try {
return await readFile(pkgMirror, 'utf8')
} catch {
if (attempts <= 0) return undefined
await new Promise((resolve) => setTimeout(resolve, 10))
return readMirrorWithRetry(pkgMirror, attempts - 1)
}
}
function fooMeta (): PackageMeta {
return {
name: 'foo',
@@ -154,6 +165,32 @@ test('prefer-offline resolution promotes the disk-loaded packument into the in-m
expect(second.pickedPackage?.version).toBe('1.0.0')
})
test('the raw response body is written verbatim to the disk mirror', async () => {
const meta = fooMeta()
// A body distinct from the compact JSON.stringify(meta) so we can prove the
// mirror is written from the raw response text, not re-serialized.
const rawBody = JSON.stringify(meta, null, 2)
const cacheDir = temporaryDirectory()
const pkgMirror = getPkgMirrorPath(cacheDir, ABBREVIATED_META_DIR, REGISTRY, 'foo')
const ctx = {
fetch: async () => ({ meta, jsonText: rawBody, etag: undefined }),
metaCache: createMetaCache(),
cacheDir,
}
// A range spec avoids the exact-version fast path and, with no on-disk mirror,
// forces the network-fetch branch that writes the mirror and caches the meta.
const spec: RegistryPackageSpec = { type: 'range', name: 'foo', fetchSpec: '^1.0.0' }
const res = await pickPackage(ctx, spec, { registry: REGISTRY, dryRun: false, preferredVersionSelectors: undefined })
expect(res.pickedPackage?.version).toBe('1.0.0')
// The mirror is written fire-and-forget, so retry until it appears.
const mirror = await readMirrorWithRetry(pkgMirror, 100)
// The body after the headers line is the raw response text, unchanged.
expect(mirror?.slice(mirror.indexOf('\n') + 1)).toBe(rawBody)
})
test('a disk-promoted cache entry that cannot satisfy the spec falls back to the registry under prefer-offline', async () => {
const staleMeta = fooMeta()
const freshMeta = fooMeta()