Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
42 changes: 42 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down
48 changes: 48 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
5 changes: 4 additions & 1 deletion internal/handler/composer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions internal/handler/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion internal/handler/npm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
160 changes: 160 additions & 0 deletions internal/handler/rewrite_cache.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading