Repository navigation
Cut memory use of npm metadata rewriting - #413
Merged
Merged
Conversation
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
force-pushed
the
npm-metadata-memory
branch
from
October 7, 2026 08:12
d09bcc0 to
d9db46b
Compare
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 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:
Two small things:
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
approved these changes
Oct 8, 2026
Contributor
|
Thanks! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.rewriteMetadatadecodes the whole packument intomap[string]anyand 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 parallelnpm ciruns are enough to pass 2 GB.cachedRewriteskips the rewrite cache and full packuments are requested, so this happens on every metadata request.npmVersionTarballandversionInCooldowndecode every version of the packument to read one value.Changes
Rewrite npm metadata in place (
npm.go, newjsonscan.go)A small scanner walks the raw JSON and reports member offsets without decoding anything.
rewriteMetadatadecodes only the version names,timeanddist-tags, then runs the existingapplyCooldownFiltering/applyDenylistFilteringon them unchanged. The response is assembled from slices of the original bytes. Only each kept version'sdist.tarballis written fresh, plustimeanddist-tagswhen a filter changed them.versionsobject still errors only when the package has denylisted versions.<>&were HTML-escaped, and keys were re-sorted.JSON.parsedoes). Earlier duplicates ofversions,timeanddist-tagsare dropped, so they can't carry unfiltered data past the filters.Single-value lookups on the download path
npmVersionTarballandversionInCooldownreadversions[v].dist.tarballandtime[v]in place.Rewrite cache keyed by the stored digest (
handler.go,rewrite_cache.go)cacheMetadataBlobnow records the SHA-256 thatStorage.Storealready computes in the existingmetadata_cache.content_digestcolumn, formattedsha256:<hex>like the container rows.rewriteCacheKeyuses the same format.Proxy.storedRewriteis 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 withinMetadataTTL, has a digest and no content encoding, and cooldown is off, which matches whencachedRewriteuses the cache today.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.rewriteMetadatanpmVersionTarballWhat remains in
rewriteMetadatais 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:timeentries,latestretargeting, custom tags);go test ./...,go test -race ./internal/handler/andgolangci-lint run ./...(v2.13.1, as in CI) pass.Not in this PR