From 481b7cf20485634f785eddffb3dd14dbb0dded06 Mon Sep 17 00:00:00 2001 From: montehurd Date: Sat, 3 Oct 2026 00:09:34 -0700 Subject: [PATCH] Cache rewritten npm and Composer metadata 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. --- config.example.yaml | 5 + docs/configuration.md | 10 + internal/config/config.go | 42 ++++ internal/config/config_test.go | 48 +++++ internal/handler/composer.go | 5 +- internal/handler/handler.go | 4 + internal/handler/npm.go | 7 +- internal/handler/rewrite_cache.go | 160 +++++++++++++++ internal/handler/rewrite_cache_test.go | 258 +++++++++++++++++++++++++ internal/server/server.go | 1 + 10 files changed, 538 insertions(+), 2 deletions(-) create mode 100644 internal/handler/rewrite_cache.go create mode 100644 internal/handler/rewrite_cache_test.go diff --git a/config.example.yaml b/config.example.yaml index 15326de4..44a3bfc3 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -14,6 +14,11 @@ base_url: "http://localhost:8080" # Set to "0" to disable the timeout. Default: "30s". # http_timeout: "30s" +# Memory for rewritten npm and Composer metadata, so each upstream document is +# rewritten once rather than on every request. Set to "0" to rewrite on every +# request. Default: "256MB". +# metadata_rewrite_cache_size: "256MB" + # Public URL where the web UI is reached. Defaults to base_url when unset. # Set this separately when the UI is served on a different hostname than the # package endpoints — for example, the UI on a public domain behind auth while diff --git a/docs/configuration.md b/docs/configuration.md index d5003fb4..ed5ad404 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -699,6 +699,16 @@ metadata_max_size: "100MB" # default Or via environment variable: `PROXY_METADATA_MAX_SIZE=250MB`. +### Rewritten metadata cache + +The npm and Composer handlers rewrite every metadata document they serve, so that download URLs point at the proxy. That means decoding the whole document and encoding it again, and Composer's minified documents are also expanded, which together cost milliseconds per typical package and far more for very large ones. The proxy keeps rewritten documents in memory, so each distinct upstream document is rewritten once and requests arriving while it is being rewritten wait for that rewrite. New bytes from upstream are rewritten again. With version cooldown enabled the cache is bypassed, because cooldown filtering depends on the current time. + +```yaml +metadata_rewrite_cache_size: "256MB" # default; "0" rewrites on every request +``` + +Or via environment variable: `PROXY_METADATA_REWRITE_CACHE_SIZE=1GB`. + ## Upstream HTTP timeout Protocol handlers use a shared HTTP client for upstream requests such as metadata fetches and pass-through file downloads. `http_timeout` sets that client's per-request timeout. Raise it if slow upstreams or large metadata responses cause `context deadline exceeded` errors. diff --git a/internal/config/config.go b/internal/config/config.go index f8dd1ab3..05ed6d2f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -155,6 +155,12 @@ type Config struct { // size return ErrMetadataTooLarge. Default: "100MB". MetadataMaxSize string `json:"metadata_max_size" yaml:"metadata_max_size"` + // MetadataRewriteCacheSize is how much rewritten npm and Composer + // metadata to keep in memory, so a document is rewritten once rather than + // on every request (e.g. "256MB", "1GB"). Default: "256MB". Set to "0" to + // rewrite on every request. + MetadataRewriteCacheSize string `json:"metadata_rewrite_cache_size" yaml:"metadata_rewrite_cache_size"` + // HTTPTimeout is the timeout for individual upstream HTTP requests made // by protocol handlers (metadata fetches, pass-through file requests). // Uses Go duration syntax (e.g. "30s", "2m"). Default: "30s". @@ -976,6 +982,7 @@ func (c *Config) LoadFromEnv() { setEnvBool(&c.MirrorAPI, "PROXY_MIRROR_API") setEnvString(&c.MetadataTTL, "PROXY_METADATA_TTL") setEnvString(&c.MetadataMaxSize, "PROXY_METADATA_MAX_SIZE") + setEnvString(&c.MetadataRewriteCacheSize, "PROXY_METADATA_REWRITE_CACHE_SIZE") setEnvString(&c.HTTPTimeout, "PROXY_HTTP_TIMEOUT") setEnvBool(&c.Gradle.BuildCache.ReadOnly, "PROXY_GRADLE_BUILD_CACHE_READ_ONLY") setEnvString(&c.Gradle.BuildCache.MaxUploadSize, "PROXY_GRADLE_BUILD_CACHE_MAX_UPLOAD_SIZE") @@ -1103,6 +1110,10 @@ func (c *Config) validateCacheArtifacts() error { } func (c *Config) validateComponents() error { + if err := validateMetadataRewriteCacheSize(c.MetadataRewriteCacheSize); err != nil { + return err + } + if _, err := denylist.New(c.Denylist.Packages); err != nil { return err } @@ -1183,6 +1194,7 @@ const ( defaultHTTPTimeout = 30 * time.Second //nolint:mnd // sensible default defaultHitFlushInterval = time.Second defaultMetadataMaxSize = 100 << 20 + defaultMetadataRewriteCacheSize = 256 << 20 defaultGradleBuildCacheMaxUploadSize = 100 << 20 defaultGradleBuildCacheSweepInterval = 10 * time.Minute defaultGradleMaxUploadSizeStr = "100MB" @@ -1231,6 +1243,36 @@ func validateMetadataMaxSize(s string) error { return nil } +func validateMetadataRewriteCacheSize(s string) error { + if s == "" || s == "0" { + return nil + } + size, err := ParseSize(s) + if err != nil { + return fmt.Errorf("invalid metadata_rewrite_cache_size: %w", err) + } + if size < 0 { + return fmt.Errorf("invalid metadata_rewrite_cache_size %q: must not be negative", s) + } + return nil +} + +// ParseMetadataRewriteCacheSize returns how many bytes of rewritten metadata +// to keep in memory. Returns 256MB if unset or invalid, 0 if disabled. +func (c *Config) ParseMetadataRewriteCacheSize() int64 { + if c.MetadataRewriteCacheSize == "" { + return defaultMetadataRewriteCacheSize + } + if c.MetadataRewriteCacheSize == "0" { + return 0 + } + size, err := ParseSize(c.MetadataRewriteCacheSize) + if err != nil || size < 0 { + return defaultMetadataRewriteCacheSize + } + return size +} + // ParseMetadataMaxSize returns the maximum metadata response size in bytes. // Returns 100MB if unset or invalid. func (c *Config) ParseMetadataMaxSize() int64 { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c3508984..20069704 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -940,6 +940,54 @@ func TestValidateHTTPTimeout(t *testing.T) { } } +func TestParseMetadataRewriteCacheSize(t *testing.T) { + tests := []struct { + name string + size string + want int64 + }{ + {"empty defaults to 256MB", "", 256 << 20}, + {"explicit zero disables", "0", 0}, + {"1GB", "1GB", 1 << 30}, + {"invalid defaults to 256MB", "lots", 256 << 20}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := Default() + cfg.MetadataRewriteCacheSize = tt.size + if got := cfg.ParseMetadataRewriteCacheSize(); got != tt.want { + t.Errorf("ParseMetadataRewriteCacheSize() = %d, want %d", got, tt.want) + } + }) + } +} + +func TestValidateMetadataRewriteCacheSize(t *testing.T) { + cfg := Default() + cfg.MetadataRewriteCacheSize = "lots" + if err := cfg.Validate(); err == nil { + t.Error("expected validation error for invalid metadata_rewrite_cache_size") + } + + for _, ok := range []string{"512MB", "0", ""} { + cfg.MetadataRewriteCacheSize = ok + if err := cfg.Validate(); err != nil { + t.Errorf("unexpected error for metadata_rewrite_cache_size %q: %v", ok, err) + } + } +} + +func TestLoadMetadataRewriteCacheSizeFromEnv(t *testing.T) { + cfg := Default() + t.Setenv("PROXY_METADATA_REWRITE_CACHE_SIZE", "1GB") + cfg.LoadFromEnv() + + if cfg.MetadataRewriteCacheSize != "1GB" { + t.Errorf("MetadataRewriteCacheSize = %q, want %q", cfg.MetadataRewriteCacheSize, "1GB") + } +} + func TestLoadHTTPTimeoutFromEnv(t *testing.T) { cfg := Default() t.Setenv("PROXY_HTTP_TIMEOUT", "90s") diff --git a/internal/handler/composer.go b/internal/handler/composer.go index 47378f79..ce606e3e 100644 --- a/internal/handler/composer.go +++ b/internal/handler/composer.go @@ -109,8 +109,11 @@ func (h *ComposerHandler) handlePackageMetadata(w http.ResponseWriter, r *http.R return } - rewritten, err := h.rewriteMetadata(body) + rewritten, err := h.proxy.cachedRewrite(r.Context(), "composer", h.proxyURL, packageName, body, h.rewriteMetadata) if err != nil { + if r.Context().Err() != nil { + return // the client left while waiting on a shared rewrite + } h.proxy.Logger.Warn("failed to rewrite metadata, proxying original", "error", err) w.Header().Set(headerContentType, "application/json") _, _ = w.Write(body) diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 37e353ca..b04f9419 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -197,6 +197,10 @@ type Proxy struct { // upstream fetch serves every waiting caller. Keyed by artifactCoalesceKey. fetchMu sync.Mutex inFlight map[string]*inflightFetch + + // rewrites caches metadata documents after their handler rewrites them. + // Nil leaves every request to rewrite its own copy. + rewrites *rewriteCache } // NewProxy creates a new Proxy with the given dependencies. diff --git a/internal/handler/npm.go b/internal/handler/npm.go index 27cf2513..07dcc485 100644 --- a/internal/handler/npm.go +++ b/internal/handler/npm.go @@ -188,8 +188,13 @@ func (h *NPMHandler) handlePackageMetadata(w http.ResponseWriter, r *http.Reques return } - rewritten, err := h.rewriteMetadata(packageName, body) + rewritten, err := h.proxy.cachedRewrite(r.Context(), "npm", h.proxyURL, packageName, body, func(b []byte) ([]byte, error) { + return h.rewriteMetadata(packageName, b) + }) if err != nil { + if r.Context().Err() != nil { + return // the client left while waiting on a shared rewrite + } if len(h.proxy.Denylist.Versions(canonicalPackagePURL("npm", packageName))) != 0 { JSONError(w, http.StatusBadGateway, "failed to filter package metadata") return diff --git a/internal/handler/rewrite_cache.go b/internal/handler/rewrite_cache.go new file mode 100644 index 00000000..7626827b --- /dev/null +++ b/internal/handler/rewrite_cache.go @@ -0,0 +1,160 @@ +package handler + +import ( + "container/list" + "context" + "crypto/sha256" + "errors" + "strings" + "sync" +) + +// 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 +// the key, and the denylist is loaded at startup. +type rewriteCache struct { + maxBytes int64 + + 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 { + key string + out []byte +} + +// inflightRewrite is one rewrite that concurrent callers share. out and err +// are written before done closes and read only after, so the close is the +// handoff. +type inflightRewrite struct { + done chan struct{} + out []byte + err error +} + +// 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{ + maxBytes: maxBytes, + order: list.New(), + entries: make(map[string]*list.Element), + inFlight: make(map[string]*inflightRewrite), + } +} + +// 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) + return strings.Join([]string{ecosystem, proxyURL, name, string(sum[:])}, "\x00") +} + +// rewrite returns rewrite(in), from the cache when it can. 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. +func (c *rewriteCache) rewrite(ctx context.Context, key string, in []byte, rewrite func([]byte) ([]byte, error)) ([]byte, error) { + if c == nil { + return rewrite(in) + } + + c.mu.Lock() + if el, ok := c.entries[key]; ok { + c.order.MoveToFront(el) + out := el.Value.(*rewriteEntry).out + c.mu.Unlock() + return out, nil + } + if f, ok := c.inFlight[key]; ok { + c.mu.Unlock() + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-f.done: + return f.out, f.err + } + } + f := &inflightRewrite{done: make(chan struct{}), err: errSharedRewriteAbandoned} + c.inFlight[key] = f + c.mu.Unlock() + + defer func() { + c.mu.Lock() + delete(c.inFlight, key) + if f.err == nil { + c.add(key, f.out) + } + c.mu.Unlock() + close(f.done) + }() + f.out, f.err = rewrite(in) + return f.out, f.err +} + +// add stores out under key and evicts least recently used entries until the +// cache is back under its limit. Output larger than the whole cache is not +// stored. Called with mu held. +func (c *rewriteCache) add(key string, out []byte) { + n := int64(len(out)) + if n > c.maxBytes { + return + } + c.entries[key] = c.order.PushFront(&rewriteEntry{key: key, out: out}) + c.size += n + for c.size > c.maxBytes { + oldest := c.order.Back() + e := oldest.Value.(*rewriteEntry) + c.order.Remove(oldest) + delete(c.entries, e.key) + c.size -= int64(len(e.out)) + } +} + +// 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) +} + +// cachedRewrite rewrites a metadata document through the proxy's rewrite +// cache. Cooldown filtering depends on the current time, so with cooldown on +// a cached rewrite could keep hiding a version past its cooldown; those +// rewrites always run. +func (p *Proxy) cachedRewrite(ctx context.Context, ecosystem, proxyURL, name string, in []byte, rewrite func([]byte) ([]byte, error)) ([]byte, error) { + if p.rewrites == nil || (p.Cooldown != nil && p.Cooldown.Enabled()) { + return rewrite(in) + } + return p.rewrites.rewrite(ctx, rewriteCacheKey(ecosystem, proxyURL, name, in), in, rewrite) +} + +// 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) +} diff --git a/internal/handler/rewrite_cache_test.go b/internal/handler/rewrite_cache_test.go new file mode 100644 index 00000000..0edb94fc --- /dev/null +++ b/internal/handler/rewrite_cache_test.go @@ -0,0 +1,258 @@ +package handler + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + + "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 +} + +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 { + out, err := c.rewrite(context.Background(), key, []byte("doc"), r.fn) + if err != nil || string(out) != "DOC" { + t.Fatalf("rewrite = %q, %v", out, err) + } + } + 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)) + out, err := c.rewrite(context.Background(), key, []byte(doc), r.fn) + if err != nil || string(out) != strings.ToUpper(doc) { + t.Fatalf("rewrite(%s) = %q, %v", doc, out, err) + } + } + 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() { + out, err := c.rewrite(context.Background(), key, []byte("doc"), r.fn) + if err != nil { + t.Error(err) + } + outs <- string(out) + } + 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() { + _, err := c.rewrite(context.Background(), key, []byte("doc"), r.fn) + first <- err + }() + <-r.entered + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := c.rewrite(ctx, key, []byte("doc"), r.fn); !errors.Is(err, context.Canceled) { + t.Errorf("waiter err = %v, want context.Canceled", err) + } + close(r.release) + if err := <-first; err != nil { + t.Fatal(err) + } + if out, err := c.rewrite(context.Background(), key, []byte("doc"), r.fn); err != nil || string(out) != "DOC" { + t.Errorf("cached rewrite = %q, %v", out, err) + } + 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"} { + _, _ = c.rewrite(context.Background(), keyFor(doc), []byte(doc), r.fn) + } + _, _ = c.rewrite(context.Background(), keyFor("aaa"), []byte("aaa"), r.fn) // aaa is now the most recent + _, _ = c.rewrite(context.Background(), keyFor("ccc"), []byte("ccc"), r.fn) // evicts bbb + + before := r.calls.Load() + _, _ = c.rewrite(context.Background(), keyFor("aaa"), []byte("aaa"), r.fn) + _, _ = c.rewrite(context.Background(), keyFor("ccc"), []byte("ccc"), r.fn) + if got := r.calls.Load() - before; got != 0 { + t.Errorf("aaa and ccc rewritten %d times, want both still cached", got) + } + _, _ = c.rewrite(context.Background(), keyFor("bbb"), []byte("bbb"), r.fn) + 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")) + + _, _ = c.rewrite(context.Background(), key, []byte("big"), r.fn) + 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 { + if _, err := c.rewrite(context.Background(), key, []byte("doc"), failing); err == nil { + 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 { + _, _ = proxy.cachedRewrite(context.Background(), "npm", "http://proxy", "left-pad", []byte("doc"), r.fn) + } + if got := r.calls.Load(); got != 2 { + t.Errorf("rewrites = %d, want 2", got) + } +} + +// TestCachedRewrite_CooldownBypassesCache asserts that with cooldown on every +// request is rewritten: cooldown filtering depends on the current time, so a +// cached rewrite could keep hiding a version after its cooldown ends. +func TestCachedRewrite_CooldownBypassesCache(t *testing.T) { + proxy, _, _, _ := setupTestProxy(t) + proxy.SetMetadataRewriteCacheSize(1 << 20) + proxy.Cooldown = &cooldown.Config{Default: "3d"} + var r countingRewrite + + for range 2 { + _, _ = proxy.cachedRewrite(context.Background(), "npm", "http://proxy", "left-pad", []byte("doc"), r.fn) + } + if got := r.calls.Load(); got != 2 { + t.Errorf("rewrites = %d, want 2", got) + } +} + +// 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) + } + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 1b82ce51..24839091 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -255,6 +255,7 @@ func (s *Server) serve(listener net.Listener) error { proxy.CacheMetadata = s.cfg.CacheMetadata proxy.MetadataTTL = s.cfg.ParseMetadataTTL() proxy.MetadataMaxSize = s.cfg.ParseMetadataMaxSize() + proxy.SetMetadataRewriteCacheSize(s.cfg.ParseMetadataRewriteCacheSize()) proxy.GradleReadOnly = s.cfg.Gradle.BuildCache.ReadOnly proxy.NPMFullMetadata = s.cfg.Upstream.NPMFullMetadata proxy.GradleMaxUploadSize = s.cfg.ParseGradleBuildCacheMaxUploadSize()