Skip to content

Cut memory use of npm metadata rewriting - #413

Merged
andrew merged 1 commit into
git-pkgs:mainfrom
simonchrz:npm-metadata-memory
Oct 8, 2026
Merged

andrew merged 1 commit into
git-pkgs:mainfrom
simonchrz:npm-metadata-memory

Conversation

@simonchrz

@simonchrz simonchrz commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Problem

We run the proxy for npm, Composer and Alpine and see resident memory above 2 GB. Most of it traces back to npm metadata handling:

  • NPMHandler.rewriteMetadata decodes the whole packument into map[string]any and re-encodes it. Generic maps cost many times the JSON size, so a full packument of tens of megabytes (typescript, @types/node, aws-sdk) briefly needs hundreds of MB per request. Several parallel npm ci runs are enough to pass 2 GB.
  • With cooldown enabled, cachedRewrite skips the rewrite cache and full packuments are requested, so this happens on every metadata request.
  • On every uncached tarball download, npmVersionTarball and versionInCooldown decode every version of the packument to read one value.
  • Even when the rewrite is cached, each request reads the whole stored document and SHA-256 hashes it to build the cache key.

Changes

Rewrite npm metadata in place (npm.go, new jsonscan.go)
A small scanner walks the raw JSON and reports member offsets without decoding anything. rewriteMetadata decodes only the version names, time and dist-tags, then runs the existing applyCooldownFiltering / applyDenylistFiltering on them unchanged. The response is assembled from slices of the original bytes. Only each kept version's dist.tarball is written fresh, plus time and dist-tags when a filter changed them.

  • Behaviour is otherwise unchanged. Invalid JSON is still rejected, so the handler falls back to proxying the original. A missing versions object still errors only when the package has denylisted versions.
  • Untouched values now reach clients byte for byte. Before, large integers went through float64, <>& were HTML-escaped, and keys were re-sorted.
  • When a top-level key repeats, the last occurrence is used (as JSON.parse does). Earlier duplicates of versions, time and dist-tags are dropped, so they can't carry unfiltered data past the filters.

Single-value lookups on the download path
npmVersionTarball and versionInCooldown read versions[v].dist.tarball and time[v] in place.

Rewrite cache keyed by the stored digest (handler.go, rewrite_cache.go)

  • cacheMetadataBlob now records the SHA-256 that Storage.Store already computes in the existing metadata_cache.content_digest column, formatted sha256:<hex> like the container rows.
  • rewriteCacheKey uses the same format.
  • The new Proxy.storedRewrite is called first by the npm and Composer metadata handlers. It serves a cached rewrite after one row lookup, without reading or hashing the stored document. It only applies when the row is within MetadataTTL, has a digest and no content encoding, and cooldown is off, which matches when cachedRewrite uses the cache today.
  • Otherwise the existing path runs unchanged. Rows written before this change have no digest and take the existing path until their next refresh.

Numbers

Benchmark included in npm_rewrite_bench_test.go: a synthetic full packument of about 25 MB with 5000 versions, Apple M-series, -benchtime 10x.

before after
rewriteMetadata 71 ms, 148–174 MB/op, 706k allocs/op 47 ms, 31 MB/op, 90k allocs/op
npmVersionTarball 19 ms, 1 MB/op, 10k allocs/op 17 ms, ~1 KB/op, 1 alloc/op

What remains in rewriteMetadata is mostly the output buffer. Peak live memory drops by more than B/op shows, because the old code kept the whole decoded tree alive until it was re-encoded.

Tests

  • jsonscan_test.go: escapes, brackets inside strings, escaped keys, repeated keys, malformed input.
  • npm_rewrite_test.go:
    • output is byte-identical apart from tarball URLs;
    • odd version entries pass through unchanged;
    • cooldown and denylist together (versions, time entries, latest retargeting, custom tags);
    • duplicate top-level keys;
    • an npm and Composer repeat request is served while storage reads fail;
    • the fast path is skipped when the row is stale or cooldown is on.
  • go test ./..., go test -race ./internal/handler/ and golangci-lint run ./... (v2.13.1, as in CI) pass.

Not in this PR

  • Composer still expands minified metadata before rewriting it. The same approach would help there; happy to follow up.
  • The rewrite cache is still bypassed when cooldown is enabled. Each rewrite is now much cheaper, but a short-TTL cache for that case could be a follow-up.

Rewriting a packument decoded the whole document into map[string]any and
encoded it again. For a full packument of tens of megabytes that costs many
times the document size per request, and with cooldown enabled (which skips
the rewrite cache and forces full packuments) it happens on every request.

- Rewrite npm metadata in place: walk the raw JSON for member offsets, decode
  only the version names, time and dist-tags for the existing filters, and
  assemble the response from slices of the original bytes with only tarball
  URLs (and filtered time/dist-tags) written fresh. Untouched values now reach
  clients byte for byte instead of round-tripping through encoding/json.
- Look up versions[v].dist.tarball and time[v] directly on the tarball
  download path instead of decoding every version.
- Record the stored metadata's SHA-256 in metadata_cache.content_digest and
  key the rewrite cache by it, so a repeat npm or Composer request inside the
  TTL is served from the rewrite cache without reading or hashing the stored
  document.

On a synthetic 25 MB packument with 5000 versions, rewriteMetadata drops from
~150-174 MB / 706k allocations per call to ~31 MB / 90k, and runs in about
two thirds of the time. npmVersionTarball drops from ~1 MB / 10k allocations
to one allocation.
@simonchrz
simonchrz force-pushed the npm-metadata-memory branch from d09bcc0 to d9db46b Compare October 7, 2026 08:12
@montehurd

Copy link
Copy Markdown
Contributor

Thanks for this! I reviewed it against #402 (the rewrite cache) and load-tested it on our CI proxy.

The cache keys line up: both storage backends return a hex SHA-256, the metadata upsert updates content_digest, and both handlers call storedRewrite with the same key cachedRewrite uses. Cooldown and TTL gating match. The full suite and -race on internal/handler pass locally.

Load test with 20 concurrent clients fetching 1,538 npm packuments and MediaWiki core's Composer dependencies. Stock v0.9.1 against this PR, both built with the same toolchain, three rounds each:

v0.9.1 This PR
npm cached, CPU per request 0.74 to 0.80 ms 0.18 to 0.20 ms
Composer cached, CPU per request 0.39 to 0.42 ms 0.19 to 0.20 ms
npm cached with cooldown, time 44 to 47 s 17 to 19 s
npm cache fill with cooldown, peak memory 22.3 GB 2.0 GB

Two small things:

  • Cache rewritten npm and Composer metadata #402's cache was self-verifying, since the key was the hash of the bytes being rewritten. The fast path now trusts that a row's content_digest matches the blob at storage_path. If a refetch stores new bytes and the upsert then fails while the row is within TTL, the fast path keeps serving the old rewrite. It's rare, but the invariant is worth noting on ContentDigest or in cacheMetadataBlob.
  • Optional: on a fast-path miss, the row storedRewrite read could be passed along, so cachedMetadataState doesn't query it again.

The cooldown follow-ups you mention, a short-TTL rewrite cache and the same approach for Composer, would be very welcome on our side.

@andrew
andrew merged commit 1584499 into git-pkgs:main Oct 8, 2026
6 checks passed
@andrew

andrew commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants