Cache rewritten npm and Composer metadata (#402)
The npm and Composer handlers rewrite every metadata document they serve
so that download URLs point at the proxy: decode the whole document into
generic maps, change the URLs, encode it again, and for Composer expand
the minified format first. That ran on every request, cached metadata
included. With metadata caching on and upstream out of the picture, a
cached request still cost 7 ms for @babel/core, 17 ms for
symfony/console and 155 ms and 134 MB of allocations for typescript, and
an 8 vCPU VM serving cached npm packuments to 20 clients ran the proxy
at 450% CPU for about 1,100 requests a second.
Rewritten documents are now kept in memory, keyed by the ecosystem,
proxy URL, package and a SHA-256 of the raw document, so new bytes from
upstream are rewritten again and nothing is served stale. Requests that
arrive while a document is being rewritten wait for that rewrite rather
than running their own; a waiter leaves when its client does, and the
rewrite still completes and is cached. The denylist is fixed at startup,
so it needs no place in the key. Cooldown filtering depends on the
current time, so with cooldown on the cache is bypassed.
metadata_rewrite_cache_size bounds the cache (default "256MB", least
recently used out first, "0" to rewrite on every request). NewProxy
callers keep the old behaviour unless they set it.
Whole cached requests, measured locally:
@babel/core 7.1 ms -> 0.38 ms, 54,489 -> 119 allocations
typescript 155 ms -> 10.5 ms, 1.37M -> 133 allocations
symfony/console 17.4 ms -> 0.48 ms, 165,909 -> 123 allocations
What remains is reading the raw document from storage and hashing it.
2026-10-03 04:09:15 -07:00
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
import (
|
2026-10-15 12:46:55 +02:00
|
|
|
"bytes"
|
|
|
|
|
"compress/gzip"
|
Cache rewritten npm and Composer metadata (#402)
The npm and Composer handlers rewrite every metadata document they serve
so that download URLs point at the proxy: decode the whole document into
generic maps, change the URLs, encode it again, and for Composer expand
the minified format first. That ran on every request, cached metadata
included. With metadata caching on and upstream out of the picture, a
cached request still cost 7 ms for @babel/core, 17 ms for
symfony/console and 155 ms and 134 MB of allocations for typescript, and
an 8 vCPU VM serving cached npm packuments to 20 clients ran the proxy
at 450% CPU for about 1,100 requests a second.
Rewritten documents are now kept in memory, keyed by the ecosystem,
proxy URL, package and a SHA-256 of the raw document, so new bytes from
upstream are rewritten again and nothing is served stale. Requests that
arrive while a document is being rewritten wait for that rewrite rather
than running their own; a waiter leaves when its client does, and the
rewrite still completes and is cached. The denylist is fixed at startup,
so it needs no place in the key. Cooldown filtering depends on the
current time, so with cooldown on the cache is bypassed.
metadata_rewrite_cache_size bounds the cache (default "256MB", least
recently used out first, "0" to rewrite on every request). NewProxy
callers keep the old behaviour unless they set it.
Whole cached requests, measured locally:
@babel/core 7.1 ms -> 0.38 ms, 54,489 -> 119 allocations
typescript 155 ms -> 10.5 ms, 1.37M -> 133 allocations
symfony/console 17.4 ms -> 0.48 ms, 165,909 -> 123 allocations
What remains is reading the raw document from storage and hashing it.
2026-10-03 04:09:15 -07:00
|
|
|
"container/list"
|
|
|
|
|
"context"
|
2026-10-09 08:10:41 +02:00
|
|
|
"crypto/rand"
|
Cache rewritten npm and Composer metadata (#402)
The npm and Composer handlers rewrite every metadata document they serve
so that download URLs point at the proxy: decode the whole document into
generic maps, change the URLs, encode it again, and for Composer expand
the minified format first. That ran on every request, cached metadata
included. With metadata caching on and upstream out of the picture, a
cached request still cost 7 ms for @babel/core, 17 ms for
symfony/console and 155 ms and 134 MB of allocations for typescript, and
an 8 vCPU VM serving cached npm packuments to 20 clients ran the proxy
at 450% CPU for about 1,100 requests a second.
Rewritten documents are now kept in memory, keyed by the ecosystem,
proxy URL, package and a SHA-256 of the raw document, so new bytes from
upstream are rewritten again and nothing is served stale. Requests that
arrive while a document is being rewritten wait for that rewrite rather
than running their own; a waiter leaves when its client does, and the
rewrite still completes and is cached. The denylist is fixed at startup,
so it needs no place in the key. Cooldown filtering depends on the
current time, so with cooldown on the cache is bypassed.
metadata_rewrite_cache_size bounds the cache (default "256MB", least
recently used out first, "0" to rewrite on every request). NewProxy
callers keep the old behaviour unless they set it.
Whole cached requests, measured locally:
@babel/core 7.1 ms -> 0.38 ms, 54,489 -> 119 allocations
typescript 155 ms -> 10.5 ms, 1.37M -> 133 allocations
symfony/console 17.4 ms -> 0.48 ms, 165,909 -> 123 allocations
What remains is reading the raw document from storage and hashing it.
2026-10-03 04:09:15 -07:00
|
|
|
"crypto/sha256"
|
2026-10-09 08:10:41 +02:00
|
|
|
"encoding/binary"
|
2026-10-08 07:36:33 +02:00
|
|
|
"encoding/hex"
|
Cache rewritten npm and Composer metadata (#402)
The npm and Composer handlers rewrite every metadata document they serve
so that download URLs point at the proxy: decode the whole document into
generic maps, change the URLs, encode it again, and for Composer expand
the minified format first. That ran on every request, cached metadata
included. With metadata caching on and upstream out of the picture, a
cached request still cost 7 ms for @babel/core, 17 ms for
symfony/console and 155 ms and 134 MB of allocations for typescript, and
an 8 vCPU VM serving cached npm packuments to 20 clients ran the proxy
at 450% CPU for about 1,100 requests a second.
Rewritten documents are now kept in memory, keyed by the ecosystem,
proxy URL, package and a SHA-256 of the raw document, so new bytes from
upstream are rewritten again and nothing is served stale. Requests that
arrive while a document is being rewritten wait for that rewrite rather
than running their own; a waiter leaves when its client does, and the
rewrite still completes and is cached. The denylist is fixed at startup,
so it needs no place in the key. Cooldown filtering depends on the
current time, so with cooldown on the cache is bypassed.
metadata_rewrite_cache_size bounds the cache (default "256MB", least
recently used out first, "0" to rewrite on every request). NewProxy
callers keep the old behaviour unless they set it.
Whole cached requests, measured locally:
@babel/core 7.1 ms -> 0.38 ms, 54,489 -> 119 allocations
typescript 155 ms -> 10.5 ms, 1.37M -> 133 allocations
symfony/console 17.4 ms -> 0.48 ms, 165,909 -> 123 allocations
What remains is reading the raw document from storage and hashing it.
2026-10-03 04:09:15 -07:00
|
|
|
"errors"
|
2026-10-09 08:10:41 +02:00
|
|
|
"io"
|
2026-10-08 13:26:55 +02:00
|
|
|
"net/http"
|
2026-10-09 08:10:41 +02:00
|
|
|
"slices"
|
2026-10-15 12:46:55 +02:00
|
|
|
"strconv"
|
Cache rewritten npm and Composer metadata (#402)
The npm and Composer handlers rewrite every metadata document they serve
so that download URLs point at the proxy: decode the whole document into
generic maps, change the URLs, encode it again, and for Composer expand
the minified format first. That ran on every request, cached metadata
included. With metadata caching on and upstream out of the picture, a
cached request still cost 7 ms for @babel/core, 17 ms for
symfony/console and 155 ms and 134 MB of allocations for typescript, and
an 8 vCPU VM serving cached npm packuments to 20 clients ran the proxy
at 450% CPU for about 1,100 requests a second.
Rewritten documents are now kept in memory, keyed by the ecosystem,
proxy URL, package and a SHA-256 of the raw document, so new bytes from
upstream are rewritten again and nothing is served stale. Requests that
arrive while a document is being rewritten wait for that rewrite rather
than running their own; a waiter leaves when its client does, and the
rewrite still completes and is cached. The denylist is fixed at startup,
so it needs no place in the key. Cooldown filtering depends on the
current time, so with cooldown on the cache is bypassed.
metadata_rewrite_cache_size bounds the cache (default "256MB", least
recently used out first, "0" to rewrite on every request). NewProxy
callers keep the old behaviour unless they set it.
Whole cached requests, measured locally:
@babel/core 7.1 ms -> 0.38 ms, 54,489 -> 119 allocations
typescript 155 ms -> 10.5 ms, 1.37M -> 133 allocations
symfony/console 17.4 ms -> 0.48 ms, 165,909 -> 123 allocations
What remains is reading the raw document from storage and hashing it.
2026-10-03 04:09:15 -07:00
|
|
|
"strings"
|
|
|
|
|
"sync"
|
2026-10-08 07:36:33 +02:00
|
|
|
"time"
|
|
|
|
|
|
2026-10-30 14:14:10 +01:00
|
|
|
"github.com/git-pkgs/proxy/internal/database"
|
2026-10-08 07:36:33 +02:00
|
|
|
"github.com/git-pkgs/proxy/internal/metrics"
|
Cache rewritten npm and Composer metadata (#402)
The npm and Composer handlers rewrite every metadata document they serve
so that download URLs point at the proxy: decode the whole document into
generic maps, change the URLs, encode it again, and for Composer expand
the minified format first. That ran on every request, cached metadata
included. With metadata caching on and upstream out of the picture, a
cached request still cost 7 ms for @babel/core, 17 ms for
symfony/console and 155 ms and 134 MB of allocations for typescript, and
an 8 vCPU VM serving cached npm packuments to 20 clients ran the proxy
at 450% CPU for about 1,100 requests a second.
Rewritten documents are now kept in memory, keyed by the ecosystem,
proxy URL, package and a SHA-256 of the raw document, so new bytes from
upstream are rewritten again and nothing is served stale. Requests that
arrive while a document is being rewritten wait for that rewrite rather
than running their own; a waiter leaves when its client does, and the
rewrite still completes and is cached. The denylist is fixed at startup,
so it needs no place in the key. Cooldown filtering depends on the
current time, so with cooldown on the cache is bypassed.
metadata_rewrite_cache_size bounds the cache (default "256MB", least
recently used out first, "0" to rewrite on every request). NewProxy
callers keep the old behaviour unless they set it.
Whole cached requests, measured locally:
@babel/core 7.1 ms -> 0.38 ms, 54,489 -> 119 allocations
typescript 155 ms -> 10.5 ms, 1.37M -> 133 allocations
symfony/console 17.4 ms -> 0.48 ms, 165,909 -> 123 allocations
What remains is reading the raw document from storage and hashing it.
2026-10-03 04:09:15 -07:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// rewriteCache keeps metadata documents after a handler has rewritten them.
|
|
|
|
|
// Rewriting means decoding the whole document into generic maps, changing its
|
|
|
|
|
// download URLs and encoding it again, which for a large npm packument or an
|
|
|
|
|
// expanded Composer document costs milliseconds to hundreds of milliseconds
|
|
|
|
|
// and megabytes of allocations. It ran on every request, cached metadata
|
|
|
|
|
// included. The cache keeps one rewrite per distinct upstream document, and
|
|
|
|
|
// concurrent requests for a document that is not cached yet share a single
|
|
|
|
|
// rewrite.
|
|
|
|
|
//
|
|
|
|
|
// Entries are keyed by a hash of the raw document, so new bytes from upstream
|
|
|
|
|
// are rewritten again rather than served stale. Everything else a rewrite
|
|
|
|
|
// depends on is fixed for the life of the process: the proxy URL is part of
|
Cache cooldown metadata rewrites for a minute
With cooldown on, every npm, Composer and Chef metadata request rewrote
the whole document, because a cached rewrite could keep hiding a
version after its cooldown ended. For a 24.5 MB packument that is about
45 ms and 31 MB of allocations per request, and the result was served
uncompressed and without Last-Modified, so Composer, which revalidates
p2 files with If-Modified-Since only, never got a 304.
The rewrite cache now keeps cooldown rewrites with an expiry of one
minute, so a version leaving cooldown shows up at most that late.
Concurrent requests share one rewrite, clients get the gzip copy, and
the rewrite is dated through metadataDate like any other. Its ETag is
the hash of its output, so replicas agree on it.
The cache is keyed by the digest the metadata cache already recorded,
instead of hashing the document again, which also lets storedRewrite
serve cooldown rewrites without reading the stored document.
Without the rewrite cache, cooldown rewrites behave as before.
2026-10-23 13:24:58 +02:00
|
|
|
// the key, and the denylist is loaded at startup. The exception is cooldown,
|
|
|
|
|
// which depends on the time of the request: with cooldown on, entries expire
|
2026-10-30 09:33:56 +01:00
|
|
|
// when the first version they held back leaves cooldown (see
|
|
|
|
|
// cooldown_expiry.go).
|
2026-10-08 13:26:55 +02:00
|
|
|
//
|
2026-10-12 07:07:03 +02:00
|
|
|
// Each entry keeps the ETag of its output, so clients can revalidate their
|
Share the dates of rewritten metadata across replicas
Dates were kept in each process's rewrite cache, so replicas sharing a
database and storage dated independently: one dated a document, upstream
changed in that second, and another gave the new document the same date,
so a client holding it got 304 and kept the old document.
- Date from metadata_dates instead of process memory: a package's date is
the second it started serving its current representation, recorded once
for every replica
- Switch a package to another representation only in a second later than
its recorded date, and serve it undated until then
- Date nothing in the second the process started, so dates clients hold
from before this change, or from the process it replaced, stay older
- Serve no date ahead of the local clock, recorded by a replica whose clock
is ahead
- Date rewrites without the rewrite cache too; the cache keeps only ETags
- Add two-replica, pre-fix date and clock skew regression tests
2026-10-12 12:52:02 +02:00
|
|
|
// copy without the output being hashed again on every request. Dates are not
|
|
|
|
|
// the cache's to give: they must agree across replicas, so the database holds
|
|
|
|
|
// them (see metadataDate).
|
2026-10-15 12:46:55 +02:00
|
|
|
//
|
|
|
|
|
// Each entry also keeps its output gzip-compressed, when that is smaller, so
|
|
|
|
|
// clients accepting gzip get it without the document being compressed again
|
|
|
|
|
// on every request, by the proxy or by a reverse proxy in front of it.
|
Cache rewritten npm and Composer metadata (#402)
The npm and Composer handlers rewrite every metadata document they serve
so that download URLs point at the proxy: decode the whole document into
generic maps, change the URLs, encode it again, and for Composer expand
the minified format first. That ran on every request, cached metadata
included. With metadata caching on and upstream out of the picture, a
cached request still cost 7 ms for @babel/core, 17 ms for
symfony/console and 155 ms and 134 MB of allocations for typescript, and
an 8 vCPU VM serving cached npm packuments to 20 clients ran the proxy
at 450% CPU for about 1,100 requests a second.
Rewritten documents are now kept in memory, keyed by the ecosystem,
proxy URL, package and a SHA-256 of the raw document, so new bytes from
upstream are rewritten again and nothing is served stale. Requests that
arrive while a document is being rewritten wait for that rewrite rather
than running their own; a waiter leaves when its client does, and the
rewrite still completes and is cached. The denylist is fixed at startup,
so it needs no place in the key. Cooldown filtering depends on the
current time, so with cooldown on the cache is bypassed.
metadata_rewrite_cache_size bounds the cache (default "256MB", least
recently used out first, "0" to rewrite on every request). NewProxy
callers keep the old behaviour unless they set it.
Whole cached requests, measured locally:
@babel/core 7.1 ms -> 0.38 ms, 54,489 -> 119 allocations
typescript 155 ms -> 10.5 ms, 1.37M -> 133 allocations
symfony/console 17.4 ms -> 0.48 ms, 165,909 -> 123 allocations
What remains is reading the raw document from storage and hashing it.
2026-10-03 04:09:15 -07:00
|
|
|
type rewriteCache struct {
|
|
|
|
|
maxBytes int64
|
Cache cooldown metadata rewrites for a minute
With cooldown on, every npm, Composer and Chef metadata request rewrote
the whole document, because a cached rewrite could keep hiding a
version after its cooldown ended. For a 24.5 MB packument that is about
45 ms and 31 MB of allocations per request, and the result was served
uncompressed and without Last-Modified, so Composer, which revalidates
p2 files with If-Modified-Since only, never got a 304.
The rewrite cache now keeps cooldown rewrites with an expiry of one
minute, so a version leaving cooldown shows up at most that late.
Concurrent requests share one rewrite, clients get the gzip copy, and
the rewrite is dated through metadataDate like any other. Its ETag is
the hash of its output, so replicas agree on it.
The cache is keyed by the digest the metadata cache already recorded,
instead of hashing the document again, which also lets storedRewrite
serve cooldown rewrites without reading the stored document.
Without the rewrite cache, cooldown rewrites behave as before.
2026-10-23 13:24:58 +02:00
|
|
|
// now reads the time entries expire by; nil means time.Now.
|
|
|
|
|
now func() time.Time
|
Cache rewritten npm and Composer metadata (#402)
The npm and Composer handlers rewrite every metadata document they serve
so that download URLs point at the proxy: decode the whole document into
generic maps, change the URLs, encode it again, and for Composer expand
the minified format first. That ran on every request, cached metadata
included. With metadata caching on and upstream out of the picture, a
cached request still cost 7 ms for @babel/core, 17 ms for
symfony/console and 155 ms and 134 MB of allocations for typescript, and
an 8 vCPU VM serving cached npm packuments to 20 clients ran the proxy
at 450% CPU for about 1,100 requests a second.
Rewritten documents are now kept in memory, keyed by the ecosystem,
proxy URL, package and a SHA-256 of the raw document, so new bytes from
upstream are rewritten again and nothing is served stale. Requests that
arrive while a document is being rewritten wait for that rewrite rather
than running their own; a waiter leaves when its client does, and the
rewrite still completes and is cached. The denylist is fixed at startup,
so it needs no place in the key. Cooldown filtering depends on the
current time, so with cooldown on the cache is bypassed.
metadata_rewrite_cache_size bounds the cache (default "256MB", least
recently used out first, "0" to rewrite on every request). NewProxy
callers keep the old behaviour unless they set it.
Whole cached requests, measured locally:
@babel/core 7.1 ms -> 0.38 ms, 54,489 -> 119 allocations
typescript 155 ms -> 10.5 ms, 1.37M -> 133 allocations
symfony/console 17.4 ms -> 0.48 ms, 165,909 -> 123 allocations
What remains is reading the raw document from storage and hashing it.
2026-10-03 04:09:15 -07:00
|
|
|
|
|
|
|
|
mu sync.Mutex
|
|
|
|
|
size int64
|
|
|
|
|
order *list.List // most recently used at the front
|
|
|
|
|
entries map[string]*list.Element
|
|
|
|
|
inFlight map[string]*inflightRewrite
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type rewriteEntry struct {
|
2026-10-15 12:46:55 +02:00
|
|
|
key string
|
|
|
|
|
rewrittenMetadata
|
Cache cooldown metadata rewrites for a minute
With cooldown on, every npm, Composer and Chef metadata request rewrote
the whole document, because a cached rewrite could keep hiding a
version after its cooldown ended. For a 24.5 MB packument that is about
45 ms and 31 MB of allocations per request, and the result was served
uncompressed and without Last-Modified, so Composer, which revalidates
p2 files with If-Modified-Since only, never got a 304.
The rewrite cache now keeps cooldown rewrites with an expiry of one
minute, so a version leaving cooldown shows up at most that late.
Concurrent requests share one rewrite, clients get the gzip copy, and
the rewrite is dated through metadataDate like any other. Its ETag is
the hash of its output, so replicas agree on it.
The cache is keyed by the digest the metadata cache already recorded,
instead of hashing the document again, which also lets storedRewrite
serve cooldown rewrites without reading the stored document.
Without the rewrite cache, cooldown rewrites behave as before.
2026-10-23 13:24:58 +02:00
|
|
|
// expires is when the entry stops being served, or zero for never.
|
|
|
|
|
expires time.Time
|
2026-10-15 12:46:55 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// size is how much of the cache's room the entry takes.
|
|
|
|
|
func (e *rewriteEntry) size() int64 {
|
2026-10-29 10:52:39 +01:00
|
|
|
return int64(len(e.plain) + len(e.gzipped))
|
Cache rewritten npm and Composer metadata (#402)
The npm and Composer handlers rewrite every metadata document they serve
so that download URLs point at the proxy: decode the whole document into
generic maps, change the URLs, encode it again, and for Composer expand
the minified format first. That ran on every request, cached metadata
included. With metadata caching on and upstream out of the picture, a
cached request still cost 7 ms for @babel/core, 17 ms for
symfony/console and 155 ms and 134 MB of allocations for typescript, and
an 8 vCPU VM serving cached npm packuments to 20 clients ran the proxy
at 450% CPU for about 1,100 requests a second.
Rewritten documents are now kept in memory, keyed by the ecosystem,
proxy URL, package and a SHA-256 of the raw document, so new bytes from
upstream are rewritten again and nothing is served stale. Requests that
arrive while a document is being rewritten wait for that rewrite rather
than running their own; a waiter leaves when its client does, and the
rewrite still completes and is cached. The denylist is fixed at startup,
so it needs no place in the key. Cooldown filtering depends on the
current time, so with cooldown on the cache is bypassed.
metadata_rewrite_cache_size bounds the cache (default "256MB", least
recently used out first, "0" to rewrite on every request). NewProxy
callers keep the old behaviour unless they set it.
Whole cached requests, measured locally:
@babel/core 7.1 ms -> 0.38 ms, 54,489 -> 119 allocations
typescript 155 ms -> 10.5 ms, 1.37M -> 133 allocations
symfony/console 17.4 ms -> 0.48 ms, 165,909 -> 123 allocations
What remains is reading the raw document from storage and hashing it.
2026-10-03 04:09:15 -07:00
|
|
|
}
|
|
|
|
|
|
2026-10-15 12:46:55 +02:00
|
|
|
// inflightRewrite is one rewrite that concurrent callers share. m and err are
|
|
|
|
|
// written before done closes and read only after, so the close is the
|
|
|
|
|
// handoff.
|
Cache rewritten npm and Composer metadata (#402)
The npm and Composer handlers rewrite every metadata document they serve
so that download URLs point at the proxy: decode the whole document into
generic maps, change the URLs, encode it again, and for Composer expand
the minified format first. That ran on every request, cached metadata
included. With metadata caching on and upstream out of the picture, a
cached request still cost 7 ms for @babel/core, 17 ms for
symfony/console and 155 ms and 134 MB of allocations for typescript, and
an 8 vCPU VM serving cached npm packuments to 20 clients ran the proxy
at 450% CPU for about 1,100 requests a second.
Rewritten documents are now kept in memory, keyed by the ecosystem,
proxy URL, package and a SHA-256 of the raw document, so new bytes from
upstream are rewritten again and nothing is served stale. Requests that
arrive while a document is being rewritten wait for that rewrite rather
than running their own; a waiter leaves when its client does, and the
rewrite still completes and is cached. The denylist is fixed at startup,
so it needs no place in the key. Cooldown filtering depends on the
current time, so with cooldown on the cache is bypassed.
metadata_rewrite_cache_size bounds the cache (default "256MB", least
recently used out first, "0" to rewrite on every request). NewProxy
callers keep the old behaviour unless they set it.
Whole cached requests, measured locally:
@babel/core 7.1 ms -> 0.38 ms, 54,489 -> 119 allocations
typescript 155 ms -> 10.5 ms, 1.37M -> 133 allocations
symfony/console 17.4 ms -> 0.48 ms, 165,909 -> 123 allocations
What remains is reading the raw document from storage and hashing it.
2026-10-03 04:09:15 -07:00
|
|
|
type inflightRewrite struct {
|
|
|
|
|
done chan struct{}
|
2026-10-15 12:46:55 +02:00
|
|
|
m rewrittenMetadata
|
Cache rewritten npm and Composer metadata (#402)
The npm and Composer handlers rewrite every metadata document they serve
so that download URLs point at the proxy: decode the whole document into
generic maps, change the URLs, encode it again, and for Composer expand
the minified format first. That ran on every request, cached metadata
included. With metadata caching on and upstream out of the picture, a
cached request still cost 7 ms for @babel/core, 17 ms for
symfony/console and 155 ms and 134 MB of allocations for typescript, and
an 8 vCPU VM serving cached npm packuments to 20 clients ran the proxy
at 450% CPU for about 1,100 requests a second.
Rewritten documents are now kept in memory, keyed by the ecosystem,
proxy URL, package and a SHA-256 of the raw document, so new bytes from
upstream are rewritten again and nothing is served stale. Requests that
arrive while a document is being rewritten wait for that rewrite rather
than running their own; a waiter leaves when its client does, and the
rewrite still completes and is cached. The denylist is fixed at startup,
so it needs no place in the key. Cooldown filtering depends on the
current time, so with cooldown on the cache is bypassed.
metadata_rewrite_cache_size bounds the cache (default "256MB", least
recently used out first, "0" to rewrite on every request). NewProxy
callers keep the old behaviour unless they set it.
Whole cached requests, measured locally:
@babel/core 7.1 ms -> 0.38 ms, 54,489 -> 119 allocations
typescript 155 ms -> 10.5 ms, 1.37M -> 133 allocations
symfony/console 17.4 ms -> 0.48 ms, 165,909 -> 123 allocations
What remains is reading the raw document from storage and hashing it.
2026-10-03 04:09:15 -07:00
|
|
|
err error
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-10 13:47:39 +02:00
|
|
|
// metadataValidators are what a client revalidates its copy of rewritten
|
|
|
|
|
// metadata with. lastModified is zero when there is none to give.
|
|
|
|
|
type metadataValidators struct {
|
2026-10-08 13:26:55 +02:00
|
|
|
etag string
|
|
|
|
|
lastModified time.Time
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-10 13:47:39 +02:00
|
|
|
// rewrittenMetadata is a rewritten metadata document with its validators.
|
2026-10-15 12:46:55 +02:00
|
|
|
// gzipped is the document gzip-compressed, or nil when there is no
|
2026-10-29 10:52:39 +01:00
|
|
|
// compressed copy to serve. The cache keeps only gzipped when there is one,
|
|
|
|
|
// so plain is nil then; content returns the document either way.
|
2026-10-10 13:47:39 +02:00
|
|
|
type rewrittenMetadata struct {
|
2026-10-29 10:52:39 +01:00
|
|
|
plain []byte
|
2026-10-10 13:47:39 +02:00
|
|
|
metadataValidators
|
2026-10-15 12:46:55 +02:00
|
|
|
gzipped []byte
|
2026-10-29 10:52:39 +01:00
|
|
|
// size is the length of the uncompressed document.
|
|
|
|
|
size int
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// content returns the document uncompressed. A cached document is kept
|
|
|
|
|
// only gzip-compressed, and is decompressed for each caller that needs the
|
|
|
|
|
// plain bytes: clients not accepting gzip, and handlers reading the document.
|
|
|
|
|
// The compressed copy was made in memory by gzipIfSmaller, so reading it back
|
|
|
|
|
// fails only on a bug; content returns nil then.
|
|
|
|
|
func (m rewrittenMetadata) content() []byte {
|
|
|
|
|
if m.plain != nil || m.gzipped == nil {
|
|
|
|
|
return m.plain
|
|
|
|
|
}
|
|
|
|
|
zr, err := gzip.NewReader(bytes.NewReader(m.gzipped))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
out := bytes.NewBuffer(make([]byte, 0, m.size))
|
|
|
|
|
if _, err := io.Copy(out, zr); err != nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return out.Bytes()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// identity returns m to be served without its gzip copy.
|
|
|
|
|
func (m rewrittenMetadata) identity() rewrittenMetadata {
|
|
|
|
|
m.plain = m.content()
|
|
|
|
|
m.gzipped = nil
|
|
|
|
|
return m
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// compact returns m as the cache keeps it: only the gzip copy when there is
|
|
|
|
|
// one.
|
|
|
|
|
func (m rewrittenMetadata) compact() rewrittenMetadata {
|
|
|
|
|
if m.gzipped != nil {
|
|
|
|
|
m.plain = nil
|
|
|
|
|
}
|
|
|
|
|
return m
|
2026-10-10 13:47:39 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-08 13:26:55 +02:00
|
|
|
// metadataETag returns the strong ETag of a rewritten document.
|
|
|
|
|
func metadataETag(out []byte) string {
|
|
|
|
|
sum := sha256.Sum256(out)
|
|
|
|
|
return `"` + hex.EncodeToString(sum[:]) + `"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-09 08:10:41 +02:00
|
|
|
// rewriteConfigNonce stands for the configuration a rewrite reads at startup,
|
|
|
|
|
// such as the denylist and cooldown rules, in ETags derived from a rewrite's
|
|
|
|
|
// inputs. It changes on every start, so no ETag outlives the configuration it
|
|
|
|
|
// was derived under.
|
|
|
|
|
var rewriteConfigNonce = rand.Text()
|
|
|
|
|
|
|
|
|
|
// keptVersionsETag returns the strong ETag of a rewrite from what fixes its
|
|
|
|
|
// output: the upstream document's digest, the proxy URL and package name its
|
|
|
|
|
// download URLs carry, the versions cooldown and the denylist kept, and the
|
|
|
|
|
// startup configuration. Hashing these costs far less than hashing the
|
|
|
|
|
// rewritten document. Each field is length-prefixed, so different inputs never
|
|
|
|
|
// hash the same bytes.
|
|
|
|
|
func keptVersionsETag(digest, proxyURL, name string, kept []string) string {
|
|
|
|
|
h := sha256.New()
|
|
|
|
|
var length []byte
|
|
|
|
|
for _, field := range append([]string{rewriteConfigNonce, digest, proxyURL, name}, slices.Sorted(slices.Values(kept))...) {
|
|
|
|
|
length = binary.AppendUvarint(length[:0], uint64(len(field)))
|
|
|
|
|
_, _ = h.Write(length)
|
|
|
|
|
_, _ = io.WriteString(h, field)
|
|
|
|
|
}
|
|
|
|
|
return `"` + hex.EncodeToString(h.Sum(nil)) + `"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// keepingRewrite rewrites a metadata document and reports the versions it
|
|
|
|
|
// kept. Together with the document they fix the rewrite's output.
|
|
|
|
|
type keepingRewrite func([]byte) ([]byte, []string, error)
|
|
|
|
|
|
Cache rewritten npm and Composer metadata (#402)
The npm and Composer handlers rewrite every metadata document they serve
so that download URLs point at the proxy: decode the whole document into
generic maps, change the URLs, encode it again, and for Composer expand
the minified format first. That ran on every request, cached metadata
included. With metadata caching on and upstream out of the picture, a
cached request still cost 7 ms for @babel/core, 17 ms for
symfony/console and 155 ms and 134 MB of allocations for typescript, and
an 8 vCPU VM serving cached npm packuments to 20 clients ran the proxy
at 450% CPU for about 1,100 requests a second.
Rewritten documents are now kept in memory, keyed by the ecosystem,
proxy URL, package and a SHA-256 of the raw document, so new bytes from
upstream are rewritten again and nothing is served stale. Requests that
arrive while a document is being rewritten wait for that rewrite rather
than running their own; a waiter leaves when its client does, and the
rewrite still completes and is cached. The denylist is fixed at startup,
so it needs no place in the key. Cooldown filtering depends on the
current time, so with cooldown on the cache is bypassed.
metadata_rewrite_cache_size bounds the cache (default "256MB", least
recently used out first, "0" to rewrite on every request). NewProxy
callers keep the old behaviour unless they set it.
Whole cached requests, measured locally:
@babel/core 7.1 ms -> 0.38 ms, 54,489 -> 119 allocations
typescript 155 ms -> 10.5 ms, 1.37M -> 133 allocations
symfony/console 17.4 ms -> 0.48 ms, 165,909 -> 123 allocations
What remains is reading the raw document from storage and hashing it.
2026-10-03 04:09:15 -07:00
|
|
|
// errSharedRewriteAbandoned is what waiters see if the caller running a
|
|
|
|
|
// shared rewrite panicked out of it.
|
|
|
|
|
var errSharedRewriteAbandoned = errors.New("shared metadata rewrite did not complete")
|
|
|
|
|
|
|
|
|
|
// newRewriteCache returns a cache holding up to maxBytes of rewritten output.
|
|
|
|
|
// A size of zero or less disables it.
|
|
|
|
|
func newRewriteCache(maxBytes int64) *rewriteCache {
|
|
|
|
|
if maxBytes <= 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return &rewriteCache{
|
Share the dates of rewritten metadata across replicas
Dates were kept in each process's rewrite cache, so replicas sharing a
database and storage dated independently: one dated a document, upstream
changed in that second, and another gave the new document the same date,
so a client holding it got 304 and kept the old document.
- Date from metadata_dates instead of process memory: a package's date is
the second it started serving its current representation, recorded once
for every replica
- Switch a package to another representation only in a second later than
its recorded date, and serve it undated until then
- Date nothing in the second the process started, so dates clients hold
from before this change, or from the process it replaced, stay older
- Serve no date ahead of the local clock, recorded by a replica whose clock
is ahead
- Date rewrites without the rewrite cache too; the cache keeps only ETags
- Add two-replica, pre-fix date and clock skew regression tests
2026-10-12 12:52:02 +02:00
|
|
|
maxBytes: maxBytes,
|
|
|
|
|
order: list.New(),
|
|
|
|
|
entries: make(map[string]*list.Element),
|
|
|
|
|
inFlight: make(map[string]*inflightRewrite),
|
Cache rewritten npm and Composer metadata (#402)
The npm and Composer handlers rewrite every metadata document they serve
so that download URLs point at the proxy: decode the whole document into
generic maps, change the URLs, encode it again, and for Composer expand
the minified format first. That ran on every request, cached metadata
included. With metadata caching on and upstream out of the picture, a
cached request still cost 7 ms for @babel/core, 17 ms for
symfony/console and 155 ms and 134 MB of allocations for typescript, and
an 8 vCPU VM serving cached npm packuments to 20 clients ran the proxy
at 450% CPU for about 1,100 requests a second.
Rewritten documents are now kept in memory, keyed by the ecosystem,
proxy URL, package and a SHA-256 of the raw document, so new bytes from
upstream are rewritten again and nothing is served stale. Requests that
arrive while a document is being rewritten wait for that rewrite rather
than running their own; a waiter leaves when its client does, and the
rewrite still completes and is cached. The denylist is fixed at startup,
so it needs no place in the key. Cooldown filtering depends on the
current time, so with cooldown on the cache is bypassed.
metadata_rewrite_cache_size bounds the cache (default "256MB", least
recently used out first, "0" to rewrite on every request). NewProxy
callers keep the old behaviour unless they set it.
Whole cached requests, measured locally:
@babel/core 7.1 ms -> 0.38 ms, 54,489 -> 119 allocations
typescript 155 ms -> 10.5 ms, 1.37M -> 133 allocations
symfony/console 17.4 ms -> 0.48 ms, 165,909 -> 123 allocations
What remains is reading the raw document from storage and hashing it.
2026-10-03 04:09:15 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// rewriteCacheKey identifies one rewrite: the ecosystem and proxy URL the
|
|
|
|
|
// handler rewrites for, the package, and the exact upstream bytes.
|
|
|
|
|
func rewriteCacheKey(ecosystem, proxyURL, name string, in []byte) string {
|
|
|
|
|
sum := sha256.Sum256(in)
|
2026-10-08 07:36:33 +02:00
|
|
|
return rewriteCacheKeyForDigest(ecosystem, proxyURL, name, "sha256:"+hex.EncodeToString(sum[:]))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// rewriteCacheKeyForDigest is rewriteCacheKey for upstream bytes known only by
|
Share the dates of rewritten metadata across replicas
Dates were kept in each process's rewrite cache, so replicas sharing a
database and storage dated independently: one dated a document, upstream
changed in that second, and another gave the new document the same date,
so a client holding it got 304 and kept the old document.
- Date from metadata_dates instead of process memory: a package's date is
the second it started serving its current representation, recorded once
for every replica
- Switch a package to another representation only in a second later than
its recorded date, and serve it undated until then
- Date nothing in the second the process started, so dates clients hold
from before this change, or from the process it replaced, stay older
- Serve no date ahead of the local clock, recorded by a replica whose clock
is ahead
- Date rewrites without the rewrite cache too; the cache keeps only ETags
- Add two-replica, pre-fix date and clock skew regression tests
2026-10-12 12:52:02 +02:00
|
|
|
// their digest, in the form the metadata cache records it.
|
2026-10-08 07:36:33 +02:00
|
|
|
func rewriteCacheKeyForDigest(ecosystem, proxyURL, name, digest string) string {
|
|
|
|
|
return strings.Join([]string{ecosystem, proxyURL, name, digest}, "\x00")
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 12:46:55 +02:00
|
|
|
// get returns the cached rewrite for key, if there is one.
|
|
|
|
|
func (c *rewriteCache) get(key string) (rewrittenMetadata, bool) {
|
2026-10-08 07:36:33 +02:00
|
|
|
if c == nil {
|
2026-10-15 12:46:55 +02:00
|
|
|
return rewrittenMetadata{}, false
|
2026-10-08 07:36:33 +02:00
|
|
|
}
|
|
|
|
|
c.mu.Lock()
|
|
|
|
|
defer c.mu.Unlock()
|
Cache cooldown metadata rewrites for a minute
With cooldown on, every npm, Composer and Chef metadata request rewrote
the whole document, because a cached rewrite could keep hiding a
version after its cooldown ended. For a 24.5 MB packument that is about
45 ms and 31 MB of allocations per request, and the result was served
uncompressed and without Last-Modified, so Composer, which revalidates
p2 files with If-Modified-Since only, never got a 304.
The rewrite cache now keeps cooldown rewrites with an expiry of one
minute, so a version leaving cooldown shows up at most that late.
Concurrent requests share one rewrite, clients get the gzip copy, and
the rewrite is dated through metadataDate like any other. Its ETag is
the hash of its output, so replicas agree on it.
The cache is keyed by the digest the metadata cache already recorded,
instead of hashing the document again, which also lets storedRewrite
serve cooldown rewrites without reading the stored document.
Without the rewrite cache, cooldown rewrites behave as before.
2026-10-23 13:24:58 +02:00
|
|
|
e, ok := c.lookup(key, c.clock())
|
2026-10-08 07:36:33 +02:00
|
|
|
if !ok {
|
2026-10-15 12:46:55 +02:00
|
|
|
return rewrittenMetadata{}, false
|
2026-10-08 07:36:33 +02:00
|
|
|
}
|
Cache cooldown metadata rewrites for a minute
With cooldown on, every npm, Composer and Chef metadata request rewrote
the whole document, because a cached rewrite could keep hiding a
version after its cooldown ended. For a 24.5 MB packument that is about
45 ms and 31 MB of allocations per request, and the result was served
uncompressed and without Last-Modified, so Composer, which revalidates
p2 files with If-Modified-Since only, never got a 304.
The rewrite cache now keeps cooldown rewrites with an expiry of one
minute, so a version leaving cooldown shows up at most that late.
Concurrent requests share one rewrite, clients get the gzip copy, and
the rewrite is dated through metadataDate like any other. Its ETag is
the hash of its output, so replicas agree on it.
The cache is keyed by the digest the metadata cache already recorded,
instead of hashing the document again, which also lets storedRewrite
serve cooldown rewrites without reading the stored document.
Without the rewrite cache, cooldown rewrites behave as before.
2026-10-23 13:24:58 +02:00
|
|
|
return e.rewrittenMetadata, true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// lookup returns the live entry for key and marks it recently used. An
|
|
|
|
|
// expired entry is dropped. Called with mu held.
|
|
|
|
|
func (c *rewriteCache) lookup(key string, now time.Time) (*rewriteEntry, bool) {
|
|
|
|
|
el, ok := c.entries[key]
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
e := el.Value.(*rewriteEntry)
|
|
|
|
|
if !e.expires.IsZero() && !now.Before(e.expires) {
|
|
|
|
|
c.remove(el)
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
2026-10-08 07:36:33 +02:00
|
|
|
c.order.MoveToFront(el)
|
Cache cooldown metadata rewrites for a minute
With cooldown on, every npm, Composer and Chef metadata request rewrote
the whole document, because a cached rewrite could keep hiding a
version after its cooldown ended. For a 24.5 MB packument that is about
45 ms and 31 MB of allocations per request, and the result was served
uncompressed and without Last-Modified, so Composer, which revalidates
p2 files with If-Modified-Since only, never got a 304.
The rewrite cache now keeps cooldown rewrites with an expiry of one
minute, so a version leaving cooldown shows up at most that late.
Concurrent requests share one rewrite, clients get the gzip copy, and
the rewrite is dated through metadataDate like any other. Its ETag is
the hash of its output, so replicas agree on it.
The cache is keyed by the digest the metadata cache already recorded,
instead of hashing the document again, which also lets storedRewrite
serve cooldown rewrites without reading the stored document.
Without the rewrite cache, cooldown rewrites behave as before.
2026-10-23 13:24:58 +02:00
|
|
|
return e, true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *rewriteCache) clock() time.Time {
|
|
|
|
|
if c.now == nil {
|
|
|
|
|
return time.Now()
|
|
|
|
|
}
|
|
|
|
|
return c.now()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// remove drops an entry. Called with mu held.
|
|
|
|
|
func (c *rewriteCache) remove(el *list.Element) {
|
|
|
|
|
e := el.Value.(*rewriteEntry)
|
|
|
|
|
c.order.Remove(el)
|
|
|
|
|
delete(c.entries, e.key)
|
|
|
|
|
c.size -= e.size()
|
Cache rewritten npm and Composer metadata (#402)
The npm and Composer handlers rewrite every metadata document they serve
so that download URLs point at the proxy: decode the whole document into
generic maps, change the URLs, encode it again, and for Composer expand
the minified format first. That ran on every request, cached metadata
included. With metadata caching on and upstream out of the picture, a
cached request still cost 7 ms for @babel/core, 17 ms for
symfony/console and 155 ms and 134 MB of allocations for typescript, and
an 8 vCPU VM serving cached npm packuments to 20 clients ran the proxy
at 450% CPU for about 1,100 requests a second.
Rewritten documents are now kept in memory, keyed by the ecosystem,
proxy URL, package and a SHA-256 of the raw document, so new bytes from
upstream are rewritten again and nothing is served stale. Requests that
arrive while a document is being rewritten wait for that rewrite rather
than running their own; a waiter leaves when its client does, and the
rewrite still completes and is cached. The denylist is fixed at startup,
so it needs no place in the key. Cooldown filtering depends on the
current time, so with cooldown on the cache is bypassed.
metadata_rewrite_cache_size bounds the cache (default "256MB", least
recently used out first, "0" to rewrite on every request). NewProxy
callers keep the old behaviour unless they set it.
Whole cached requests, measured locally:
@babel/core 7.1 ms -> 0.38 ms, 54,489 -> 119 allocations
typescript 155 ms -> 10.5 ms, 1.37M -> 133 allocations
symfony/console 17.4 ms -> 0.48 ms, 165,909 -> 123 allocations
What remains is reading the raw document from storage and hashing it.
2026-10-03 04:09:15 -07:00
|
|
|
}
|
|
|
|
|
|
2026-10-15 12:46:55 +02:00
|
|
|
// rewrite returns rewrite(in) with its ETag and gzip copy, from the cache
|
Cache cooldown metadata rewrites for a minute
With cooldown on, every npm, Composer and Chef metadata request rewrote
the whole document, because a cached rewrite could keep hiding a
version after its cooldown ended. For a 24.5 MB packument that is about
45 ms and 31 MB of allocations per request, and the result was served
uncompressed and without Last-Modified, so Composer, which revalidates
p2 files with If-Modified-Since only, never got a 304.
The rewrite cache now keeps cooldown rewrites with an expiry of one
minute, so a version leaving cooldown shows up at most that late.
Concurrent requests share one rewrite, clients get the gzip copy, and
the rewrite is dated through metadataDate like any other. Its ETag is
the hash of its output, so replicas agree on it.
The cache is keyed by the digest the metadata cache already recorded,
instead of hashing the document again, which also lets storedRewrite
serve cooldown rewrites without reading the stored document.
Without the rewrite cache, cooldown rewrites behave as before.
2026-10-23 13:24:58 +02:00
|
|
|
// when it can. A ttl above zero makes the result expire after that long.
|
|
|
|
|
// Callers must treat the returned bytes as read-only: a cached result is
|
|
|
|
|
// shared. A caller waiting on another's rewrite leaves when ctx ends; the
|
|
|
|
|
// rewrite itself always runs to completion, so the result is cached for the
|
|
|
|
|
// next request.
|
2026-10-30 09:33:56 +01:00
|
|
|
func (c *rewriteCache) rewrite(ctx context.Context, key string, in []byte, expiry func(start time.Time) time.Time, rewrite func([]byte) ([]byte, error)) (rewrittenMetadata, error) {
|
Cache rewritten npm and Composer metadata (#402)
The npm and Composer handlers rewrite every metadata document they serve
so that download URLs point at the proxy: decode the whole document into
generic maps, change the URLs, encode it again, and for Composer expand
the minified format first. That ran on every request, cached metadata
included. With metadata caching on and upstream out of the picture, a
cached request still cost 7 ms for @babel/core, 17 ms for
symfony/console and 155 ms and 134 MB of allocations for typescript, and
an 8 vCPU VM serving cached npm packuments to 20 clients ran the proxy
at 450% CPU for about 1,100 requests a second.
Rewritten documents are now kept in memory, keyed by the ecosystem,
proxy URL, package and a SHA-256 of the raw document, so new bytes from
upstream are rewritten again and nothing is served stale. Requests that
arrive while a document is being rewritten wait for that rewrite rather
than running their own; a waiter leaves when its client does, and the
rewrite still completes and is cached. The denylist is fixed at startup,
so it needs no place in the key. Cooldown filtering depends on the
current time, so with cooldown on the cache is bypassed.
metadata_rewrite_cache_size bounds the cache (default "256MB", least
recently used out first, "0" to rewrite on every request). NewProxy
callers keep the old behaviour unless they set it.
Whole cached requests, measured locally:
@babel/core 7.1 ms -> 0.38 ms, 54,489 -> 119 allocations
typescript 155 ms -> 10.5 ms, 1.37M -> 133 allocations
symfony/console 17.4 ms -> 0.48 ms, 165,909 -> 123 allocations
What remains is reading the raw document from storage and hashing it.
2026-10-03 04:09:15 -07:00
|
|
|
if c == nil {
|
2026-10-08 13:26:55 +02:00
|
|
|
return rewriteWithETag(in, rewrite)
|
Cache rewritten npm and Composer metadata (#402)
The npm and Composer handlers rewrite every metadata document they serve
so that download URLs point at the proxy: decode the whole document into
generic maps, change the URLs, encode it again, and for Composer expand
the minified format first. That ran on every request, cached metadata
included. With metadata caching on and upstream out of the picture, a
cached request still cost 7 ms for @babel/core, 17 ms for
symfony/console and 155 ms and 134 MB of allocations for typescript, and
an 8 vCPU VM serving cached npm packuments to 20 clients ran the proxy
at 450% CPU for about 1,100 requests a second.
Rewritten documents are now kept in memory, keyed by the ecosystem,
proxy URL, package and a SHA-256 of the raw document, so new bytes from
upstream are rewritten again and nothing is served stale. Requests that
arrive while a document is being rewritten wait for that rewrite rather
than running their own; a waiter leaves when its client does, and the
rewrite still completes and is cached. The denylist is fixed at startup,
so it needs no place in the key. Cooldown filtering depends on the
current time, so with cooldown on the cache is bypassed.
metadata_rewrite_cache_size bounds the cache (default "256MB", least
recently used out first, "0" to rewrite on every request). NewProxy
callers keep the old behaviour unless they set it.
Whole cached requests, measured locally:
@babel/core 7.1 ms -> 0.38 ms, 54,489 -> 119 allocations
typescript 155 ms -> 10.5 ms, 1.37M -> 133 allocations
symfony/console 17.4 ms -> 0.48 ms, 165,909 -> 123 allocations
What remains is reading the raw document from storage and hashing it.
2026-10-03 04:09:15 -07:00
|
|
|
}
|
2026-10-30 14:14:10 +01:00
|
|
|
return c.produce(ctx, key, expiry, func() (rewrittenMetadata, error) {
|
|
|
|
|
m, err := rewriteWithETag(in, rewrite)
|
|
|
|
|
if err == nil {
|
|
|
|
|
m.gzipped = gzipIfSmaller(m.plain)
|
|
|
|
|
}
|
|
|
|
|
return m, err
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// produce is rewrite for a document that build makes with its validators
|
|
|
|
|
// and gzip copy.
|
|
|
|
|
func (c *rewriteCache) produce(ctx context.Context, key string, expiry func(start time.Time) time.Time, build func() (rewrittenMetadata, error)) (rewrittenMetadata, error) {
|
Cache rewritten npm and Composer metadata (#402)
The npm and Composer handlers rewrite every metadata document they serve
so that download URLs point at the proxy: decode the whole document into
generic maps, change the URLs, encode it again, and for Composer expand
the minified format first. That ran on every request, cached metadata
included. With metadata caching on and upstream out of the picture, a
cached request still cost 7 ms for @babel/core, 17 ms for
symfony/console and 155 ms and 134 MB of allocations for typescript, and
an 8 vCPU VM serving cached npm packuments to 20 clients ran the proxy
at 450% CPU for about 1,100 requests a second.
Rewritten documents are now kept in memory, keyed by the ecosystem,
proxy URL, package and a SHA-256 of the raw document, so new bytes from
upstream are rewritten again and nothing is served stale. Requests that
arrive while a document is being rewritten wait for that rewrite rather
than running their own; a waiter leaves when its client does, and the
rewrite still completes and is cached. The denylist is fixed at startup,
so it needs no place in the key. Cooldown filtering depends on the
current time, so with cooldown on the cache is bypassed.
metadata_rewrite_cache_size bounds the cache (default "256MB", least
recently used out first, "0" to rewrite on every request). NewProxy
callers keep the old behaviour unless they set it.
Whole cached requests, measured locally:
@babel/core 7.1 ms -> 0.38 ms, 54,489 -> 119 allocations
typescript 155 ms -> 10.5 ms, 1.37M -> 133 allocations
symfony/console 17.4 ms -> 0.48 ms, 165,909 -> 123 allocations
What remains is reading the raw document from storage and hashing it.
2026-10-03 04:09:15 -07:00
|
|
|
|
|
|
|
|
c.mu.Lock()
|
Cache cooldown metadata rewrites for a minute
With cooldown on, every npm, Composer and Chef metadata request rewrote
the whole document, because a cached rewrite could keep hiding a
version after its cooldown ended. For a 24.5 MB packument that is about
45 ms and 31 MB of allocations per request, and the result was served
uncompressed and without Last-Modified, so Composer, which revalidates
p2 files with If-Modified-Since only, never got a 304.
The rewrite cache now keeps cooldown rewrites with an expiry of one
minute, so a version leaving cooldown shows up at most that late.
Concurrent requests share one rewrite, clients get the gzip copy, and
the rewrite is dated through metadataDate like any other. Its ETag is
the hash of its output, so replicas agree on it.
The cache is keyed by the digest the metadata cache already recorded,
instead of hashing the document again, which also lets storedRewrite
serve cooldown rewrites without reading the stored document.
Without the rewrite cache, cooldown rewrites behave as before.
2026-10-23 13:24:58 +02:00
|
|
|
if e, ok := c.lookup(key, c.clock()); ok {
|
Cache rewritten npm and Composer metadata (#402)
The npm and Composer handlers rewrite every metadata document they serve
so that download URLs point at the proxy: decode the whole document into
generic maps, change the URLs, encode it again, and for Composer expand
the minified format first. That ran on every request, cached metadata
included. With metadata caching on and upstream out of the picture, a
cached request still cost 7 ms for @babel/core, 17 ms for
symfony/console and 155 ms and 134 MB of allocations for typescript, and
an 8 vCPU VM serving cached npm packuments to 20 clients ran the proxy
at 450% CPU for about 1,100 requests a second.
Rewritten documents are now kept in memory, keyed by the ecosystem,
proxy URL, package and a SHA-256 of the raw document, so new bytes from
upstream are rewritten again and nothing is served stale. Requests that
arrive while a document is being rewritten wait for that rewrite rather
than running their own; a waiter leaves when its client does, and the
rewrite still completes and is cached. The denylist is fixed at startup,
so it needs no place in the key. Cooldown filtering depends on the
current time, so with cooldown on the cache is bypassed.
metadata_rewrite_cache_size bounds the cache (default "256MB", least
recently used out first, "0" to rewrite on every request). NewProxy
callers keep the old behaviour unless they set it.
Whole cached requests, measured locally:
@babel/core 7.1 ms -> 0.38 ms, 54,489 -> 119 allocations
typescript 155 ms -> 10.5 ms, 1.37M -> 133 allocations
symfony/console 17.4 ms -> 0.48 ms, 165,909 -> 123 allocations
What remains is reading the raw document from storage and hashing it.
2026-10-03 04:09:15 -07:00
|
|
|
c.mu.Unlock()
|
Cache cooldown metadata rewrites for a minute
With cooldown on, every npm, Composer and Chef metadata request rewrote
the whole document, because a cached rewrite could keep hiding a
version after its cooldown ended. For a 24.5 MB packument that is about
45 ms and 31 MB of allocations per request, and the result was served
uncompressed and without Last-Modified, so Composer, which revalidates
p2 files with If-Modified-Since only, never got a 304.
The rewrite cache now keeps cooldown rewrites with an expiry of one
minute, so a version leaving cooldown shows up at most that late.
Concurrent requests share one rewrite, clients get the gzip copy, and
the rewrite is dated through metadataDate like any other. Its ETag is
the hash of its output, so replicas agree on it.
The cache is keyed by the digest the metadata cache already recorded,
instead of hashing the document again, which also lets storedRewrite
serve cooldown rewrites without reading the stored document.
Without the rewrite cache, cooldown rewrites behave as before.
2026-10-23 13:24:58 +02:00
|
|
|
return e.rewrittenMetadata, nil
|
Cache rewritten npm and Composer metadata (#402)
The npm and Composer handlers rewrite every metadata document they serve
so that download URLs point at the proxy: decode the whole document into
generic maps, change the URLs, encode it again, and for Composer expand
the minified format first. That ran on every request, cached metadata
included. With metadata caching on and upstream out of the picture, a
cached request still cost 7 ms for @babel/core, 17 ms for
symfony/console and 155 ms and 134 MB of allocations for typescript, and
an 8 vCPU VM serving cached npm packuments to 20 clients ran the proxy
at 450% CPU for about 1,100 requests a second.
Rewritten documents are now kept in memory, keyed by the ecosystem,
proxy URL, package and a SHA-256 of the raw document, so new bytes from
upstream are rewritten again and nothing is served stale. Requests that
arrive while a document is being rewritten wait for that rewrite rather
than running their own; a waiter leaves when its client does, and the
rewrite still completes and is cached. The denylist is fixed at startup,
so it needs no place in the key. Cooldown filtering depends on the
current time, so with cooldown on the cache is bypassed.
metadata_rewrite_cache_size bounds the cache (default "256MB", least
recently used out first, "0" to rewrite on every request). NewProxy
callers keep the old behaviour unless they set it.
Whole cached requests, measured locally:
@babel/core 7.1 ms -> 0.38 ms, 54,489 -> 119 allocations
typescript 155 ms -> 10.5 ms, 1.37M -> 133 allocations
symfony/console 17.4 ms -> 0.48 ms, 165,909 -> 123 allocations
What remains is reading the raw document from storage and hashing it.
2026-10-03 04:09:15 -07:00
|
|
|
}
|
|
|
|
|
if f, ok := c.inFlight[key]; ok {
|
|
|
|
|
c.mu.Unlock()
|
|
|
|
|
select {
|
|
|
|
|
case <-ctx.Done():
|
2026-10-15 12:46:55 +02:00
|
|
|
return rewrittenMetadata{}, ctx.Err()
|
Cache rewritten npm and Composer metadata (#402)
The npm and Composer handlers rewrite every metadata document they serve
so that download URLs point at the proxy: decode the whole document into
generic maps, change the URLs, encode it again, and for Composer expand
the minified format first. That ran on every request, cached metadata
included. With metadata caching on and upstream out of the picture, a
cached request still cost 7 ms for @babel/core, 17 ms for
symfony/console and 155 ms and 134 MB of allocations for typescript, and
an 8 vCPU VM serving cached npm packuments to 20 clients ran the proxy
at 450% CPU for about 1,100 requests a second.
Rewritten documents are now kept in memory, keyed by the ecosystem,
proxy URL, package and a SHA-256 of the raw document, so new bytes from
upstream are rewritten again and nothing is served stale. Requests that
arrive while a document is being rewritten wait for that rewrite rather
than running their own; a waiter leaves when its client does, and the
rewrite still completes and is cached. The denylist is fixed at startup,
so it needs no place in the key. Cooldown filtering depends on the
current time, so with cooldown on the cache is bypassed.
metadata_rewrite_cache_size bounds the cache (default "256MB", least
recently used out first, "0" to rewrite on every request). NewProxy
callers keep the old behaviour unless they set it.
Whole cached requests, measured locally:
@babel/core 7.1 ms -> 0.38 ms, 54,489 -> 119 allocations
typescript 155 ms -> 10.5 ms, 1.37M -> 133 allocations
symfony/console 17.4 ms -> 0.48 ms, 165,909 -> 123 allocations
What remains is reading the raw document from storage and hashing it.
2026-10-03 04:09:15 -07:00
|
|
|
case <-f.done:
|
2026-10-15 12:46:55 +02:00
|
|
|
return f.m, f.err
|
Cache rewritten npm and Composer metadata (#402)
The npm and Composer handlers rewrite every metadata document they serve
so that download URLs point at the proxy: decode the whole document into
generic maps, change the URLs, encode it again, and for Composer expand
the minified format first. That ran on every request, cached metadata
included. With metadata caching on and upstream out of the picture, a
cached request still cost 7 ms for @babel/core, 17 ms for
symfony/console and 155 ms and 134 MB of allocations for typescript, and
an 8 vCPU VM serving cached npm packuments to 20 clients ran the proxy
at 450% CPU for about 1,100 requests a second.
Rewritten documents are now kept in memory, keyed by the ecosystem,
proxy URL, package and a SHA-256 of the raw document, so new bytes from
upstream are rewritten again and nothing is served stale. Requests that
arrive while a document is being rewritten wait for that rewrite rather
than running their own; a waiter leaves when its client does, and the
rewrite still completes and is cached. The denylist is fixed at startup,
so it needs no place in the key. Cooldown filtering depends on the
current time, so with cooldown on the cache is bypassed.
metadata_rewrite_cache_size bounds the cache (default "256MB", least
recently used out first, "0" to rewrite on every request). NewProxy
callers keep the old behaviour unless they set it.
Whole cached requests, measured locally:
@babel/core 7.1 ms -> 0.38 ms, 54,489 -> 119 allocations
typescript 155 ms -> 10.5 ms, 1.37M -> 133 allocations
symfony/console 17.4 ms -> 0.48 ms, 165,909 -> 123 allocations
What remains is reading the raw document from storage and hashing it.
2026-10-03 04:09:15 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
f := &inflightRewrite{done: make(chan struct{}), err: errSharedRewriteAbandoned}
|
|
|
|
|
c.inFlight[key] = f
|
|
|
|
|
c.mu.Unlock()
|
|
|
|
|
|
2026-10-30 09:33:56 +01:00
|
|
|
// expiry is given the time from before the rewrite, so an entry never
|
|
|
|
|
// outlives the time its cooldown decisions were made at.
|
|
|
|
|
start := c.clock()
|
Cache rewritten npm and Composer metadata (#402)
The npm and Composer handlers rewrite every metadata document they serve
so that download URLs point at the proxy: decode the whole document into
generic maps, change the URLs, encode it again, and for Composer expand
the minified format first. That ran on every request, cached metadata
included. With metadata caching on and upstream out of the picture, a
cached request still cost 7 ms for @babel/core, 17 ms for
symfony/console and 155 ms and 134 MB of allocations for typescript, and
an 8 vCPU VM serving cached npm packuments to 20 clients ran the proxy
at 450% CPU for about 1,100 requests a second.
Rewritten documents are now kept in memory, keyed by the ecosystem,
proxy URL, package and a SHA-256 of the raw document, so new bytes from
upstream are rewritten again and nothing is served stale. Requests that
arrive while a document is being rewritten wait for that rewrite rather
than running their own; a waiter leaves when its client does, and the
rewrite still completes and is cached. The denylist is fixed at startup,
so it needs no place in the key. Cooldown filtering depends on the
current time, so with cooldown on the cache is bypassed.
metadata_rewrite_cache_size bounds the cache (default "256MB", least
recently used out first, "0" to rewrite on every request). NewProxy
callers keep the old behaviour unless they set it.
Whole cached requests, measured locally:
@babel/core 7.1 ms -> 0.38 ms, 54,489 -> 119 allocations
typescript 155 ms -> 10.5 ms, 1.37M -> 133 allocations
symfony/console 17.4 ms -> 0.48 ms, 165,909 -> 123 allocations
What remains is reading the raw document from storage and hashing it.
2026-10-03 04:09:15 -07:00
|
|
|
defer func() {
|
|
|
|
|
c.mu.Lock()
|
|
|
|
|
delete(c.inFlight, key)
|
|
|
|
|
if f.err == nil {
|
2026-10-30 09:33:56 +01:00
|
|
|
var expires time.Time
|
|
|
|
|
if expiry != nil {
|
|
|
|
|
expires = expiry(start)
|
|
|
|
|
}
|
2026-10-29 10:52:39 +01:00
|
|
|
c.add(&rewriteEntry{key: key, rewrittenMetadata: f.m.compact(), expires: expires})
|
Cache rewritten npm and Composer metadata (#402)
The npm and Composer handlers rewrite every metadata document they serve
so that download URLs point at the proxy: decode the whole document into
generic maps, change the URLs, encode it again, and for Composer expand
the minified format first. That ran on every request, cached metadata
included. With metadata caching on and upstream out of the picture, a
cached request still cost 7 ms for @babel/core, 17 ms for
symfony/console and 155 ms and 134 MB of allocations for typescript, and
an 8 vCPU VM serving cached npm packuments to 20 clients ran the proxy
at 450% CPU for about 1,100 requests a second.
Rewritten documents are now kept in memory, keyed by the ecosystem,
proxy URL, package and a SHA-256 of the raw document, so new bytes from
upstream are rewritten again and nothing is served stale. Requests that
arrive while a document is being rewritten wait for that rewrite rather
than running their own; a waiter leaves when its client does, and the
rewrite still completes and is cached. The denylist is fixed at startup,
so it needs no place in the key. Cooldown filtering depends on the
current time, so with cooldown on the cache is bypassed.
metadata_rewrite_cache_size bounds the cache (default "256MB", least
recently used out first, "0" to rewrite on every request). NewProxy
callers keep the old behaviour unless they set it.
Whole cached requests, measured locally:
@babel/core 7.1 ms -> 0.38 ms, 54,489 -> 119 allocations
typescript 155 ms -> 10.5 ms, 1.37M -> 133 allocations
symfony/console 17.4 ms -> 0.48 ms, 165,909 -> 123 allocations
What remains is reading the raw document from storage and hashing it.
2026-10-03 04:09:15 -07:00
|
|
|
}
|
|
|
|
|
c.mu.Unlock()
|
|
|
|
|
close(f.done)
|
|
|
|
|
}()
|
2026-10-30 14:14:10 +01:00
|
|
|
f.m, f.err = build()
|
2026-10-15 12:46:55 +02:00
|
|
|
return f.m, f.err
|
Cache rewritten npm and Composer metadata (#402)
The npm and Composer handlers rewrite every metadata document they serve
so that download URLs point at the proxy: decode the whole document into
generic maps, change the URLs, encode it again, and for Composer expand
the minified format first. That ran on every request, cached metadata
included. With metadata caching on and upstream out of the picture, a
cached request still cost 7 ms for @babel/core, 17 ms for
symfony/console and 155 ms and 134 MB of allocations for typescript, and
an 8 vCPU VM serving cached npm packuments to 20 clients ran the proxy
at 450% CPU for about 1,100 requests a second.
Rewritten documents are now kept in memory, keyed by the ecosystem,
proxy URL, package and a SHA-256 of the raw document, so new bytes from
upstream are rewritten again and nothing is served stale. Requests that
arrive while a document is being rewritten wait for that rewrite rather
than running their own; a waiter leaves when its client does, and the
rewrite still completes and is cached. The denylist is fixed at startup,
so it needs no place in the key. Cooldown filtering depends on the
current time, so with cooldown on the cache is bypassed.
metadata_rewrite_cache_size bounds the cache (default "256MB", least
recently used out first, "0" to rewrite on every request). NewProxy
callers keep the old behaviour unless they set it.
Whole cached requests, measured locally:
@babel/core 7.1 ms -> 0.38 ms, 54,489 -> 119 allocations
typescript 155 ms -> 10.5 ms, 1.37M -> 133 allocations
symfony/console 17.4 ms -> 0.48 ms, 165,909 -> 123 allocations
What remains is reading the raw document from storage and hashing it.
2026-10-03 04:09:15 -07:00
|
|
|
}
|
|
|
|
|
|
Share the dates of rewritten metadata across replicas
Dates were kept in each process's rewrite cache, so replicas sharing a
database and storage dated independently: one dated a document, upstream
changed in that second, and another gave the new document the same date,
so a client holding it got 304 and kept the old document.
- Date from metadata_dates instead of process memory: a package's date is
the second it started serving its current representation, recorded once
for every replica
- Switch a package to another representation only in a second later than
its recorded date, and serve it undated until then
- Date nothing in the second the process started, so dates clients hold
from before this change, or from the process it replaced, stay older
- Serve no date ahead of the local clock, recorded by a replica whose clock
is ahead
- Date rewrites without the rewrite cache too; the cache keeps only ETags
- Add two-replica, pre-fix date and clock skew regression tests
2026-10-12 12:52:02 +02:00
|
|
|
// rewriteWithETag runs rewrite and computes the ETag of what it returns.
|
2026-10-15 12:46:55 +02:00
|
|
|
func rewriteWithETag(in []byte, rewrite func([]byte) ([]byte, error)) (rewrittenMetadata, error) {
|
2026-10-08 13:26:55 +02:00
|
|
|
out, err := rewrite(in)
|
|
|
|
|
if err != nil {
|
2026-10-15 12:46:55 +02:00
|
|
|
return rewrittenMetadata{}, err
|
2026-10-08 13:26:55 +02:00
|
|
|
}
|
2026-10-29 10:52:39 +01:00
|
|
|
return rewrittenMetadata{plain: out, metadataValidators: metadataValidators{etag: metadataETag(out)}, size: len(out)}, nil
|
2026-10-15 12:46:55 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// gzipIfSmaller returns body gzip-compressed, or nil when compressing does
|
|
|
|
|
// not make it smaller.
|
|
|
|
|
func gzipIfSmaller(body []byte) []byte {
|
|
|
|
|
var buf bytes.Buffer
|
|
|
|
|
zw := gzip.NewWriter(&buf)
|
|
|
|
|
if _, err := zw.Write(body); err != nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
if err := zw.Close(); err != nil || buf.Len() >= len(body) {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return buf.Bytes()
|
2026-10-08 13:26:55 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// add stores e and evicts least recently used entries until the cache is
|
2026-10-15 12:46:55 +02:00
|
|
|
// back under its limit. An entry larger than the whole cache is not stored.
|
2026-10-08 13:26:55 +02:00
|
|
|
// Called with mu held.
|
|
|
|
|
func (c *rewriteCache) add(e *rewriteEntry) {
|
2026-10-15 12:46:55 +02:00
|
|
|
n := e.size()
|
Cache rewritten npm and Composer metadata (#402)
The npm and Composer handlers rewrite every metadata document they serve
so that download URLs point at the proxy: decode the whole document into
generic maps, change the URLs, encode it again, and for Composer expand
the minified format first. That ran on every request, cached metadata
included. With metadata caching on and upstream out of the picture, a
cached request still cost 7 ms for @babel/core, 17 ms for
symfony/console and 155 ms and 134 MB of allocations for typescript, and
an 8 vCPU VM serving cached npm packuments to 20 clients ran the proxy
at 450% CPU for about 1,100 requests a second.
Rewritten documents are now kept in memory, keyed by the ecosystem,
proxy URL, package and a SHA-256 of the raw document, so new bytes from
upstream are rewritten again and nothing is served stale. Requests that
arrive while a document is being rewritten wait for that rewrite rather
than running their own; a waiter leaves when its client does, and the
rewrite still completes and is cached. The denylist is fixed at startup,
so it needs no place in the key. Cooldown filtering depends on the
current time, so with cooldown on the cache is bypassed.
metadata_rewrite_cache_size bounds the cache (default "256MB", least
recently used out first, "0" to rewrite on every request). NewProxy
callers keep the old behaviour unless they set it.
Whole cached requests, measured locally:
@babel/core 7.1 ms -> 0.38 ms, 54,489 -> 119 allocations
typescript 155 ms -> 10.5 ms, 1.37M -> 133 allocations
symfony/console 17.4 ms -> 0.48 ms, 165,909 -> 123 allocations
What remains is reading the raw document from storage and hashing it.
2026-10-03 04:09:15 -07:00
|
|
|
if n > c.maxBytes {
|
|
|
|
|
return
|
|
|
|
|
}
|
Cache cooldown metadata rewrites for a minute
With cooldown on, every npm, Composer and Chef metadata request rewrote
the whole document, because a cached rewrite could keep hiding a
version after its cooldown ended. For a 24.5 MB packument that is about
45 ms and 31 MB of allocations per request, and the result was served
uncompressed and without Last-Modified, so Composer, which revalidates
p2 files with If-Modified-Since only, never got a 304.
The rewrite cache now keeps cooldown rewrites with an expiry of one
minute, so a version leaving cooldown shows up at most that late.
Concurrent requests share one rewrite, clients get the gzip copy, and
the rewrite is dated through metadataDate like any other. Its ETag is
the hash of its output, so replicas agree on it.
The cache is keyed by the digest the metadata cache already recorded,
instead of hashing the document again, which also lets storedRewrite
serve cooldown rewrites without reading the stored document.
Without the rewrite cache, cooldown rewrites behave as before.
2026-10-23 13:24:58 +02:00
|
|
|
if old, ok := c.entries[e.key]; ok {
|
|
|
|
|
c.remove(old)
|
|
|
|
|
}
|
2026-10-08 13:26:55 +02:00
|
|
|
c.entries[e.key] = c.order.PushFront(e)
|
Cache rewritten npm and Composer metadata (#402)
The npm and Composer handlers rewrite every metadata document they serve
so that download URLs point at the proxy: decode the whole document into
generic maps, change the URLs, encode it again, and for Composer expand
the minified format first. That ran on every request, cached metadata
included. With metadata caching on and upstream out of the picture, a
cached request still cost 7 ms for @babel/core, 17 ms for
symfony/console and 155 ms and 134 MB of allocations for typescript, and
an 8 vCPU VM serving cached npm packuments to 20 clients ran the proxy
at 450% CPU for about 1,100 requests a second.
Rewritten documents are now kept in memory, keyed by the ecosystem,
proxy URL, package and a SHA-256 of the raw document, so new bytes from
upstream are rewritten again and nothing is served stale. Requests that
arrive while a document is being rewritten wait for that rewrite rather
than running their own; a waiter leaves when its client does, and the
rewrite still completes and is cached. The denylist is fixed at startup,
so it needs no place in the key. Cooldown filtering depends on the
current time, so with cooldown on the cache is bypassed.
metadata_rewrite_cache_size bounds the cache (default "256MB", least
recently used out first, "0" to rewrite on every request). NewProxy
callers keep the old behaviour unless they set it.
Whole cached requests, measured locally:
@babel/core 7.1 ms -> 0.38 ms, 54,489 -> 119 allocations
typescript 155 ms -> 10.5 ms, 1.37M -> 133 allocations
symfony/console 17.4 ms -> 0.48 ms, 165,909 -> 123 allocations
What remains is reading the raw document from storage and hashing it.
2026-10-03 04:09:15 -07:00
|
|
|
c.size += n
|
|
|
|
|
for c.size > c.maxBytes {
|
Cache cooldown metadata rewrites for a minute
With cooldown on, every npm, Composer and Chef metadata request rewrote
the whole document, because a cached rewrite could keep hiding a
version after its cooldown ended. For a 24.5 MB packument that is about
45 ms and 31 MB of allocations per request, and the result was served
uncompressed and without Last-Modified, so Composer, which revalidates
p2 files with If-Modified-Since only, never got a 304.
The rewrite cache now keeps cooldown rewrites with an expiry of one
minute, so a version leaving cooldown shows up at most that late.
Concurrent requests share one rewrite, clients get the gzip copy, and
the rewrite is dated through metadataDate like any other. Its ETag is
the hash of its output, so replicas agree on it.
The cache is keyed by the digest the metadata cache already recorded,
instead of hashing the document again, which also lets storedRewrite
serve cooldown rewrites without reading the stored document.
Without the rewrite cache, cooldown rewrites behave as before.
2026-10-23 13:24:58 +02:00
|
|
|
c.remove(c.order.Back())
|
Cache rewritten npm and Composer metadata (#402)
The npm and Composer handlers rewrite every metadata document they serve
so that download URLs point at the proxy: decode the whole document into
generic maps, change the URLs, encode it again, and for Composer expand
the minified format first. That ran on every request, cached metadata
included. With metadata caching on and upstream out of the picture, a
cached request still cost 7 ms for @babel/core, 17 ms for
symfony/console and 155 ms and 134 MB of allocations for typescript, and
an 8 vCPU VM serving cached npm packuments to 20 clients ran the proxy
at 450% CPU for about 1,100 requests a second.
Rewritten documents are now kept in memory, keyed by the ecosystem,
proxy URL, package and a SHA-256 of the raw document, so new bytes from
upstream are rewritten again and nothing is served stale. Requests that
arrive while a document is being rewritten wait for that rewrite rather
than running their own; a waiter leaves when its client does, and the
rewrite still completes and is cached. The denylist is fixed at startup,
so it needs no place in the key. Cooldown filtering depends on the
current time, so with cooldown on the cache is bypassed.
metadata_rewrite_cache_size bounds the cache (default "256MB", least
recently used out first, "0" to rewrite on every request). NewProxy
callers keep the old behaviour unless they set it.
Whole cached requests, measured locally:
@babel/core 7.1 ms -> 0.38 ms, 54,489 -> 119 allocations
typescript 155 ms -> 10.5 ms, 1.37M -> 133 allocations
symfony/console 17.4 ms -> 0.48 ms, 165,909 -> 123 allocations
What remains is reading the raw document from storage and hashing it.
2026-10-03 04:09:15 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// cachedRewrite rewrites a metadata document through the proxy's rewrite
|
2026-10-10 13:47:39 +02:00
|
|
|
// cache and returns it with its validators. digest identifies in when the
|
Cache cooldown metadata rewrites for a minute
With cooldown on, every npm, Composer and Chef metadata request rewrote
the whole document, because a cached rewrite could keep hiding a
version after its cooldown ended. For a 24.5 MB packument that is about
45 ms and 31 MB of allocations per request, and the result was served
uncompressed and without Last-Modified, so Composer, which revalidates
p2 files with If-Modified-Since only, never got a 304.
The rewrite cache now keeps cooldown rewrites with an expiry of one
minute, so a version leaving cooldown shows up at most that late.
Concurrent requests share one rewrite, clients get the gzip copy, and
the rewrite is dated through metadataDate like any other. Its ETag is
the hash of its output, so replicas agree on it.
The cache is keyed by the digest the metadata cache already recorded,
instead of hashing the document again, which also lets storedRewrite
serve cooldown rewrites without reading the stored document.
Without the rewrite cache, cooldown rewrites behave as before.
2026-10-23 13:24:58 +02:00
|
|
|
// metadata cache stored it, and is empty otherwise; when known it keys the
|
|
|
|
|
// cache, so the document isn't hashed again.
|
|
|
|
|
//
|
2026-10-30 09:33:56 +01:00
|
|
|
// With cooldown on, the cached rewrite expires when the first version it
|
|
|
|
|
// held back leaves cooldown, and after p.cooldownRewriteTTL at the latest. Its
|
Cache cooldown metadata rewrites for a minute
With cooldown on, every npm, Composer and Chef metadata request rewrote
the whole document, because a cached rewrite could keep hiding a
version after its cooldown ended. For a 24.5 MB packument that is about
45 ms and 31 MB of allocations per request, and the result was served
uncompressed and without Last-Modified, so Composer, which revalidates
p2 files with If-Modified-Since only, never got a 304.
The rewrite cache now keeps cooldown rewrites with an expiry of one
minute, so a version leaving cooldown shows up at most that late.
Concurrent requests share one rewrite, clients get the gzip copy, and
the rewrite is dated through metadataDate like any other. Its ETag is
the hash of its output, so replicas agree on it.
The cache is keyed by the digest the metadata cache already recorded,
instead of hashing the document again, which also lets storedRewrite
serve cooldown rewrites without reading the stored document.
Without the rewrite cache, cooldown rewrites behave as before.
2026-10-23 13:24:58 +02:00
|
|
|
// ETag is the hash of its output, and it is dated like any other, so clients
|
|
|
|
|
// revalidate with either validator and get the gzip copy.
|
|
|
|
|
//
|
|
|
|
|
// Without the rewrite cache, cooldown rewrites run on every request and go
|
|
|
|
|
// undated: when digest is known their ETag comes from their inputs rather
|
|
|
|
|
// than from hashing each output again, and that ETag changes with every
|
|
|
|
|
// restart, so replicas would never agree on a date for it.
|
2026-10-10 13:47:39 +02:00
|
|
|
func (p *Proxy) cachedRewrite(ctx context.Context, ecosystem, proxyURL, name string, in []byte, digest string, rewrite keepingRewrite) (rewrittenMetadata, error) {
|
Cache cooldown metadata rewrites for a minute
With cooldown on, every npm, Composer and Chef metadata request rewrote
the whole document, because a cached rewrite could keep hiding a
version after its cooldown ended. For a 24.5 MB packument that is about
45 ms and 31 MB of allocations per request, and the result was served
uncompressed and without Last-Modified, so Composer, which revalidates
p2 files with If-Modified-Since only, never got a 304.
The rewrite cache now keeps cooldown rewrites with an expiry of one
minute, so a version leaving cooldown shows up at most that late.
Concurrent requests share one rewrite, clients get the gzip copy, and
the rewrite is dated through metadataDate like any other. Its ETag is
the hash of its output, so replicas agree on it.
The cache is keyed by the digest the metadata cache already recorded,
instead of hashing the document again, which also lets storedRewrite
serve cooldown rewrites without reading the stored document.
Without the rewrite cache, cooldown rewrites behave as before.
2026-10-23 13:24:58 +02:00
|
|
|
rewriteOnly := func(b []byte) ([]byte, error) {
|
|
|
|
|
out, _, err := rewrite(b)
|
|
|
|
|
return out, err
|
|
|
|
|
}
|
|
|
|
|
cooldown := p.cooldownEnabled()
|
|
|
|
|
|
|
|
|
|
if p.rewrites != nil {
|
|
|
|
|
key := rewriteCacheKeyForDigest(ecosystem, proxyURL, name, digest)
|
|
|
|
|
if digest == "" {
|
|
|
|
|
key = rewriteCacheKey(ecosystem, proxyURL, name, in)
|
|
|
|
|
}
|
2026-10-30 09:33:56 +01:00
|
|
|
var expiry func(time.Time) time.Time
|
Cache cooldown metadata rewrites for a minute
With cooldown on, every npm, Composer and Chef metadata request rewrote
the whole document, because a cached rewrite could keep hiding a
version after its cooldown ended. For a 24.5 MB packument that is about
45 ms and 31 MB of allocations per request, and the result was served
uncompressed and without Last-Modified, so Composer, which revalidates
p2 files with If-Modified-Since only, never got a 304.
The rewrite cache now keeps cooldown rewrites with an expiry of one
minute, so a version leaving cooldown shows up at most that late.
Concurrent requests share one rewrite, clients get the gzip copy, and
the rewrite is dated through metadataDate like any other. Its ETag is
the hash of its output, so replicas agree on it.
The cache is keyed by the digest the metadata cache already recorded,
instead of hashing the document again, which also lets storedRewrite
serve cooldown rewrites without reading the stored document.
Without the rewrite cache, cooldown rewrites behave as before.
2026-10-23 13:24:58 +02:00
|
|
|
if cooldown {
|
2026-10-30 09:33:56 +01:00
|
|
|
// Recording runs from before the rewrite until after it, so it
|
|
|
|
|
// hears of every version the rewrite holds back.
|
|
|
|
|
rec := p.watchCooldown()
|
|
|
|
|
defer p.unwatchCooldown(rec)
|
|
|
|
|
expiry = func(start time.Time) time.Time { return p.cooldownExpiry(rec, start) }
|
Cache cooldown metadata rewrites for a minute
With cooldown on, every npm, Composer and Chef metadata request rewrote
the whole document, because a cached rewrite could keep hiding a
version after its cooldown ended. For a 24.5 MB packument that is about
45 ms and 31 MB of allocations per request, and the result was served
uncompressed and without Last-Modified, so Composer, which revalidates
p2 files with If-Modified-Since only, never got a 304.
The rewrite cache now keeps cooldown rewrites with an expiry of one
minute, so a version leaving cooldown shows up at most that late.
Concurrent requests share one rewrite, clients get the gzip copy, and
the rewrite is dated through metadataDate like any other. Its ETag is
the hash of its output, so replicas agree on it.
The cache is keyed by the digest the metadata cache already recorded,
instead of hashing the document again, which also lets storedRewrite
serve cooldown rewrites without reading the stored document.
Without the rewrite cache, cooldown rewrites behave as before.
2026-10-23 13:24:58 +02:00
|
|
|
}
|
2026-10-30 09:33:56 +01:00
|
|
|
m, err := p.rewrites.rewrite(ctx, key, in, expiry, rewriteOnly)
|
Cache cooldown metadata rewrites for a minute
With cooldown on, every npm, Composer and Chef metadata request rewrote
the whole document, because a cached rewrite could keep hiding a
version after its cooldown ended. For a 24.5 MB packument that is about
45 ms and 31 MB of allocations per request, and the result was served
uncompressed and without Last-Modified, so Composer, which revalidates
p2 files with If-Modified-Since only, never got a 304.
The rewrite cache now keeps cooldown rewrites with an expiry of one
minute, so a version leaving cooldown shows up at most that late.
Concurrent requests share one rewrite, clients get the gzip copy, and
the rewrite is dated through metadataDate like any other. Its ETag is
the hash of its output, so replicas agree on it.
The cache is keyed by the digest the metadata cache already recorded,
instead of hashing the document again, which also lets storedRewrite
serve cooldown rewrites without reading the stored document.
Without the rewrite cache, cooldown rewrites behave as before.
2026-10-23 13:24:58 +02:00
|
|
|
if err == nil {
|
|
|
|
|
m.lastModified = p.metadataDate(ecosystem, proxyURL, name, m.etag)
|
|
|
|
|
}
|
|
|
|
|
return m, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if cooldown && digest != "" {
|
2026-10-09 08:10:41 +02:00
|
|
|
out, kept, err := rewrite(in)
|
|
|
|
|
if err != nil {
|
2026-10-10 13:47:39 +02:00
|
|
|
return rewrittenMetadata{}, err
|
2026-10-09 08:10:41 +02:00
|
|
|
}
|
2026-10-29 10:52:39 +01:00
|
|
|
return rewrittenMetadata{plain: out, metadataValidators: metadataValidators{etag: keptVersionsETag(digest, proxyURL, name, kept)}, size: len(out)}, nil
|
2026-10-09 08:10:41 +02:00
|
|
|
}
|
Cache cooldown metadata rewrites for a minute
With cooldown on, every npm, Composer and Chef metadata request rewrote
the whole document, because a cached rewrite could keep hiding a
version after its cooldown ended. For a 24.5 MB packument that is about
45 ms and 31 MB of allocations per request, and the result was served
uncompressed and without Last-Modified, so Composer, which revalidates
p2 files with If-Modified-Since only, never got a 304.
The rewrite cache now keeps cooldown rewrites with an expiry of one
minute, so a version leaving cooldown shows up at most that late.
Concurrent requests share one rewrite, clients get the gzip copy, and
the rewrite is dated through metadataDate like any other. Its ETag is
the hash of its output, so replicas agree on it.
The cache is keyed by the digest the metadata cache already recorded,
instead of hashing the document again, which also lets storedRewrite
serve cooldown rewrites without reading the stored document.
Without the rewrite cache, cooldown rewrites behave as before.
2026-10-23 13:24:58 +02:00
|
|
|
m, err := rewriteWithETag(in, rewriteOnly)
|
|
|
|
|
if err == nil && !cooldown {
|
2026-10-15 12:46:55 +02:00
|
|
|
m.lastModified = p.metadataDate(ecosystem, proxyURL, name, m.etag)
|
Share the dates of rewritten metadata across replicas
Dates were kept in each process's rewrite cache, so replicas sharing a
database and storage dated independently: one dated a document, upstream
changed in that second, and another gave the new document the same date,
so a client holding it got 304 and kept the old document.
- Date from metadata_dates instead of process memory: a package's date is
the second it started serving its current representation, recorded once
for every replica
- Switch a package to another representation only in a second later than
its recorded date, and serve it undated until then
- Date nothing in the second the process started, so dates clients hold
from before this change, or from the process it replaced, stay older
- Serve no date ahead of the local clock, recorded by a replica whose clock
is ahead
- Date rewrites without the rewrite cache too; the cache keeps only ETags
- Add two-replica, pre-fix date and clock skew regression tests
2026-10-12 12:52:02 +02:00
|
|
|
}
|
2026-10-15 12:46:55 +02:00
|
|
|
return m, err
|
Cache rewritten npm and Composer metadata (#402)
The npm and Composer handlers rewrite every metadata document they serve
so that download URLs point at the proxy: decode the whole document into
generic maps, change the URLs, encode it again, and for Composer expand
the minified format first. That ran on every request, cached metadata
included. With metadata caching on and upstream out of the picture, a
cached request still cost 7 ms for @babel/core, 17 ms for
symfony/console and 155 ms and 134 MB of allocations for typescript, and
an 8 vCPU VM serving cached npm packuments to 20 clients ran the proxy
at 450% CPU for about 1,100 requests a second.
Rewritten documents are now kept in memory, keyed by the ecosystem,
proxy URL, package and a SHA-256 of the raw document, so new bytes from
upstream are rewritten again and nothing is served stale. Requests that
arrive while a document is being rewritten wait for that rewrite rather
than running their own; a waiter leaves when its client does, and the
rewrite still completes and is cached. The denylist is fixed at startup,
so it needs no place in the key. Cooldown filtering depends on the
current time, so with cooldown on the cache is bypassed.
metadata_rewrite_cache_size bounds the cache (default "256MB", least
recently used out first, "0" to rewrite on every request). NewProxy
callers keep the old behaviour unless they set it.
Whole cached requests, measured locally:
@babel/core 7.1 ms -> 0.38 ms, 54,489 -> 119 allocations
typescript 155 ms -> 10.5 ms, 1.37M -> 133 allocations
symfony/console 17.4 ms -> 0.48 ms, 165,909 -> 123 allocations
What remains is reading the raw document from storage and hashing it.
2026-10-03 04:09:15 -07:00
|
|
|
}
|
|
|
|
|
|
2026-10-30 14:14:10 +01:00
|
|
|
// streamedRewrite rewrites a document stored in storage, through the
|
|
|
|
|
// proxy's rewrite cache, without holding the document or its rewrite in
|
|
|
|
|
// memory uncompressed. rewrite is given a way to open the stored document,
|
|
|
|
|
// as often as it needs to read it, and writes the rewritten document to w.
|
|
|
|
|
// The result is cached under the same key cachedRewrite uses for the
|
|
|
|
|
// document, and expires the same way with cooldown on.
|
|
|
|
|
//
|
|
|
|
|
// It returns errMetadataNotStored without the rewrite cache, which the
|
|
|
|
|
// result has to be kept in.
|
|
|
|
|
func (p *Proxy) streamedRewrite(ctx context.Context, ecosystem, proxyURL, name string, entry *database.MetadataCacheEntry, rewrite func(open func() (io.ReadCloser, error), w io.Writer) error) (rewrittenMetadata, error) {
|
|
|
|
|
if p.rewrites == nil {
|
|
|
|
|
return rewrittenMetadata{}, errMetadataNotStored
|
|
|
|
|
}
|
|
|
|
|
var expiry func(time.Time) time.Time
|
|
|
|
|
if p.cooldownEnabled() {
|
|
|
|
|
rec := p.watchCooldown()
|
|
|
|
|
defer p.unwatchCooldown(rec)
|
|
|
|
|
expiry = func(start time.Time) time.Time { return p.cooldownExpiry(rec, start) }
|
|
|
|
|
}
|
|
|
|
|
key := rewriteCacheKeyForDigest(ecosystem, proxyURL, name, entry.ContentDigest.String)
|
|
|
|
|
m, err := p.rewrites.produce(ctx, key, expiry, func() (rewrittenMetadata, error) {
|
|
|
|
|
// The rewrite runs to completion for the cache even when the
|
|
|
|
|
// caller leaves.
|
|
|
|
|
readCtx := context.WithoutCancel(ctx)
|
|
|
|
|
return gzipRewrite(func(w io.Writer) error {
|
|
|
|
|
return rewrite(func() (io.ReadCloser, error) { return p.openStoredMetadata(readCtx, entry) }, w)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
if err == nil {
|
|
|
|
|
m.lastModified = p.metadataDate(ecosystem, proxyURL, name, m.etag)
|
|
|
|
|
}
|
|
|
|
|
return m, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// gzipRewrite runs write and keeps what it writes gzip-compressed only, with
|
|
|
|
|
// the ETag and length of the uncompressed output.
|
|
|
|
|
func gzipRewrite(write func(w io.Writer) error) (rewrittenMetadata, error) {
|
|
|
|
|
var buf bytes.Buffer
|
|
|
|
|
zw := gzip.NewWriter(&buf)
|
|
|
|
|
sum := sha256.New()
|
|
|
|
|
n := &countingWriter{w: io.Discard}
|
|
|
|
|
if err := write(io.MultiWriter(zw, sum, n)); err != nil {
|
|
|
|
|
return rewrittenMetadata{}, err
|
|
|
|
|
}
|
|
|
|
|
if err := zw.Close(); err != nil {
|
|
|
|
|
return rewrittenMetadata{}, err
|
|
|
|
|
}
|
|
|
|
|
return rewrittenMetadata{
|
|
|
|
|
metadataValidators: metadataValidators{etag: `"` + hex.EncodeToString(sum.Sum(nil)) + `"`},
|
|
|
|
|
gzipped: buf.Bytes(),
|
|
|
|
|
size: int(n.n),
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-13 13:23:29 +02:00
|
|
|
// uncachedRewrite gives a rewrite made outside the cache its validators: an
|
|
|
|
|
// ETag of the output itself and, as cachedRewrite does, a date that replicas
|
|
|
|
|
// agree on.
|
|
|
|
|
func (p *Proxy) uncachedRewrite(ecosystem, proxyURL, name string, out []byte) rewrittenMetadata {
|
|
|
|
|
v := metadataValidators{etag: metadataETag(out)}
|
|
|
|
|
if !p.cooldownEnabled() {
|
|
|
|
|
v.lastModified = p.metadataDate(ecosystem, proxyURL, name, v.etag)
|
|
|
|
|
}
|
2026-10-29 10:52:39 +01:00
|
|
|
return rewrittenMetadata{plain: out, metadataValidators: v, size: len(out)}
|
2026-10-13 13:23:29 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-08 13:26:55 +02:00
|
|
|
func (p *Proxy) cooldownEnabled() bool {
|
|
|
|
|
return p.Cooldown != nil && p.Cooldown.Enabled()
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-08 07:36:33 +02:00
|
|
|
// storedRewrite returns the cached rewrite of the metadata stored for
|
|
|
|
|
// ecosystem and cacheKey, when that metadata is still within its TTL and a
|
Cache cooldown metadata rewrites for a minute
With cooldown on, every npm, Composer and Chef metadata request rewrote
the whole document, because a cached rewrite could keep hiding a
version after its cooldown ended. For a 24.5 MB packument that is about
45 ms and 31 MB of allocations per request, and the result was served
uncompressed and without Last-Modified, so Composer, which revalidates
p2 files with If-Modified-Since only, never got a 304.
The rewrite cache now keeps cooldown rewrites with an expiry of one
minute, so a version leaving cooldown shows up at most that late.
Concurrent requests share one rewrite, clients get the gzip copy, and
the rewrite is dated through metadataDate like any other. Its ETag is
the hash of its output, so replicas agree on it.
The cache is keyed by the digest the metadata cache already recorded,
instead of hashing the document again, which also lets storedRewrite
serve cooldown rewrites without reading the stored document.
Without the rewrite cache, cooldown rewrites behave as before.
2026-10-23 13:24:58 +02:00
|
|
|
// rewrite of it is cached and has not expired. It reads only the cache row,
|
|
|
|
|
// never the stored document, so a repeated request for a large packument
|
|
|
|
|
// costs a database lookup instead of reading and hashing the whole document
|
|
|
|
|
// again. When it reports false the caller takes the usual fetch and rewrite
|
|
|
|
|
// path.
|
2026-10-08 13:26:55 +02:00
|
|
|
func (p *Proxy) storedRewrite(ecosystem, cacheKey, proxyURL, name string) (rewrittenMetadata, bool) {
|
2026-10-25 17:32:52 +01:00
|
|
|
m, _, ok := p.storedRewriteVariant(ecosystem, cacheKey, proxyURL, name, nil)
|
|
|
|
|
return m, ok
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// storedRewriteVariant is storedRewrite for a rewrite whose output depends on
|
|
|
|
|
// more than the stored document. variant is given the stored content type
|
|
|
|
|
// and returns the rest of what the rewrite depends on, as passed to
|
|
|
|
|
// variantDigest; nil means nothing else. It also returns the stored content
|
|
|
|
|
// type, as a fetch of the document would.
|
|
|
|
|
func (p *Proxy) storedRewriteVariant(ecosystem, cacheKey, proxyURL, name string, variant func(contentType string) []string) (rewrittenMetadata, string, bool) {
|
Cache cooldown metadata rewrites for a minute
With cooldown on, every npm, Composer and Chef metadata request rewrote
the whole document, because a cached rewrite could keep hiding a
version after its cooldown ended. For a 24.5 MB packument that is about
45 ms and 31 MB of allocations per request, and the result was served
uncompressed and without Last-Modified, so Composer, which revalidates
p2 files with If-Modified-Since only, never got a 304.
The rewrite cache now keeps cooldown rewrites with an expiry of one
minute, so a version leaving cooldown shows up at most that late.
Concurrent requests share one rewrite, clients get the gzip copy, and
the rewrite is dated through metadataDate like any other. Its ETag is
the hash of its output, so replicas agree on it.
The cache is keyed by the digest the metadata cache already recorded,
instead of hashing the document again, which also lets storedRewrite
serve cooldown rewrites without reading the stored document.
Without the rewrite cache, cooldown rewrites behave as before.
2026-10-23 13:24:58 +02:00
|
|
|
if p.rewrites == nil {
|
2026-10-25 17:32:52 +01:00
|
|
|
return rewrittenMetadata{}, "", false
|
2026-10-08 07:36:33 +02:00
|
|
|
}
|
|
|
|
|
if !p.CacheMetadata || p.DB == nil || p.MetadataTTL <= 0 {
|
2026-10-25 17:32:52 +01:00
|
|
|
return rewrittenMetadata{}, "", false
|
2026-10-08 07:36:33 +02:00
|
|
|
}
|
|
|
|
|
entry, err := p.DB.GetMetadataCache(ecosystem, cacheKey)
|
|
|
|
|
if err != nil || entry == nil || !entry.ContentDigest.Valid || entry.ContentEncoding.String != "" {
|
2026-10-25 17:32:52 +01:00
|
|
|
return rewrittenMetadata{}, "", false
|
2026-10-08 07:36:33 +02:00
|
|
|
}
|
|
|
|
|
if !entry.FetchedAt.Valid || time.Since(entry.FetchedAt.Time) >= p.MetadataTTL {
|
2026-10-25 17:32:52 +01:00
|
|
|
return rewrittenMetadata{}, "", false
|
2026-10-08 13:26:55 +02:00
|
|
|
}
|
2026-10-25 17:32:52 +01:00
|
|
|
contentType := contentTypeJSON
|
|
|
|
|
if entry.ContentType.Valid {
|
|
|
|
|
contentType = entry.ContentType.String
|
|
|
|
|
}
|
|
|
|
|
digest := entry.ContentDigest.String
|
|
|
|
|
if variant != nil {
|
|
|
|
|
digest = variantDigest(digest, nil, variant(contentType)...)
|
|
|
|
|
}
|
|
|
|
|
m, ok := p.rewrites.get(rewriteCacheKeyForDigest(ecosystem, proxyURL, name, digest))
|
2026-10-08 13:26:55 +02:00
|
|
|
if !ok {
|
2026-10-25 17:32:52 +01:00
|
|
|
return rewrittenMetadata{}, "", false
|
2026-10-08 13:26:55 +02:00
|
|
|
}
|
2026-10-15 12:46:55 +02:00
|
|
|
m.lastModified = p.metadataDate(ecosystem, proxyURL, name, m.etag)
|
2026-10-29 10:52:39 +01:00
|
|
|
metrics.RecordMetadataCacheHit(ecosystem, m.size)
|
2026-10-20 11:22:55 +02:00
|
|
|
p.recordMetadataRead(entry)
|
2026-10-25 17:32:52 +01:00
|
|
|
return m, contentType, true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// variantDigest returns a digest for a rewrite of in that also depends on
|
|
|
|
|
// parts, such as the content type or the versions a filter removes, so that
|
|
|
|
|
// rewrites differing in them are cached apart. digest is in's digest as the
|
|
|
|
|
// metadata cache records it, or empty to hash in. Passed to cachedRewrite in
|
|
|
|
|
// place of the document's digest, it keeps the metadata date keyed by the
|
|
|
|
|
// package alone.
|
|
|
|
|
func variantDigest(digest string, in []byte, parts ...string) string {
|
|
|
|
|
if digest == "" {
|
|
|
|
|
sum := sha256.Sum256(in)
|
|
|
|
|
digest = "sha256:" + hex.EncodeToString(sum[:])
|
|
|
|
|
}
|
|
|
|
|
h := sha256.New()
|
|
|
|
|
var length []byte
|
|
|
|
|
for _, part := range parts {
|
|
|
|
|
length = binary.AppendUvarint(length[:0], uint64(len(part)))
|
|
|
|
|
_, _ = h.Write(length)
|
|
|
|
|
_, _ = io.WriteString(h, part)
|
|
|
|
|
}
|
|
|
|
|
return digest + "+" + hex.EncodeToString(h.Sum(nil))
|
2026-10-08 13:26:55 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// serveRewrittenMetadata writes rewritten metadata with its validators, or
|
2026-10-15 12:46:55 +02:00
|
|
|
// 304 Not Modified when they show the client's copy is current. A client
|
|
|
|
|
// accepting gzip gets the compressed copy when there is one. Being another
|
|
|
|
|
// representation, it has an ETag of its own.
|
2026-10-08 13:26:55 +02:00
|
|
|
func serveRewrittenMetadata(w http.ResponseWriter, r *http.Request, m rewrittenMetadata) {
|
2026-10-23 20:53:06 +02:00
|
|
|
serveRewrittenMetadataAs(w, r, m, contentTypeJSON)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// serveRewrittenMetadataAs is serveRewrittenMetadata for a document of
|
|
|
|
|
// another content type.
|
|
|
|
|
func serveRewrittenMetadataAs(w http.ResponseWriter, r *http.Request, m rewrittenMetadata, contentType string) {
|
2026-10-29 10:52:39 +01:00
|
|
|
etag := m.etag
|
2026-10-15 12:46:55 +02:00
|
|
|
compressed := m.gzipped != nil && acceptsGzip(r)
|
|
|
|
|
if compressed {
|
2026-10-29 10:52:39 +01:00
|
|
|
etag = gzipETag(m.etag)
|
2026-10-15 12:46:55 +02:00
|
|
|
}
|
2026-10-25 17:32:52 +01:00
|
|
|
ensureVary(w.Header(), headerAcceptEncoding)
|
2026-10-15 12:46:55 +02:00
|
|
|
w.Header().Set(headerETag, etag)
|
2026-10-08 13:26:55 +02:00
|
|
|
if !m.lastModified.IsZero() {
|
|
|
|
|
w.Header().Set(headerLastModified, m.lastModified.UTC().Format(http.TimeFormat))
|
|
|
|
|
}
|
2026-10-15 12:46:55 +02:00
|
|
|
if notModified(r, etag, m.lastModified) {
|
2026-10-08 13:26:55 +02:00
|
|
|
w.WriteHeader(http.StatusNotModified)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-10-23 20:53:06 +02:00
|
|
|
w.Header().Set(headerContentType, contentType)
|
2026-10-15 12:46:55 +02:00
|
|
|
if compressed {
|
|
|
|
|
w.Header().Set(headerContentEncoding, "gzip")
|
2026-10-30 14:14:10 +01:00
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
_, _ = w.Write(m.gzipped)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if m.plain == nil && m.gzipped != nil {
|
|
|
|
|
// Decompressed as it is sent, rather than into a copy of the
|
|
|
|
|
// whole document.
|
|
|
|
|
zr, err := gzip.NewReader(bytes.NewReader(m.gzipped))
|
|
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, "failed to read cached metadata", http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set(headerContentLength, strconv.Itoa(m.size))
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
_, _ = io.Copy(w, zr)
|
|
|
|
|
return
|
2026-10-15 12:46:55 +02:00
|
|
|
}
|
2026-10-08 13:26:55 +02:00
|
|
|
w.WriteHeader(http.StatusOK)
|
2026-10-30 14:14:10 +01:00
|
|
|
_, _ = w.Write(m.plain)
|
2026-10-15 12:46:55 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-25 17:32:52 +01:00
|
|
|
// ensureVary adds field to the Vary header unless it is listed already.
|
|
|
|
|
func ensureVary(header http.Header, field string) {
|
|
|
|
|
for _, value := range header.Values("Vary") {
|
|
|
|
|
for listed := range strings.SplitSeq(value, ",") {
|
|
|
|
|
if strings.EqualFold(strings.TrimSpace(listed), field) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
header.Add("Vary", field)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 12:46:55 +02:00
|
|
|
// gzipETag returns the ETag of the gzip representation of the document
|
|
|
|
|
// whose ETag is etag.
|
|
|
|
|
func gzipETag(etag string) string {
|
|
|
|
|
return strings.TrimSuffix(etag, `"`) + `-gzip"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// acceptsGzip reports whether the request's Accept-Encoding allows a gzip
|
|
|
|
|
// response: gzip or x-gzip, or else "*", with a quality above zero.
|
|
|
|
|
func acceptsGzip(r *http.Request) bool {
|
|
|
|
|
wildcard := false
|
|
|
|
|
for _, value := range r.Header.Values(headerAcceptEncoding) {
|
|
|
|
|
for coding := range strings.SplitSeq(value, ",") {
|
|
|
|
|
name, params, _ := strings.Cut(coding, ";")
|
|
|
|
|
switch strings.ToLower(strings.TrimSpace(name)) {
|
|
|
|
|
case "gzip", "x-gzip":
|
|
|
|
|
return codingQualityPositive(params)
|
|
|
|
|
case "*":
|
|
|
|
|
wildcard = codingQualityPositive(params)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return wildcard
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// codingQualityPositive reports whether an Accept-Encoding entry's
|
|
|
|
|
// parameters give it a quality above zero. No q parameter means 1.
|
|
|
|
|
func codingQualityPositive(params string) bool {
|
|
|
|
|
for param := range strings.SplitSeq(params, ";") {
|
|
|
|
|
key, value, _ := strings.Cut(param, "=")
|
|
|
|
|
if strings.TrimSpace(key) != "q" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
q, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
|
|
|
|
|
return err == nil && q > 0
|
|
|
|
|
}
|
|
|
|
|
return true
|
2026-10-08 13:26:55 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// notModified reports whether a conditional request's validators match.
|
|
|
|
|
// If-Modified-Since only counts when the request has no If-None-Match.
|
|
|
|
|
func notModified(r *http.Request, etag string, lastModified time.Time) bool {
|
|
|
|
|
if header := r.Header.Get("If-None-Match"); header != "" {
|
|
|
|
|
return ifNoneMatchHits(header, etag)
|
2026-10-08 07:36:33 +02:00
|
|
|
}
|
2026-10-08 13:26:55 +02:00
|
|
|
if lastModified.IsZero() {
|
|
|
|
|
return false
|
2026-10-08 07:36:33 +02:00
|
|
|
}
|
2026-10-08 13:26:55 +02:00
|
|
|
since, err := http.ParseTime(r.Header.Get("If-Modified-Since"))
|
|
|
|
|
return err == nil && !lastModified.Truncate(time.Second).After(since)
|
2026-10-08 07:36:33 +02:00
|
|
|
}
|
|
|
|
|
|
Cache rewritten npm and Composer metadata (#402)
The npm and Composer handlers rewrite every metadata document they serve
so that download URLs point at the proxy: decode the whole document into
generic maps, change the URLs, encode it again, and for Composer expand
the minified format first. That ran on every request, cached metadata
included. With metadata caching on and upstream out of the picture, a
cached request still cost 7 ms for @babel/core, 17 ms for
symfony/console and 155 ms and 134 MB of allocations for typescript, and
an 8 vCPU VM serving cached npm packuments to 20 clients ran the proxy
at 450% CPU for about 1,100 requests a second.
Rewritten documents are now kept in memory, keyed by the ecosystem,
proxy URL, package and a SHA-256 of the raw document, so new bytes from
upstream are rewritten again and nothing is served stale. Requests that
arrive while a document is being rewritten wait for that rewrite rather
than running their own; a waiter leaves when its client does, and the
rewrite still completes and is cached. The denylist is fixed at startup,
so it needs no place in the key. Cooldown filtering depends on the
current time, so with cooldown on the cache is bypassed.
metadata_rewrite_cache_size bounds the cache (default "256MB", least
recently used out first, "0" to rewrite on every request). NewProxy
callers keep the old behaviour unless they set it.
Whole cached requests, measured locally:
@babel/core 7.1 ms -> 0.38 ms, 54,489 -> 119 allocations
typescript 155 ms -> 10.5 ms, 1.37M -> 133 allocations
symfony/console 17.4 ms -> 0.48 ms, 165,909 -> 123 allocations
What remains is reading the raw document from storage and hashing it.
2026-10-03 04:09:15 -07:00
|
|
|
// SetMetadataRewriteCacheSize enables the cache of rewritten metadata with
|
|
|
|
|
// room for maxBytes of output. Zero or less disables it.
|
|
|
|
|
func (p *Proxy) SetMetadataRewriteCacheSize(maxBytes int64) {
|
|
|
|
|
p.rewrites = newRewriteCache(maxBytes)
|
Cache cooldown metadata rewrites for a minute
With cooldown on, every npm, Composer and Chef metadata request rewrote
the whole document, because a cached rewrite could keep hiding a
version after its cooldown ended. For a 24.5 MB packument that is about
45 ms and 31 MB of allocations per request, and the result was served
uncompressed and without Last-Modified, so Composer, which revalidates
p2 files with If-Modified-Since only, never got a 304.
The rewrite cache now keeps cooldown rewrites with an expiry of one
minute, so a version leaving cooldown shows up at most that late.
Concurrent requests share one rewrite, clients get the gzip copy, and
the rewrite is dated through metadataDate like any other. Its ETag is
the hash of its output, so replicas agree on it.
The cache is keyed by the digest the metadata cache already recorded,
instead of hashing the document again, which also lets storedRewrite
serve cooldown rewrites without reading the stored document.
Without the rewrite cache, cooldown rewrites behave as before.
2026-10-23 13:24:58 +02:00
|
|
|
if p.rewrites != nil {
|
|
|
|
|
p.rewrites.now = p.clock
|
|
|
|
|
}
|
Cache rewritten npm and Composer metadata (#402)
The npm and Composer handlers rewrite every metadata document they serve
so that download URLs point at the proxy: decode the whole document into
generic maps, change the URLs, encode it again, and for Composer expand
the minified format first. That ran on every request, cached metadata
included. With metadata caching on and upstream out of the picture, a
cached request still cost 7 ms for @babel/core, 17 ms for
symfony/console and 155 ms and 134 MB of allocations for typescript, and
an 8 vCPU VM serving cached npm packuments to 20 clients ran the proxy
at 450% CPU for about 1,100 requests a second.
Rewritten documents are now kept in memory, keyed by the ecosystem,
proxy URL, package and a SHA-256 of the raw document, so new bytes from
upstream are rewritten again and nothing is served stale. Requests that
arrive while a document is being rewritten wait for that rewrite rather
than running their own; a waiter leaves when its client does, and the
rewrite still completes and is cached. The denylist is fixed at startup,
so it needs no place in the key. Cooldown filtering depends on the
current time, so with cooldown on the cache is bypassed.
metadata_rewrite_cache_size bounds the cache (default "256MB", least
recently used out first, "0" to rewrite on every request). NewProxy
callers keep the old behaviour unless they set it.
Whole cached requests, measured locally:
@babel/core 7.1 ms -> 0.38 ms, 54,489 -> 119 allocations
typescript 155 ms -> 10.5 ms, 1.37M -> 133 allocations
symfony/console 17.4 ms -> 0.48 ms, 165,909 -> 123 allocations
What remains is reading the raw document from storage and hashing it.
2026-10-03 04:09:15 -07:00
|
|
|
}
|