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 (
|
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
|
|
|
"bytes"
|
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
|
|
|
"context"
|
|
|
|
|
"errors"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"strings"
|
|
|
|
|
"sync"
|
|
|
|
|
"sync/atomic"
|
|
|
|
|
"testing"
|
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
|
|
|
"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
|
|
|
|
|
|
|
|
"github.com/git-pkgs/cooldown"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// countingRewrite upper-cases its input and counts calls. If entered is set it
|
|
|
|
|
// is closed when the first call starts, and the call then waits on release.
|
|
|
|
|
type countingRewrite struct {
|
|
|
|
|
calls atomic.Int64
|
|
|
|
|
entered chan struct{}
|
|
|
|
|
release chan struct{}
|
|
|
|
|
once sync.Once
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (r *countingRewrite) fn(in []byte) ([]byte, error) {
|
|
|
|
|
r.calls.Add(1)
|
|
|
|
|
if r.entered != nil {
|
|
|
|
|
r.once.Do(func() { close(r.entered) })
|
|
|
|
|
<-r.release
|
|
|
|
|
}
|
|
|
|
|
return []byte(strings.ToUpper(string(in))), nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-09 08:10:41 +02:00
|
|
|
func (r *countingRewrite) keeping(in []byte) ([]byte, []string, error) {
|
|
|
|
|
out, err := r.fn(in)
|
|
|
|
|
return out, nil, 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
|
|
|
func TestRewriteCache_RepeatServesCachedRewrite(t *testing.T) {
|
|
|
|
|
c := newRewriteCache(1 << 20)
|
|
|
|
|
var r countingRewrite
|
|
|
|
|
key := rewriteCacheKey("npm", "http://proxy", "left-pad", []byte("doc"))
|
|
|
|
|
|
|
|
|
|
for range 3 {
|
2026-10-30 15:06:42 +01:00
|
|
|
m, err := c.rewrite(context.Background(), key, []byte("doc"), nil, r.fn)
|
2026-10-29 10:52:39 +01:00
|
|
|
if err != nil || string(m.content()) != "DOC" {
|
|
|
|
|
t.Fatalf("rewrite = %q, %v", m.content(), 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
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if got := r.calls.Load(); got != 1 {
|
|
|
|
|
t.Errorf("rewrites = %d, want 1", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRewriteCache_NewUpstreamBytesRewriteAgain(t *testing.T) {
|
|
|
|
|
c := newRewriteCache(1 << 20)
|
|
|
|
|
var r countingRewrite
|
|
|
|
|
|
|
|
|
|
for _, doc := range []string{"v1", "v2"} {
|
|
|
|
|
key := rewriteCacheKey("npm", "http://proxy", "left-pad", []byte(doc))
|
2026-10-30 15:06:42 +01:00
|
|
|
m, err := c.rewrite(context.Background(), key, []byte(doc), nil, r.fn)
|
2026-10-29 10:52:39 +01:00
|
|
|
if err != nil || string(m.content()) != strings.ToUpper(doc) {
|
|
|
|
|
t.Fatalf("rewrite(%s) = %q, %v", doc, m.content(), 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
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if got := r.calls.Load(); got != 2 {
|
|
|
|
|
t.Errorf("rewrites = %d, want 2", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestRewriteCache_ConcurrentRequestsShareRewrite asserts that requests
|
|
|
|
|
// arriving while a document is being rewritten wait for that rewrite instead
|
|
|
|
|
// of running their own. Callers that arrive after it finishes hit the cache,
|
|
|
|
|
// so the count holds however the goroutines are scheduled.
|
|
|
|
|
func TestRewriteCache_ConcurrentRequestsShareRewrite(t *testing.T) {
|
|
|
|
|
c := newRewriteCache(1 << 20)
|
|
|
|
|
r := countingRewrite{entered: make(chan struct{}), release: make(chan struct{})}
|
|
|
|
|
key := rewriteCacheKey("npm", "http://proxy", "typescript", []byte("doc"))
|
|
|
|
|
const n = 10
|
|
|
|
|
|
|
|
|
|
outs := make(chan string, n)
|
|
|
|
|
run := func() {
|
2026-10-30 15:06:42 +01:00
|
|
|
m, err := c.rewrite(context.Background(), key, []byte("doc"), nil, r.fn)
|
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 err != nil {
|
|
|
|
|
t.Error(err)
|
|
|
|
|
}
|
2026-10-29 10:52:39 +01:00
|
|
|
outs <- string(m.content())
|
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
|
|
|
}
|
|
|
|
|
go run()
|
|
|
|
|
<-r.entered
|
|
|
|
|
for range n - 1 {
|
|
|
|
|
go run()
|
|
|
|
|
}
|
|
|
|
|
close(r.release)
|
|
|
|
|
|
|
|
|
|
for range n {
|
|
|
|
|
if out := <-outs; out != "DOC" {
|
|
|
|
|
t.Errorf("out = %q", out)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if got := r.calls.Load(); got != 1 {
|
|
|
|
|
t.Errorf("rewrites = %d, want 1", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestRewriteCache_WaiterLeavingKeepsRewrite asserts that a caller waiting on
|
|
|
|
|
// another's rewrite returns its own context error when its client leaves,
|
|
|
|
|
// while the rewrite completes and is cached for the next request.
|
|
|
|
|
func TestRewriteCache_WaiterLeavingKeepsRewrite(t *testing.T) {
|
|
|
|
|
c := newRewriteCache(1 << 20)
|
|
|
|
|
r := countingRewrite{entered: make(chan struct{}), release: make(chan struct{})}
|
|
|
|
|
key := rewriteCacheKey("npm", "http://proxy", "typescript", []byte("doc"))
|
|
|
|
|
|
|
|
|
|
first := make(chan error, 1)
|
|
|
|
|
go func() {
|
2026-10-30 15:06:42 +01:00
|
|
|
_, err := c.rewrite(context.Background(), key, []byte("doc"), nil, r.fn)
|
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
|
|
|
first <- err
|
|
|
|
|
}()
|
|
|
|
|
<-r.entered
|
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
|
cancel()
|
2026-10-30 15:06:42 +01:00
|
|
|
if _, err := c.rewrite(ctx, key, []byte("doc"), nil, r.fn); !errors.Is(err, context.Canceled) {
|
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
|
|
|
t.Errorf("waiter err = %v, want context.Canceled", err)
|
|
|
|
|
}
|
|
|
|
|
close(r.release)
|
|
|
|
|
if err := <-first; err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
2026-10-30 15:06:42 +01:00
|
|
|
if m, err := c.rewrite(context.Background(), key, []byte("doc"), nil, r.fn); err != nil || string(m.content()) != "DOC" {
|
2026-10-29 10:52:39 +01:00
|
|
|
t.Errorf("cached rewrite = %q, %v", m.content(), 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
|
|
|
}
|
|
|
|
|
if got := r.calls.Load(); got != 1 {
|
|
|
|
|
t.Errorf("rewrites = %d, want 1", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRewriteCache_EvictsLeastRecentlyUsed(t *testing.T) {
|
|
|
|
|
c := newRewriteCache(6) // room for two three-byte outputs
|
|
|
|
|
var r countingRewrite
|
|
|
|
|
keyFor := func(doc string) string { return rewriteCacheKey("npm", "http://proxy", doc, []byte(doc)) }
|
|
|
|
|
|
|
|
|
|
for _, doc := range []string{"aaa", "bbb"} {
|
2026-10-30 15:06:42 +01:00
|
|
|
_, _ = c.rewrite(context.Background(), keyFor(doc), []byte(doc), nil, r.fn)
|
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 15:06:42 +01:00
|
|
|
_, _ = c.rewrite(context.Background(), keyFor("aaa"), []byte("aaa"), nil, r.fn) // aaa is now the most recent
|
|
|
|
|
_, _ = c.rewrite(context.Background(), keyFor("ccc"), []byte("ccc"), nil, r.fn) // evicts bbb
|
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
|
|
|
|
|
|
|
|
before := r.calls.Load()
|
2026-10-30 15:06:42 +01:00
|
|
|
_, _ = c.rewrite(context.Background(), keyFor("aaa"), []byte("aaa"), nil, r.fn)
|
|
|
|
|
_, _ = c.rewrite(context.Background(), keyFor("ccc"), []byte("ccc"), nil, r.fn)
|
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 got := r.calls.Load() - before; got != 0 {
|
|
|
|
|
t.Errorf("aaa and ccc rewritten %d times, want both still cached", got)
|
|
|
|
|
}
|
2026-10-30 15:06:42 +01:00
|
|
|
_, _ = c.rewrite(context.Background(), keyFor("bbb"), []byte("bbb"), nil, r.fn)
|
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 got := r.calls.Load() - before; got != 1 {
|
|
|
|
|
t.Errorf("bbb should have been evicted and rewritten")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRewriteCache_OutputLargerThanCacheIsNotStored(t *testing.T) {
|
|
|
|
|
c := newRewriteCache(2)
|
|
|
|
|
var r countingRewrite
|
|
|
|
|
key := rewriteCacheKey("npm", "http://proxy", "big", []byte("big"))
|
|
|
|
|
|
2026-10-30 15:06:42 +01:00
|
|
|
_, _ = c.rewrite(context.Background(), key, []byte("big"), nil, r.fn)
|
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.len() != 0 {
|
|
|
|
|
t.Errorf("cached %d entries, want 0", c.len())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRewriteCache_ErrorsAreNotCached(t *testing.T) {
|
|
|
|
|
c := newRewriteCache(1 << 20)
|
|
|
|
|
var calls int
|
|
|
|
|
failing := func([]byte) ([]byte, error) { calls++; return nil, errors.New("bad document") }
|
|
|
|
|
key := rewriteCacheKey("npm", "http://proxy", "broken", []byte("doc"))
|
|
|
|
|
|
|
|
|
|
for range 2 {
|
2026-10-30 15:06:42 +01:00
|
|
|
if _, err := c.rewrite(context.Background(), key, []byte("doc"), nil, failing); err == 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
|
|
|
t.Fatal("expected the rewrite error")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if calls != 2 {
|
|
|
|
|
t.Errorf("rewrites = %d, want 2", calls)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestCachedRewrite_DisabledRewritesEveryTime(t *testing.T) {
|
|
|
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
|
|
|
proxy.SetMetadataRewriteCacheSize(0)
|
|
|
|
|
var r countingRewrite
|
|
|
|
|
|
|
|
|
|
for range 2 {
|
2026-10-10 13:47:39 +02:00
|
|
|
_, _ = proxy.cachedRewrite(context.Background(), "npm", "http://proxy", "left-pad", []byte("doc"), "", r.keeping)
|
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 got := r.calls.Load(); got != 2 {
|
|
|
|
|
t.Errorf("rewrites = %d, want 2", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
// TestCachedRewrite_CooldownExpires asserts that with cooldown on a rewrite
|
2026-10-29 19:19:12 +01:00
|
|
|
// is cached for maxCooldownRewriteTTL only: cooldown filtering depends on 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
|
|
|
// current time, so a cached rewrite would otherwise keep hiding a version
|
|
|
|
|
// after its cooldown ends.
|
|
|
|
|
func TestCachedRewrite_CooldownExpires(t *testing.T) {
|
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
|
|
|
proxy, _, _, _ := setupTestProxy(t)
|
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
|
|
|
clock := &testClock{t: time.Date(2026, 10, 10, 12, 0, 0, 0, time.UTC)}
|
|
|
|
|
proxy.now = clock.now
|
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
|
|
|
proxy.SetMetadataRewriteCacheSize(1 << 20)
|
|
|
|
|
proxy.Cooldown = &cooldown.Config{Default: "3d"}
|
|
|
|
|
var r countingRewrite
|
|
|
|
|
|
|
|
|
|
for range 2 {
|
2026-10-10 13:47:39 +02:00
|
|
|
_, _ = proxy.cachedRewrite(context.Background(), "npm", "http://proxy", "left-pad", []byte("doc"), "", r.keeping)
|
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
|
|
|
}
|
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 got := r.calls.Load(); got != 1 {
|
|
|
|
|
t.Errorf("rewrites within the TTL = %d, want 1", got)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-29 19:19:12 +01:00
|
|
|
clock.advance(maxCooldownRewriteTTL)
|
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
|
|
|
_, _ = proxy.cachedRewrite(context.Background(), "npm", "http://proxy", "left-pad", []byte("doc"), "", r.keeping)
|
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 got := r.calls.Load(); got != 2 {
|
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
|
|
|
t.Errorf("rewrites after the TTL = %d, want 2", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestRewriteCacheExpiredEntryFreesRoom asserts that an expired entry is
|
|
|
|
|
// dropped when looked up, so its size no longer counts.
|
|
|
|
|
func TestRewriteCacheExpiredEntryFreesRoom(t *testing.T) {
|
|
|
|
|
c := newRewriteCache(1 << 20)
|
|
|
|
|
clock := &testClock{t: time.Date(2026, 10, 10, 12, 0, 0, 0, time.UTC)}
|
|
|
|
|
c.now = clock.now
|
|
|
|
|
upper := func(b []byte) ([]byte, error) { return bytes.ToUpper(b), nil }
|
2026-10-30 15:06:42 +01:00
|
|
|
if _, err := c.rewrite(context.Background(), "k", []byte("doc"), func(start time.Time) time.Time { return start.Add(time.Minute) }, upper); err != nil {
|
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
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
clock.advance(time.Minute)
|
|
|
|
|
if _, ok := c.get("k"); ok {
|
|
|
|
|
t.Fatal("expired entry was served")
|
|
|
|
|
}
|
|
|
|
|
if c.size != 0 || c.order.Len() != 0 {
|
|
|
|
|
t.Errorf("size %d, entries %d after expiry; want 0", c.size, c.order.Len())
|
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
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestMetadataHandlers_ServeCachedRewrite asserts that repeated requests for
|
|
|
|
|
// the same npm or Composer metadata reuse one rewrite and return the same
|
|
|
|
|
// bytes, with the download URLs pointing at the proxy.
|
|
|
|
|
func TestMetadataHandlers_ServeCachedRewrite(t *testing.T) {
|
|
|
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
switch r.URL.Path {
|
|
|
|
|
case "/left-pad":
|
|
|
|
|
_, _ = w.Write([]byte(`{"name":"left-pad","versions":{"1.3.0":{"dist":{"tarball":"https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz"}}}}`))
|
|
|
|
|
case "/p2/vendor/pkg.json":
|
|
|
|
|
_, _ = w.Write([]byte(`{"packages":{"vendor/pkg":[{"version":"1.0.0","dist":{"type":"zip","url":"https://example.com/pkg-1.0.0.zip"}}]}}`))
|
|
|
|
|
default:
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
defer upstream.Close()
|
|
|
|
|
|
|
|
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
|
|
|
proxy.HTTPClient = upstream.Client()
|
|
|
|
|
proxy.SetMetadataRewriteCacheSize(1 << 20)
|
|
|
|
|
|
|
|
|
|
cases := []struct {
|
|
|
|
|
name string
|
|
|
|
|
handler http.Handler
|
|
|
|
|
path string
|
|
|
|
|
}{
|
|
|
|
|
{"npm", NewNPMHandler(proxy, "http://proxy.example", upstream.URL).Routes(), "/left-pad"},
|
|
|
|
|
{"composer", NewComposerHandlerWithUpstreams(proxy, "http://proxy.example", upstream.URL, upstream.URL).Routes(), "/p2/vendor/pkg.json"},
|
|
|
|
|
}
|
|
|
|
|
for i, c := range cases {
|
|
|
|
|
var bodies []string
|
|
|
|
|
for range 2 {
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
c.handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, c.path, nil))
|
|
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("%s: status %d", c.name, rec.Code)
|
|
|
|
|
}
|
|
|
|
|
bodies = append(bodies, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
if bodies[0] != bodies[1] || !strings.Contains(bodies[0], "http://proxy.example") {
|
|
|
|
|
t.Errorf("%s: bodies %q and %q", c.name, bodies[0], bodies[1])
|
|
|
|
|
}
|
|
|
|
|
if got := proxy.rewrites.len(); got != i+1 {
|
|
|
|
|
t.Errorf("%s: cached rewrites = %d, want %d", c.name, got, i+1)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-19 08:35:46 +01:00
|
|
|
|
|
|
|
|
// len reports how many rewrites are cached.
|
|
|
|
|
func (c *rewriteCache) len() int {
|
|
|
|
|
if c == nil {
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
c.mu.Lock()
|
|
|
|
|
defer c.mu.Unlock()
|
|
|
|
|
return len(c.entries)
|
|
|
|
|
}
|
2026-10-29 19:19:12 +01:00
|
|
|
|
2026-10-30 15:06:42 +01:00
|
|
|
// TestCooldownRewriteTTL checks that the cap on a rewrite filtered by
|
|
|
|
|
// cooldown is the shortest cooldown, between a minute and a day.
|
2026-10-29 19:19:12 +01:00
|
|
|
func TestCooldownRewriteTTL(t *testing.T) {
|
|
|
|
|
for _, tc := range []struct {
|
|
|
|
|
shortest, want time.Duration
|
|
|
|
|
}{
|
2026-10-30 15:06:42 +01:00
|
|
|
{0, 24 * time.Hour},
|
|
|
|
|
{72 * time.Hour, 24 * time.Hour},
|
|
|
|
|
{2 * time.Hour, 2 * time.Hour},
|
|
|
|
|
{10 * time.Second, time.Minute},
|
2026-10-29 19:19:12 +01:00
|
|
|
} {
|
|
|
|
|
p := &Proxy{ShortestCooldown: tc.shortest}
|
|
|
|
|
if got := p.cooldownRewriteTTL(); got != tc.want {
|
|
|
|
|
t.Errorf("shortest cooldown %v: TTL = %v, want %v", tc.shortest, got, tc.want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|