Repository navigation
Conversation
A cached rewrite made with cooldown on stays right until a version it held back leaves cooldown, so it now expires then instead of after a fixed time. Handlers ask cooldown through Proxy.cooldown, which reports each withheld version to the rewrites running at that moment. A rewrite that held nothing back is kept for the shortest cooldown, at most a day.
Closes git-pkgs#525. With metadata caching and the rewrite cache on, a packument is streamed from upstream into storage and rewritten from there in two passes: the first collects the time map, dist-tags and version names, the second copies each kept version and rewrites its tarball URL. Neither the packument nor its rewrite is held in memory uncompressed, so metadata_max_size no longer limits packuments. Both passes check the stored bytes against the digest in the cache row, so a document replaced in between is not rewritten from mixed bytes. Documents the stream can't handle (abbreviated, no versions object) and rewrite errors fall back to the in-memory rewrite.
andrew
left a comment
There was a problem hiding this comment.
Two availability regressions need fixing before merge. I reproduced both through npm handler requests with cooldown and metadata caching enabled, comparing the streaming path with the existing in-memory path.
-
storeUpstreamMetadataininternal/handler/metadata_stored.goreplaces the cached document beforeserveStreamedPackumentchecks whether full metadata is required. After seeding a full packument and expiring its TTL, an upstream response containing an abbreviated packument overwrites the usable full copy. The streaming rewrite rejects it, and the in-memory fallback can no longer serve the old full document: the request returns 502, whereas the existing path rejects the abbreviated response before caching and serves the old copy with 200. Please validate a staged copy before replacing the existing cache, and add a regression test that verifies both the response and the retained cache contents. -
serveStreamedPackumentininternal/handler/npm.gotreats storage write failures as upstream failures. With a healthy upstream, an uncached packument belowmetadata_max_size, andStorage.Storereturning a disk-full error, the streaming path returns 502. The existing in-memory path still returns the filtered document with 200 because caching is best effort. Please fall back to the bounded in-memory path when storage fails, and cover this with a handler regression test.
… lookups A fetched packument is now stored under a staging path and checked before it replaces the cached copy, so an abbreviated one no longer overwrites a full copy; the full copy is served as when upstream fails. A failure to store falls back to the in-memory path, as caching is best effort there. Requests for a single version or dist-tag and the tarball download's cooldown and integrity lookup read the stored packument as a stream too, keeping only the one version entry they need.
# Conflicts: # internal/handler/handler.go
|
Both fixed in 8c9e87f.
The same commit also streams the two npm paths the description listed as not covered: Main is merged in, so #638 no longer shows here. |
andrew
left a comment
There was a problem hiding this comment.
Two availability failures and a regression-test gap remain:
-
internal/handler/metadata_stored.go:125skips stale fallback onErrUpstreamNotFound. With an expired cached packument and an upstream 404, a handler request returns 404 through streaming while the existing in-memory path serves the cached document with 200. Preserve the existing stale fallback and add a request-level test with a cached document. -
internal/handler/metadata_stored.go:157accepts a 304 without checking that the stored blob remains usable. I reproduced this by caching a packument larger thanmetadata_max_size, deleting its blob while retaining the row, expiring the TTL, and clearing the rewrite cache. The next handler request returns 502 against a healthy upstream: the 304 refreshes the row, then recovery uses the bounded in-memory path. Retry through streaming without validators when the stored blob is missing, and cover recovery for a document over the size limit. -
internal/handler/npm_stream_test.go:265does not exercise the abbreviated replacement.newStreamUpstreamreturns 304 wheneverIf-None-Matchis"v1", before reading the replacement body, so this test passes without exercising staging or rejection. Make the replacement return 200, assert that it was sent, and retain the response and stored-content checks.
…or a missing blob
|
Fixed in 760623e.
The first two tests fail on the previous commit. |
andrew
left a comment
There was a problem hiding this comment.
Missing-blob recovery still fails while the metadata cache row is within its TTL. In internal/handler/metadata_stored.go:99, storedMetadataEntryState treats the row as fresh without checking whether its blob exists. The streaming read then fails, and recovery falls back to the bounded in-memory path.
I reproduced this through an npm handler request: cache a packument above metadata_max_size, delete its blob while retaining the fresh row, clear the rewrite cache, then request the package again. It returns 502 against a healthy upstream. The same test with an expired row returns 200, so the new 304 recovery handles that case. Please recover missing blobs through streaming regardless of TTL, and extend TestNPMStreamedPackumentBlobMissing to cover a still-fresh row and verify that its blob is restored.
A row within its TTL was served without checking its blob, so a missing blob failed the streamed read and the request fell back to the bounded in-memory path. A read that finds the stored copy missing now fetches the packument again into storage, ignoring that row, and reads it once more, for packument, version and tarball requests.
|
Fixed in 11ff4cf. A read that finds the stored copy missing now fetches the packument again, ignoring that row whether it's within its TTL or not, and reads it once more. It checks on the failed read rather than checking the blob on every request, so a fresh hit doesn't cost an extra stat or HEAD.
|
A body that ended before its Content-Length was read as complete in memory: ReadMetadataSized took the transport's io.ErrUnexpectedEOF for the end of a body shorter than its size hint. The half document was served and cached, or failed the filter with a 502, instead of the stored copy being served. Upstream bodies are now read through a reader that marks their read errors, and such a failure is an upstream failure on both paths: streamed, it no longer counts as a failure to store, which had sent the request to the bounded in-memory path. A stored packument whose bytes no longer match its row's digest is now fetched again like a missing one. Tests also cover concurrent requests sharing one fetch, client revalidation and upstream failures with nothing cached. The stream tests build cooldown through cooldown.NewPolicy as the server does, since a bare cooldown.Config parses itself on first use without locking.
|
More hardening in f0c96e8, from new tests:
Also new: concurrent requests sharing one fetch, client revalidation with While writing the concurrency test I found that |
andrew
left a comment
There was a problem hiding this comment.
Three things before this can merge.
-
The streamed path has no size limit.
storeUpstreamMetadataininternal/handler/metadata_stored.gocopies the upstream body into storage uncapped, twice with cooldown (staged, then final).ScanPackument,LookupVersionandjsonstreamdecode each top-level member whole throughjson.Decoder, so one oversized member such asreadmeis held in memory.metadata_max_sizewas the protection against a misbehaving upstream, and the docs change says it no longer applies here. Please cap the upstream body on this path and add a request-level test with a body over the cap. In the same paragraph ofdocs/configuration.md, the sentence saying/<package>/<version>requests still read the packument into memory is out of date, since 8c9e87f streams them. -
The
streamProxyhelper ininternal/handler/npm_stream_test.goswitched tocooldown.NewPolicyto avoid the race incooldown.Config.parse, while every other handler test assigns acooldown.Configdirectly. That race is a cooldown bug, tracked in git-pkgs/cooldown#15, and gets fixed there. Once a cooldown release with the fix is bumped here, please revert the helper and its comment. This PR waits on that release. -
Please move the truncated-body change to
fetchUpstreamMetadataandReadMetadataSized, withTestReadMetadataSizedTruncatedUpstreamand the in-memory cases of the truncation tests, into its own PR. It changes behaviour for every ecosystem, so it needs its own review and its own revert.upstreamBodyReaderstays here forstoreUpstreamMetadata. Please keep further rounds to what the review asks for: the version and tarball streaming in 8c9e87f and this change both arrived as additions in fix-up rounds.
The change to ReadMetadataSized and fetchUpstreamMetadata, with its tests, is now git-pkgs#649. upstreamBodyReader stays for storeUpstreamMetadata, in a file of its own that git-pkgs#649 adds with the same content.
metadata_max_size bounds what is read into memory, but a packument streamed from upstream into storage had no limit, and with cooldown was stored twice. It is now capped by metadata_stream_max_size, 1 GB by default. A document over the cap fails as an upstream failure: nothing is stored, and the stored copy, if any, is served instead. The metadata settings move into configureMetadata to keep serve under the maintidx limit.
# Conflicts: # cmd/proxy/main.go
I also merged main (98ff4fd) for a conflict in |
Closes #525.
With
cache_metadataand the rewrite cache on, an npm packument is streamed from upstream into storage and rewritten from there in two passes, as the issue describes:npm.ScanPackumentreads the stored document once and keeps the time map, dist-tags and version names.npm.RewriteStreamreads it again throughinternal/jsonstream, copying each kept version with its tarball URL rewritten and dropping the rest. Its output matchesnpm.Rewriteexcept for whitespace inside a filteredtimemap; a test compares the two over documents with the members in either order, duplicates and values copied verbatim.storedMetadataDocumentstreams the upstream body into storage withoutReadMetadata, sometadata_max_sizedoesn't apply. It shares fetches, leases, 304 revalidation and the stale fallback with the in-memory lookup. With cooldown, a fetched packument is staged under its own path and checked before it replaces the cached copy, so an abbreviated one never overwrites a full one. A failure to store falls back to the in-memory path.Request tests through
/npm/...with cooldown on cover a packument larger thanmetadata_max_size(the in-memory path returns 502 for it),timeafterversionsandlatestinside cooldown, the stale fallback, 404, a 304 that keeps the stored document, a document changed in storage, an abbreviated packument from upstream keeping the full copy, a failing store, version and dist-tag requests, and the tarball cooldown and URL lookup.Against registry.npmjs.org with
cooldown.default: 3d, the first request forrenovate(73 MB) peaked at 60 MB RSS, against 284 MB before, at about the same time (1.07s vs 0.95s)./<package>/<version>and dist-tags resolve the same way (npm.VersionStream), and the tarball download's cooldown, integrity and attestations lookups read only the one version entry (npm.LookupVersion). Forrenovate, a version request plus a tarball download peaked at 49 MB, against 283 MB.