From 4f680ddbd0b1381d29465aa3b7d0f0971607a589 Mon Sep 17 00:00:00 2001 From: simon chrzanowski Date: Thu, 8 Oct 2026 13:26:55 +0200 Subject: [PATCH 1/7] Send ETag and Last-Modified with rewritten npm and Composer metadata - Rewritten metadata went out with only Content-Type, so pnpm, npm and Composer could never revalidate and downloaded every document in full - ETag is the SHA-256 of the rewritten body, computed once per rewrite and kept with the rewrite cache entry; exact under cooldown as well - Last-Modified is the upstream Last-Modified, never earlier than the process start, since a restart can change the rewrite; omitted with cooldown on - Answer If-None-Match, and If-Modified-Since when there is no If-None-Match, with 304 on the rewrite fast path and on the fetch path --- internal/handler/composer.go | 10 +- internal/handler/metadata_validators_test.go | 253 +++++++++++++++++++ internal/handler/npm.go | 14 +- internal/handler/npm_rewrite_test.go | 2 +- internal/handler/rewrite_cache.go | 186 ++++++++++---- internal/handler/rewrite_cache_test.go | 32 +-- 6 files changed, 421 insertions(+), 76 deletions(-) create mode 100644 internal/handler/metadata_validators_test.go diff --git a/internal/handler/composer.go b/internal/handler/composer.go index f7b40ae8..e85ddc7c 100644 --- a/internal/handler/composer.go +++ b/internal/handler/composer.go @@ -128,9 +128,8 @@ func (h *ComposerHandler) handlePackageMetadata(w http.ResponseWriter, r *http.R repoURL, cacheKey := h.sourceFor(packageName) upstreamURL := fmt.Sprintf("%s/p2/%s/%s.json", repoURL, vendor, pkg) - if rewritten, ok := h.proxy.storedRewrite("composer", cacheKey, h.proxyURL, cacheKey); ok { - w.Header().Set(headerContentType, "application/json") - _, _ = w.Write(rewritten) + if stored, ok := h.proxy.storedRewrite("composer", cacheKey, h.proxyURL, cacheKey); ok { + serveRewrittenMetadata(w, r, stored) return } @@ -145,7 +144,7 @@ func (h *ComposerHandler) handlePackageMetadata(w http.ResponseWriter, r *http.R return } - rewritten, err := h.proxy.cachedRewrite(r.Context(), "composer", h.proxyURL, cacheKey, body, h.rewriteMetadata) + rewritten, etag, err := h.proxy.cachedRewrite(r.Context(), "composer", h.proxyURL, cacheKey, body, h.rewriteMetadata) if err != nil { if r.Context().Err() != nil { return // the client left while waiting on a shared rewrite @@ -156,8 +155,7 @@ func (h *ComposerHandler) handlePackageMetadata(w http.ResponseWriter, r *http.R return } - w.Header().Set(headerContentType, "application/json") - _, _ = w.Write(rewritten) + serveRewrittenMetadata(w, r, h.proxy.rewrittenMetadataFor("composer", packageName, rewritten, etag)) } // rewriteMetadata rewrites dist URLs in Composer metadata to point at this proxy. diff --git a/internal/handler/metadata_validators_test.go b/internal/handler/metadata_validators_test.go new file mode 100644 index 00000000..79f49cad --- /dev/null +++ b/internal/handler/metadata_validators_test.go @@ -0,0 +1,253 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/git-pkgs/cooldown" +) + +// validatorUpstream serves one npm packument and one Composer document whose +// bodies a test can change, with a fixed Last-Modified. +type validatorUpstream struct { + *httptest.Server + mu sync.Mutex + npm string + composer string + lastModified time.Time +} + +func newValidatorUpstream(t *testing.T) *validatorUpstream { + t.Helper() + u := &validatorUpstream{ + npm: `{"name":"left-pad","versions":{"1.3.0":{"dist":{"tarball":"https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz"}}}}`, + composer: `{"packages":{"vendor/pkg":[{"version":"1.0.0","dist":{"type":"zip","url":"https://example.com/pkg-1.0.0.zip"}}]}}`, + lastModified: time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC), + } + u.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u.mu.Lock() + defer u.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Last-Modified", u.lastModified.Format(http.TimeFormat)) + switch r.URL.Path { + case "/left-pad": + _, _ = w.Write([]byte(u.npm)) + case "/p2/vendor/pkg.json": + _, _ = w.Write([]byte(u.composer)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(u.Close) + return u +} + +func (u *validatorUpstream) set(npm, composer string) { + u.mu.Lock() + defer u.mu.Unlock() + u.npm, u.composer = npm, composer +} + +type validatorCase struct { + ecosystem, path string + handler func(*Proxy, *validatorUpstream) http.Handler +} + +var validatorCases = []validatorCase{ + {"npm", "/left-pad", func(p *Proxy, u *validatorUpstream) http.Handler { + return NewNPMHandler(p, "http://proxy.example", u.URL).Routes() + }}, + {"composer", "/p2/vendor/pkg.json", func(p *Proxy, u *validatorUpstream) http.Handler { + return NewComposerHandlerWithUpstreams(p, "http://proxy.example", u.URL, u.URL).Routes() + }}, +} + +func validatorProxy(t *testing.T, u *validatorUpstream, ttl time.Duration) *Proxy { + t.Helper() + proxy, _, _, _ := setupTestProxy(t) + proxy.HTTPClient = u.Client() + proxy.CacheMetadata = true + proxy.MetadataTTL = ttl + proxy.SetMetadataRewriteCacheSize(1 << 20) + return proxy +} + +func serve(handler http.Handler, path string, header http.Header) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, path, nil) + for k, v := range header { + req.Header[k] = v + } + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + return rec +} + +// TestRewrittenMetadataETag checks that rewritten metadata carries an ETag +// that clients can revalidate with, on the first fetch, on the rewrite fast +// path and when the TTL forces a revalidation. +func TestRewrittenMetadataETag(t *testing.T) { + for _, c := range validatorCases { + for _, ttl := range []time.Duration{time.Hour, 0} { + t.Run(c.ecosystem+"/ttl="+ttl.String(), func(t *testing.T) { + u := newValidatorUpstream(t) + handler := c.handler(validatorProxy(t, u, ttl), u) + + first := serve(handler, c.path, nil) + etag := first.Header().Get("ETag") + if first.Code != http.StatusOK || etag == "" { + t.Fatalf("first response: status %d, ETag %q; want 200 with an ETag", first.Code, etag) + } + for _, ifNoneMatch := range []string{etag, "W/" + etag, `"other", ` + etag} { + expectNotModified(t, serve(handler, c.path, http.Header{"If-None-Match": {ifNoneMatch}}), etag) + } + if rec := serve(handler, c.path, nil); rec.Header().Get("ETag") != etag || rec.Body.String() != first.Body.String() { + t.Errorf("repeat response: ETag %q, want %q, and the same body", rec.Header().Get("ETag"), etag) + } + }) + } + } +} + +func expectNotModified(t *testing.T, rec *httptest.ResponseRecorder, etag string) { + t.Helper() + if rec.Code != http.StatusNotModified || rec.Body.Len() != 0 { + t.Errorf("status %d with %d bytes, want an empty 304", rec.Code, rec.Body.Len()) + } + if got := rec.Header().Get("ETag"); got != etag { + t.Errorf("304 ETag = %q, want %q", got, etag) + } +} + +// TestRewrittenMetadataETagFollowsUpstream checks that a new upstream +// document gets a new ETag, so a client's old copy no longer matches. +func TestRewrittenMetadataETagFollowsUpstream(t *testing.T) { + for _, c := range validatorCases { + t.Run(c.ecosystem, func(t *testing.T) { + u := newValidatorUpstream(t) + handler := c.handler(validatorProxy(t, u, 0), u) + etag := serve(handler, c.path, nil).Header().Get("ETag") + + u.set( + `{"name":"left-pad","versions":{"1.3.1":{"dist":{"tarball":"https://registry.npmjs.org/left-pad/-/left-pad-1.3.1.tgz"}}}}`, + `{"packages":{"vendor/pkg":[{"version":"1.0.1","dist":{"type":"zip","url":"https://example.com/pkg-1.0.1.zip"}}]}}`, + ) + rec := serve(handler, c.path, http.Header{"If-None-Match": {etag}}) + if rec.Code != http.StatusOK || rec.Header().Get("ETag") == etag { + t.Errorf("status %d, ETag %q; want 200 with a new ETag", rec.Code, rec.Header().Get("ETag")) + } + }) + } +} + +// TestRewrittenMetadataLastModified checks the validator Composer revalidates +// with. It is the upstream Last-Modified, but never earlier than the start of +// this process: a restart can change the rewrite without upstream changing. +func TestRewrittenMetadataLastModified(t *testing.T) { + for _, c := range validatorCases { + for _, ttl := range []time.Duration{time.Hour, 0} { + t.Run(c.ecosystem+"/ttl="+ttl.String(), func(t *testing.T) { + u := newValidatorUpstream(t) + handler := c.handler(validatorProxy(t, u, ttl), u) + + first := serve(handler, c.path, nil) + lastModified, err := http.ParseTime(first.Header().Get("Last-Modified")) + if err != nil { + t.Fatalf("Last-Modified %q: %v", first.Header().Get("Last-Modified"), err) + } + if lastModified.Before(processStartedAt.Truncate(time.Second)) { + t.Errorf("Last-Modified %v is before the process started at %v", lastModified, processStartedAt) + } + + current := serve(handler, c.path, http.Header{"If-Modified-Since": {first.Header().Get("Last-Modified")}}) + if current.Code != http.StatusNotModified || current.Body.Len() != 0 { + t.Errorf("If-Modified-Since Last-Modified: status %d with %d bytes, want an empty 304", current.Code, current.Body.Len()) + } + + older := lastModified.Add(-time.Hour).Format(http.TimeFormat) + if rec := serve(handler, c.path, http.Header{"If-Modified-Since": {older}}); rec.Code != http.StatusOK { + t.Errorf("If-Modified-Since an hour earlier: status %d, want 200", rec.Code) + } + + // If-None-Match takes precedence over If-Modified-Since. + rec := serve(handler, c.path, http.Header{ + "If-None-Match": {`"other"`}, + "If-Modified-Since": {first.Header().Get("Last-Modified")}, + }) + if rec.Code != http.StatusOK { + t.Errorf("mismatched If-None-Match with a current If-Modified-Since: status %d, want 200", rec.Code) + } + }) + } + } +} + +// TestRewrittenMetadataValidatorsWithCooldown checks that with cooldown on, +// rewritten metadata keeps an exact ETag but has no Last-Modified: cooldown +// changes the rewrite as versions age in, while upstream stays the same. +func TestRewrittenMetadataValidatorsWithCooldown(t *testing.T) { + for _, c := range validatorCases { + t.Run(c.ecosystem, func(t *testing.T) { + u := newValidatorUpstream(t) + proxy := validatorProxy(t, u, time.Hour) + proxy.Cooldown = &cooldown.Config{Default: "3d"} + handler := c.handler(proxy, u) + + first := serve(handler, c.path, nil) + etag := first.Header().Get("ETag") + if etag == "" { + t.Fatal("no ETag with cooldown on") + } + if lm := first.Header().Get("Last-Modified"); lm != "" { + t.Errorf("Last-Modified = %q with cooldown on, want none", lm) + } + if rec := serve(handler, c.path, http.Header{"If-None-Match": {etag}}); rec.Code != http.StatusNotModified { + t.Errorf("If-None-Match: status %d, want 304", rec.Code) + } + future := time.Now().Add(time.Hour).Format(http.TimeFormat) + if rec := serve(handler, c.path, http.Header{"If-Modified-Since": {future}}); rec.Code != http.StatusOK { + t.Errorf("If-Modified-Since: status %d, want 200", rec.Code) + } + }) + } +} + +// TestUnrewrittenMetadataHasNoValidators checks that metadata proxied as it +// is, because rewriting it failed, does not claim the rewrite's validators. +func TestUnrewrittenMetadataHasNoValidators(t *testing.T) { + u := newValidatorUpstream(t) + u.set(`{"name":"left-pad","versions":`, `{"packages":`) + for _, c := range validatorCases { + t.Run(c.ecosystem, func(t *testing.T) { + rec := serve(c.handler(validatorProxy(t, u, time.Hour), u), c.path, nil) + if rec.Code != http.StatusOK { + t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) + } + if etag, lm := rec.Header().Get("ETag"), rec.Header().Get("Last-Modified"); etag != "" || lm != "" { + t.Errorf("ETag %q, Last-Modified %q; want neither on unrewritten metadata", etag, lm) + } + }) + } +} + +func TestRewriteCacheETag(t *testing.T) { + c := newRewriteCache(1 << 20) + exclaim := func(b []byte) ([]byte, error) { return []byte(string(b) + "!"), nil } + key := rewriteCacheKey("npm", "http://proxy", "demo", []byte("doc")) + + out, etag, err := c.rewrite(t.Context(), key, []byte("doc"), exclaim) + if err != nil || string(out) != "doc!" { + t.Fatalf("rewrite = %q, %v", out, err) + } + if want := metadataETag([]byte("doc!")); etag != want { + t.Errorf("rewrite ETag = %q, want %q", etag, want) + } + if _, cachedETag, ok := c.get(key); !ok || cachedETag != etag { + t.Errorf("get ETag = %q, %v; want %q", cachedETag, ok, etag) + } + if metadataETag([]byte("other")) == etag { + t.Error("different output has the same ETag") + } +} diff --git a/internal/handler/npm.go b/internal/handler/npm.go index 9ce667b2..b3d81eff 100644 --- a/internal/handler/npm.go +++ b/internal/handler/npm.go @@ -215,10 +215,8 @@ func (h *NPMHandler) handlePackageMetadata(w http.ResponseWriter, r *http.Reques accept = contentTypeJSON } - if rewritten, ok := h.proxy.storedRewrite("npm", metadataCacheKey, h.proxyURL, metadataCacheKey); ok { - w.Header().Set(headerContentType, contentTypeJSON) - w.WriteHeader(http.StatusOK) - _, _ = w.Write(rewritten) + if stored, ok := h.proxy.storedRewrite("npm", metadataCacheKey, h.proxyURL, metadataCacheKey); ok { + serveRewrittenMetadata(w, r, stored) return } @@ -243,7 +241,7 @@ func (h *NPMHandler) handlePackageMetadata(w http.ResponseWriter, r *http.Reques return } - rewritten, err := h.proxy.cachedRewrite(r.Context(), "npm", h.proxyURL, metadataCacheKey, body, func(b []byte) ([]byte, error) { + rewritten, etag, err := h.proxy.cachedRewrite(r.Context(), "npm", h.proxyURL, metadataCacheKey, body, func(b []byte) ([]byte, error) { return h.rewriteMetadata(packageName, b) }) if err != nil { @@ -270,9 +268,7 @@ func (h *NPMHandler) handlePackageMetadata(w http.ResponseWriter, r *http.Reques return } - w.Header().Set(headerContentType, contentTypeJSON) - w.WriteHeader(http.StatusOK) - _, _ = w.Write(rewritten) + serveRewrittenMetadata(w, r, h.proxy.rewrittenMetadataFor("npm", metadataCacheKey, rewritten, etag)) } // handleFilteredNPMVersionMetadata serves /pkg/ and /pkg/ from the @@ -290,7 +286,7 @@ func (h *NPMHandler) handleFilteredNPMVersionMetadata(w http.ResponseWriter, r * return } - rewritten, err := h.proxy.cachedRewrite(r.Context(), "npm", h.proxyURL, metadataCacheKey, packument, func(b []byte) ([]byte, error) { + rewritten, _, err := h.proxy.cachedRewrite(r.Context(), "npm", h.proxyURL, metadataCacheKey, packument, func(b []byte) ([]byte, error) { return h.rewriteNPMVersionFromPackument(packageName, specifier, b) }) if err != nil { diff --git a/internal/handler/npm_rewrite_test.go b/internal/handler/npm_rewrite_test.go index 6503ae1e..d9a787eb 100644 --- a/internal/handler/npm_rewrite_test.go +++ b/internal/handler/npm_rewrite_test.go @@ -247,7 +247,7 @@ func TestNPMStoredRewriteSkippedWhenStale(t *testing.T) { body := []byte(`{}`) proxy.cacheMetadataBlob(t.Context(), "npm", "demo", metadataStoragePath("npm", "demo"), &upstreamMetadata{body: body}) key := rewriteCacheKey("npm", "http://proxy", "demo", body) - if _, err := proxy.rewrites.rewrite(t.Context(), key, body, func(b []byte) ([]byte, error) { return b, nil }); err != nil { + if _, _, err := proxy.rewrites.rewrite(t.Context(), key, body, func(b []byte) ([]byte, error) { return b, nil }); err != nil { t.Fatal(err) } if _, ok := proxy.storedRewrite("npm", "demo", "http://proxy", "demo"); !ok { diff --git a/internal/handler/rewrite_cache.go b/internal/handler/rewrite_cache.go index 23096d68..63f74483 100644 --- a/internal/handler/rewrite_cache.go +++ b/internal/handler/rewrite_cache.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "encoding/hex" "errors" + "net/http" "strings" "sync" "time" @@ -26,6 +27,9 @@ import ( // 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. +// +// Each entry keeps the ETag of its output, so clients can revalidate their +// copy without the output being hashed again on every request. type rewriteCache struct { maxBytes int64 @@ -37,19 +41,40 @@ type rewriteCache struct { } type rewriteEntry struct { - key string - out []byte + key string + out []byte + etag string } -// 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 +// inflightRewrite is one rewrite that concurrent callers share. out, etag 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 + etag string err error } +// processStartedAt bounds the Last-Modified of rewritten metadata from below. +// A rewrite depends on configuration read at startup as well as on the +// upstream document, so a restart can change it while upstream stays the same. +var processStartedAt = time.Now() + +// rewrittenMetadata is a rewritten metadata document with the validators a +// client revalidates its copy with. lastModified is zero when unknown. +type rewrittenMetadata struct { + body []byte + etag string + lastModified time.Time +} + +// metadataETag returns the strong ETag of a rewritten document. +func metadataETag(out []byte) string { + sum := sha256.Sum256(out) + return `"` + hex.EncodeToString(sum[:]) + `"` +} + // 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") @@ -81,44 +106,46 @@ func rewriteCacheKeyForDigest(ecosystem, proxyURL, name, digest string) string { return strings.Join([]string{ecosystem, proxyURL, name, digest}, "\x00") } -// get returns the cached rewrite for key, if there is one. -func (c *rewriteCache) get(key string) ([]byte, bool) { +// get returns the cached rewrite for key and its ETag, if there is one. +func (c *rewriteCache) get(key string) ([]byte, string, bool) { if c == nil { - return nil, false + return nil, "", false } c.mu.Lock() defer c.mu.Unlock() el, ok := c.entries[key] if !ok { - return nil, false + return nil, "", false } c.order.MoveToFront(el) - return el.Value.(*rewriteEntry).out, true + e := el.Value.(*rewriteEntry) + return e.out, e.etag, true } -// 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) { +// rewrite returns rewrite(in) and its ETag, 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, string, error) { if c == nil { - return rewrite(in) + return rewriteWithETag(in, rewrite) } c.mu.Lock() if el, ok := c.entries[key]; ok { c.order.MoveToFront(el) - out := el.Value.(*rewriteEntry).out + e := el.Value.(*rewriteEntry) c.mu.Unlock() - return out, nil + return e.out, e.etag, nil } if f, ok := c.inFlight[key]; ok { c.mu.Unlock() select { case <-ctx.Done(): - return nil, ctx.Err() + return nil, "", ctx.Err() case <-f.done: - return f.out, f.err + return f.out, f.etag, f.err } } f := &inflightRewrite{done: make(chan struct{}), err: errSharedRewriteAbandoned} @@ -129,24 +156,33 @@ func (c *rewriteCache) rewrite(ctx context.Context, key string, in []byte, rewri c.mu.Lock() delete(c.inFlight, key) if f.err == nil { - c.add(key, f.out) + c.add(&rewriteEntry{key: key, out: f.out, etag: f.etag}) } c.mu.Unlock() close(f.done) }() - f.out, f.err = rewrite(in) - return f.out, f.err + f.out, f.etag, f.err = rewriteWithETag(in, rewrite) + return f.out, f.etag, 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)) +// rewriteWithETag runs rewrite and computes the ETag of what it returns. +func rewriteWithETag(in []byte, rewrite func([]byte) ([]byte, error)) ([]byte, string, error) { + out, err := rewrite(in) + if err != nil { + return nil, "", err + } + return out, metadataETag(out), nil +} + +// add stores e 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(e *rewriteEntry) { + n := int64(len(e.out)) if n > c.maxBytes { return } - c.entries[key] = c.order.PushFront(&rewriteEntry{key: key, out: out}) + c.entries[e.key] = c.order.PushFront(e) c.size += n for c.size > c.maxBytes { oldest := c.order.Back() @@ -168,41 +204,103 @@ func (c *rewriteCache) len() int { } // 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) +// cache and returns it with its ETag. 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, string, error) { + if p.rewrites == nil || p.cooldownEnabled() { + return rewriteWithETag(in, rewrite) } return p.rewrites.rewrite(ctx, rewriteCacheKey(ecosystem, proxyURL, name, in), in, rewrite) } +func (p *Proxy) cooldownEnabled() bool { + return p.Cooldown != nil && p.Cooldown.Enabled() +} + +// rewriteLastModified returns the Last-Modified of a rewrite of metadata that +// upstream last modified at upstream, or zero when there is none to give. +// With cooldown on there is none: versions leaving cooldown change the +// rewrite while upstream stays the same. +func (p *Proxy) rewriteLastModified(upstream time.Time) time.Time { + if upstream.IsZero() || p.cooldownEnabled() { + return time.Time{} + } + if upstream.Before(processStartedAt) { + return processStartedAt + } + return upstream +} + +// rewrittenMetadataFor returns a fresh rewrite of the metadata cached for +// ecosystem and cacheKey with its validators. +func (p *Proxy) rewrittenMetadataFor(ecosystem, cacheKey string, out []byte, etag string) rewrittenMetadata { + return rewrittenMetadata{ + body: out, + etag: etag, + lastModified: p.rewriteLastModified(p.lookupCachedMeta(ecosystem, cacheKey).lastModified), + } +} + // storedRewrite returns the cached rewrite of the metadata stored for // ecosystem and cacheKey, when that metadata is still within its TTL and a // rewrite of it is cached. It reads only the cache row, never the stored // document, so a repeated request for a large packument costs a database // lookup instead of reading and hashing the whole document again. When it // reports false the caller takes the usual fetch and rewrite path. -func (p *Proxy) storedRewrite(ecosystem, cacheKey, proxyURL, name string) ([]byte, bool) { - if p.rewrites == nil || (p.Cooldown != nil && p.Cooldown.Enabled()) { - return nil, false +func (p *Proxy) storedRewrite(ecosystem, cacheKey, proxyURL, name string) (rewrittenMetadata, bool) { + if p.rewrites == nil || p.cooldownEnabled() { + return rewrittenMetadata{}, false } if !p.CacheMetadata || p.DB == nil || p.MetadataTTL <= 0 { - return nil, false + return rewrittenMetadata{}, false } entry, err := p.DB.GetMetadataCache(ecosystem, cacheKey) if err != nil || entry == nil || !entry.ContentDigest.Valid || entry.ContentEncoding.String != "" { - return nil, false + return rewrittenMetadata{}, false } if !entry.FetchedAt.Valid || time.Since(entry.FetchedAt.Time) >= p.MetadataTTL { - return nil, false + return rewrittenMetadata{}, false + } + out, etag, ok := p.rewrites.get(rewriteCacheKeyForDigest(ecosystem, proxyURL, name, entry.ContentDigest.String)) + if !ok { + return rewrittenMetadata{}, false + } + metrics.RecordCacheHit(ecosystem) + var upstreamModified time.Time + if entry.LastModified.Valid { + upstreamModified = entry.LastModified.Time + } + return rewrittenMetadata{body: out, etag: etag, lastModified: p.rewriteLastModified(upstreamModified)}, true +} + +// serveRewrittenMetadata writes rewritten metadata with its validators, or +// 304 Not Modified when they show the client's copy is current. +func serveRewrittenMetadata(w http.ResponseWriter, r *http.Request, m rewrittenMetadata) { + w.Header().Set(headerETag, m.etag) + if !m.lastModified.IsZero() { + w.Header().Set(headerLastModified, m.lastModified.UTC().Format(http.TimeFormat)) + } + if notModified(r, m.etag, m.lastModified) { + w.WriteHeader(http.StatusNotModified) + return + } + w.Header().Set(headerContentType, contentTypeJSON) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(m.body) +} + +// notModified reports whether a conditional request's validators match. +// If-Modified-Since only counts when the request has no If-None-Match. +func notModified(r *http.Request, etag string, lastModified time.Time) bool { + if header := r.Header.Get("If-None-Match"); header != "" { + return ifNoneMatchHits(header, etag) } - out, ok := p.rewrites.get(rewriteCacheKeyForDigest(ecosystem, proxyURL, name, entry.ContentDigest.String)) - if ok { - metrics.RecordCacheHit(ecosystem) + if lastModified.IsZero() { + return false } - return out, ok + since, err := http.ParseTime(r.Header.Get("If-Modified-Since")) + return err == nil && !lastModified.Truncate(time.Second).After(since) } // SetMetadataRewriteCacheSize enables the cache of rewritten metadata with diff --git a/internal/handler/rewrite_cache_test.go b/internal/handler/rewrite_cache_test.go index 0edb94fc..1581ed75 100644 --- a/internal/handler/rewrite_cache_test.go +++ b/internal/handler/rewrite_cache_test.go @@ -37,7 +37,7 @@ func TestRewriteCache_RepeatServesCachedRewrite(t *testing.T) { key := rewriteCacheKey("npm", "http://proxy", "left-pad", []byte("doc")) for range 3 { - out, err := c.rewrite(context.Background(), key, []byte("doc"), r.fn) + out, _, err := c.rewrite(context.Background(), key, []byte("doc"), r.fn) if err != nil || string(out) != "DOC" { t.Fatalf("rewrite = %q, %v", out, err) } @@ -53,7 +53,7 @@ func TestRewriteCache_NewUpstreamBytesRewriteAgain(t *testing.T) { 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) + 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) } @@ -75,7 +75,7 @@ func TestRewriteCache_ConcurrentRequestsShareRewrite(t *testing.T) { outs := make(chan string, n) run := func() { - out, err := c.rewrite(context.Background(), key, []byte("doc"), r.fn) + out, _, err := c.rewrite(context.Background(), key, []byte("doc"), r.fn) if err != nil { t.Error(err) } @@ -108,20 +108,20 @@ func TestRewriteCache_WaiterLeavingKeepsRewrite(t *testing.T) { first := make(chan error, 1) go func() { - _, err := c.rewrite(context.Background(), key, []byte("doc"), r.fn) + _, _, 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) { + 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" { + 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 { @@ -135,18 +135,18 @@ func TestRewriteCache_EvictsLeastRecentlyUsed(t *testing.T) { 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(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 + _, _, _ = 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) + _, _, _ = 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) + _, _, _ = 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") } @@ -157,7 +157,7 @@ func TestRewriteCache_OutputLargerThanCacheIsNotStored(t *testing.T) { var r countingRewrite key := rewriteCacheKey("npm", "http://proxy", "big", []byte("big")) - _, _ = c.rewrite(context.Background(), key, []byte("big"), r.fn) + _, _, _ = c.rewrite(context.Background(), key, []byte("big"), r.fn) if c.len() != 0 { t.Errorf("cached %d entries, want 0", c.len()) } @@ -170,7 +170,7 @@ func TestRewriteCache_ErrorsAreNotCached(t *testing.T) { key := rewriteCacheKey("npm", "http://proxy", "broken", []byte("doc")) for range 2 { - if _, err := c.rewrite(context.Background(), key, []byte("doc"), failing); err == nil { + if _, _, err := c.rewrite(context.Background(), key, []byte("doc"), failing); err == nil { t.Fatal("expected the rewrite error") } } @@ -185,7 +185,7 @@ func TestCachedRewrite_DisabledRewritesEveryTime(t *testing.T) { var r countingRewrite for range 2 { - _, _ = proxy.cachedRewrite(context.Background(), "npm", "http://proxy", "left-pad", []byte("doc"), r.fn) + _, _, _ = 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) @@ -202,7 +202,7 @@ func TestCachedRewrite_CooldownBypassesCache(t *testing.T) { var r countingRewrite for range 2 { - _, _ = proxy.cachedRewrite(context.Background(), "npm", "http://proxy", "left-pad", []byte("doc"), r.fn) + _, _, _ = 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) From bf7d17d39a45edfd2fae3af6de5f992edded5924 Mon Sep 17 00:00:00 2001 From: simon chrzanowski Date: Fri, 9 Oct 2026 08:10:41 +0200 Subject: [PATCH 2/7] Derive cooldown metadata ETags from the rewrite's inputs - With cooldown on every rewrite runs, and hashing each rewritten body for its ETag made every npm metadata request about 20% more expensive - Hash what fixes the output instead: the upstream digest, proxy URL, package name, the versions cooldown and the denylist kept, and a per-process nonce standing for the startup configuration - Carry the stored document's digest and upstream Last-Modified with the metadata lookup result, so neither is read from a second cache row lookup that a concurrent refresh could have changed - Fall back to hashing the body when the digest is unknown, and keep the body hash with cooldown off, where it is computed once per cached rewrite --- internal/handler/composer.go | 34 ++++--- internal/handler/handler.go | 75 ++++++++++++---- internal/handler/metadata_validators_test.go | 93 ++++++++++++++++++++ internal/handler/npm.go | 42 +++++---- internal/handler/rewrite_cache.go | 67 +++++++++++--- internal/handler/rewrite_cache_test.go | 9 +- 6 files changed, 258 insertions(+), 62 deletions(-) diff --git a/internal/handler/composer.go b/internal/handler/composer.go index e85ddc7c..4c799534 100644 --- a/internal/handler/composer.go +++ b/internal/handler/composer.go @@ -133,8 +133,8 @@ func (h *ComposerHandler) handlePackageMetadata(w http.ResponseWriter, r *http.R return } - body, _, err := h.proxy.FetchOrCacheMetadata(r.Context(), "composer", cacheKey, upstreamURL) - if err != nil { + upstream := h.proxy.fetchMetadataDocument(r.Context(), "composer", cacheKey, upstreamURL, contentTypeJSON) + if err := upstream.err; err != nil { if errors.Is(err, ErrUpstreamNotFound) { http.Error(w, "not found", http.StatusNotFound) return @@ -144,18 +144,18 @@ func (h *ComposerHandler) handlePackageMetadata(w http.ResponseWriter, r *http.R return } - rewritten, etag, err := h.proxy.cachedRewrite(r.Context(), "composer", h.proxyURL, cacheKey, body, h.rewriteMetadata) + rewritten, etag, err := h.proxy.cachedRewrite(r.Context(), "composer", h.proxyURL, cacheKey, upstream.body, upstream.digest, h.rewriteMetadataKeeping) 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) + _, _ = w.Write(upstream.body) return } - serveRewrittenMetadata(w, r, h.proxy.rewrittenMetadataFor("composer", packageName, rewritten, etag)) + serveRewrittenMetadata(w, r, h.proxy.rewrittenMetadataFor(rewritten, etag, upstream.lastModified)) } // rewriteMetadata rewrites dist URLs in Composer metadata to point at this proxy. @@ -168,16 +168,24 @@ func (h *ComposerHandler) handlePackageMetadata(w http.ResponseWriter, r *http.R // of the original bytes: only dist values are written fresh, and minified // metadata stays minified. func (h *ComposerHandler) rewriteMetadata(body []byte) ([]byte, error) { + out, _, err := h.rewriteMetadataKeeping(body) + return out, err +} + +// rewriteMetadataKeeping is rewriteMetadata that also reports the versions +// it kept, each as its package name and version. +func (h *ComposerHandler) rewriteMetadataKeeping(body []byte) ([]byte, []string, error) { if !json.Valid(body) { - return nil, errors.New("composer metadata is not valid JSON") + return nil, nil, errors.New("composer metadata is not valid JSON") } format, _, err := lookupJSONString(body, "minified") if err != nil { - return nil, err + return nil, nil, err } minified := format == composerMinified var out bytes.Buffer + var kept []string out.Grow(len(body) + len(body)/8) err = rewriteJSONMembers(&out, body, func(out *bytes.Buffer, m jsonMember) (bool, error) { packages := m.value(body) @@ -193,24 +201,25 @@ func (h *ComposerHandler) rewriteMetadata(body []byte) ([]byte, error) { if err != nil { return false, err } - return true, h.writeVersions(out, packageName, versions, minified) + return true, h.writeVersions(out, packageName, versions, minified, &kept) }) }) if err != nil { - return nil, err + return nil, nil, err } - return out.Bytes(), nil + return out.Bytes(), kept, nil } // writeVersions writes one package's version list without the versions in -// cooldown and with each dist pointing at this proxy. +// cooldown and with each dist pointing at this proxy, and appends each version +// it writes to kept as its package name and version. // // Minified lists stay minified. Each version written carries the fields that // differ from what the client has expanded so far, which is the upstream // entry itself unless filtered versions were dropped before it; then it also // carries the changes those versions made. Every version gets its own dist, // since the proxied URL names the version. -func (h *ComposerHandler) writeVersions(out *bytes.Buffer, packageName string, versions []byte, minified bool) error { +func (h *ComposerHandler) writeVersions(out *bytes.Buffer, packageName string, versions []byte, minified bool, kept *[]string) error { packagePURL := canonicalPackagePURL("composer", packageName) upstream, written := newComposerFields(), newComposerFields() devReset, first := false, true @@ -252,6 +261,7 @@ func (h *ComposerHandler) writeVersions(out *bytes.Buffer, packageName string, v disableDefaultNotification(upstream) } h.writeVersion(out, packageName, version, upstream, written) + *kept = append(*kept, packageName+"\x00"+version) return nil }) out.WriteByte(']') diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 036e2a5f..6f7f7006 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -1086,24 +1086,35 @@ func (p *Proxy) FetchOrCacheMetadata(ctx context.Context, ecosystem, cacheKey, u // joined a fetch gets its own validation error directly. It must not modify the // body, which joined callers share. func (p *Proxy) fetchOrCacheMetadata(ctx context.Context, ecosystem, cacheKey, upstreamURL, acceptEncoding string, validate func([]byte) error, acceptHeaders ...string) ([]byte, string, string, error) { + accept := contentTypeJSON + if len(acceptHeaders) > 0 && acceptHeaders[0] != "" { + accept = acceptHeaders[0] + } + res := p.lookupMetadata(ctx, ecosystem, cacheKey, upstreamURL, accept, acceptEncoding, validate) + return res.body, res.contentType, res.contentEncoding, res.err +} + +// fetchMetadataDocument is FetchOrCacheMetadata for callers that rewrite the +// document and need to know which one they got: the result carries its +// digest and upstream Last-Modified along with the body. +func (p *Proxy) fetchMetadataDocument(ctx context.Context, ecosystem, cacheKey, upstreamURL, accept string) metadataResult { + return p.lookupMetadata(ctx, ecosystem, cacheKey, upstreamURL, accept, "", nil) +} + +// lookupMetadata implements fetchOrCacheMetadata and fetchMetadataDocument. +func (p *Proxy) lookupMetadata(ctx context.Context, ecosystem, cacheKey, upstreamURL, accept, acceptEncoding string, validate func([]byte) error) metadataResult { if containsPathTraversal(cacheKey) { - return nil, "", "", fmt.Errorf("invalid cache key: %q", cacheKey) + return metadataResult{err: fmt.Errorf("invalid cache key: %q", cacheKey)} } // Serve from cache if within TTL (skip upstream entirely) if _, hit := p.cachedMetadataState(ctx, ecosystem, cacheKey, validate); hit != nil { metrics.RecordCacheHit(ecosystem) - return hit.body, hit.contentType, hit.contentEncoding, nil + return *hit } p.recordMetadataCacheMiss(ecosystem) - accept := contentTypeJSON - if len(acceptHeaders) > 0 && acceptHeaders[0] != "" { - accept = acceptHeaders[0] - } - - res := p.coalescedMetadataMiss(ctx, ecosystem, cacheKey, upstreamURL, accept, acceptEncoding, validate) - return res.body, res.contentType, res.contentEncoding, res.err + return p.coalescedMetadataMiss(ctx, ecosystem, cacheKey, upstreamURL, accept, acceptEncoding, validate) } // coalescedMetadataMiss handles a metadata cache miss, sharing one upstream @@ -1144,7 +1155,8 @@ func (p *Proxy) cachedMetadataState(ctx context.Context, ecosystem, cacheKey str if entry != nil && p.MetadataTTL > 0 && entry.FetchedAt.Valid && time.Since(entry.FetchedAt.Time) < p.MetadataTTL { data, ct, err := p.readCachedMetadata(ctx, entry, validate) if err == nil { - return entry, &metadataResult{body: data, contentType: ct, contentEncoding: entry.ContentEncoding.String} + hit := metadataResult{body: data, contentType: ct, contentEncoding: entry.ContentEncoding.String}.describedBy(entry) + return entry, &hit } if validate != nil { return nil, nil @@ -1167,10 +1179,11 @@ func (p *Proxy) fetchMetadataFromUpstream(ctx context.Context, ecosystem, cacheK err = validate(meta.body) } if err == nil { + var digest string if p.CacheMetadata { - p.cacheMetadataBlob(ctx, ecosystem, cacheKey, metadataStoragePath(ecosystem, cacheKey), meta) + digest = p.cacheMetadataBlob(ctx, ecosystem, cacheKey, metadataStoragePath(ecosystem, cacheKey), meta) } - return metadataResult{body: meta.body, contentType: meta.contentType, contentEncoding: meta.contentEncoding} + return metadataResult{body: meta.body, contentType: meta.contentType, contentEncoding: meta.contentEncoding, digest: digest, lastModified: meta.lastModified} } // Upstream failed -- fall back to cache if available @@ -1193,7 +1206,7 @@ func (p *Proxy) fetchMetadataFromUpstream(ctx context.Context, ecosystem, cacheK p.Logger.Info("serving metadata from cache", "ecosystem", ecosystem, "key", cacheKey) - return metadataResult{body: data, contentType: ct, contentEncoding: entry.ContentEncoding.String} + return metadataResult{body: data, contentType: ct, contentEncoding: entry.ContentEncoding.String}.describedBy(entry) } // metadataResult is what a metadata lookup hands back. A shared fetch gives @@ -1202,7 +1215,23 @@ type metadataResult struct { body []byte contentType string contentEncoding string - err error + // digest identifies body when the metadata cache stored it, and is + // empty otherwise. lastModified is upstream's, zero when unknown. + digest string + lastModified time.Time + err error +} + +// describedBy returns res with the digest and Last-Modified that entry, the +// cache row its body was read for, records. +func (res metadataResult) describedBy(entry *database.MetadataCacheEntry) metadataResult { + if entry.ContentDigest.Valid { + res.digest = entry.ContentDigest.String + } + if entry.LastModified.Valid { + res.lastModified = entry.LastModified.Time + } + return res } // inflightMetadata is one metadata fetch that concurrent callers share. res is @@ -1385,16 +1414,22 @@ func (p *Proxy) fetchUpstreamMetadata(ctx context.Context, upstreamURL string, e return meta, nil } -// cacheMetadataBlob stores metadata bytes in storage and updates the database. -func (p *Proxy) cacheMetadataBlob(ctx context.Context, ecosystem, cacheKey, storagePath string, meta *upstreamMetadata) { +// cacheMetadataBlob stores metadata bytes in storage and updates the +// database. It returns the digest it recorded for the bytes, or "" when they +// were not cached. +func (p *Proxy) cacheMetadataBlob(ctx context.Context, ecosystem, cacheKey, storagePath string, meta *upstreamMetadata) string { if p.DB == nil || p.Storage == nil { - return + return "" } size, hash, err := p.Storage.Store(ctx, storagePath, bytes.NewReader(meta.body)) if err != nil { p.Logger.Warn("failed to cache metadata", "ecosystem", ecosystem, "key", cacheKey, "error", err) - return + return "" + } + var digest string + if hash != "" { + digest = "sha256:" + hash } err = p.DB.UpsertMetadataCache(&database.MetadataCacheEntry{ @@ -1406,7 +1441,7 @@ func (p *Proxy) cacheMetadataBlob(ctx context.Context, ecosystem, cacheKey, stor ContentEncoding: sql.NullString{String: meta.contentEncoding, Valid: meta.contentEncoding != ""}, // The digest identifies the stored bytes, so a rewrite cached for them // can be found without reading them back (see storedRewrite). - ContentDigest: sql.NullString{String: "sha256:" + hash, Valid: hash != ""}, + ContentDigest: sql.NullString{String: digest, Valid: digest != ""}, Size: sql.NullInt64{Int64: size, Valid: true}, LastModified: sql.NullTime{Time: meta.lastModified, Valid: !meta.lastModified.IsZero()}, FetchedAt: sql.NullTime{Time: time.Now(), Valid: true}, @@ -1420,7 +1455,9 @@ func (p *Proxy) cacheMetadataBlob(ctx context.Context, ecosystem, cacheKey, stor if delErr := p.Storage.Delete(ctx, storagePath); delErr != nil { p.Logger.Warn("failed to discard metadata blob", "ecosystem", ecosystem, "key", cacheKey, "error", delErr) } + return "" } + return digest } // currentMetadataEntry re-reads the metadata cache row and returns it, or diff --git a/internal/handler/metadata_validators_test.go b/internal/handler/metadata_validators_test.go index 79f49cad..00213e1c 100644 --- a/internal/handler/metadata_validators_test.go +++ b/internal/handler/metadata_validators_test.go @@ -1,8 +1,10 @@ package handler import ( + "fmt" "net/http" "net/http/httptest" + "strings" "sync" "testing" "time" @@ -251,3 +253,94 @@ func TestRewriteCacheETag(t *testing.T) { t.Error("different output has the same ETag") } } + +// cooldownUpstream sets documents with one version published long ago and +// one published two hours ago, so a cooldown of a day hides the recent one +// and a cooldown of an hour keeps it. +func cooldownUpstream(t *testing.T) *validatorUpstream { + t.Helper() + recent := time.Now().Add(-2 * time.Hour).UTC().Format(time.RFC3339) + u := newValidatorUpstream(t) + u.set( + `{"name":"left-pad","dist-tags":{"latest":"1.4.0"},"versions":{`+ + `"1.3.0":{"dist":{"tarball":"https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz"}},`+ + `"1.4.0":{"dist":{"tarball":"https://registry.npmjs.org/left-pad/-/left-pad-1.4.0.tgz"}}},`+ + `"time":{"1.3.0":"2016-01-01T00:00:00Z","1.4.0":"`+recent+`"}}`, + `{"packages":{"vendor/pkg":[`+ + `{"version":"1.0.0","time":"2016-01-01T00:00:00Z","dist":{"type":"zip","url":"https://example.com/pkg-1.0.0.zip"}},`+ + `{"version":"1.1.0","time":"`+recent+`","dist":{"type":"zip","url":"https://example.com/pkg-1.1.0.zip"}}]}}`, + ) + return u +} + +// TestRewrittenMetadataETagWithCooldownFollowsKeptVersions checks that with +// cooldown on, the ETag changes when a version leaves cooldown although the +// upstream document stays the same, and comes back when the same versions do. +func TestRewrittenMetadataETagWithCooldownFollowsKeptVersions(t *testing.T) { + for _, c := range validatorCases { + t.Run(c.ecosystem, func(t *testing.T) { + u := cooldownUpstream(t) + proxy := validatorProxy(t, u, time.Hour) + handler := c.handler(proxy, u) + + proxy.Cooldown = &cooldown.Config{Default: "1d"} + etag := serve(handler, c.path, nil).Header().Get("ETag") + + proxy.Cooldown = &cooldown.Config{Default: "1h"} + rec := serve(handler, c.path, http.Header{"If-None-Match": {etag}}) + if rec.Code != http.StatusOK || rec.Header().Get("ETag") == etag { + t.Errorf("after a version left cooldown: status %d, ETag %q; want 200 with a new ETag", rec.Code, rec.Header().Get("ETag")) + } + + proxy.Cooldown = &cooldown.Config{Default: "1d"} + expectNotModified(t, serve(handler, c.path, http.Header{"If-None-Match": {etag}}), etag) + }) + } +} + +// TestRewrittenMetadataETagWithCooldownComesFromInputs checks that with +// cooldown on, the ETag is derived from what fixes the rewrite rather than +// from the rewritten body, when the metadata cache knows the upstream +// digest, and falls back to the body when it does not. +func TestRewrittenMetadataETagWithCooldownComesFromInputs(t *testing.T) { + for _, c := range validatorCases { + for _, cacheMetadata := range []bool{true, false} { + t.Run(fmt.Sprintf("%s/cacheMetadata=%t", c.ecosystem, cacheMetadata), func(t *testing.T) { + u := cooldownUpstream(t) + proxy := validatorProxy(t, u, time.Hour) + proxy.CacheMetadata = cacheMetadata + proxy.Cooldown = &cooldown.Config{Default: "1d"} + handler := c.handler(proxy, u) + + rec := serve(handler, c.path, nil) + bodyETag := metadataETag(rec.Body.Bytes()) + if got := rec.Header().Get("ETag"); (got == bodyETag) == cacheMetadata { + t.Errorf("ETag %q, body hash %q; want them to differ only with a known digest", got, bodyETag) + } + etag := rec.Header().Get("ETag") + expectNotModified(t, serve(handler, c.path, http.Header{"If-None-Match": {etag}}), etag) + }) + } + } +} + +func TestKeptVersionsETag(t *testing.T) { + base := keptVersionsETag("sha256:abc", "http://proxy", "left-pad", []string{"1.0.0", "1.1.0"}) + if got := keptVersionsETag("sha256:abc", "http://proxy", "left-pad", []string{"1.1.0", "1.0.0"}); got != base { + t.Errorf("kept versions in another order: ETag %q, want %q", got, base) + } + for name, etag := range map[string]string{ + "digest": keptVersionsETag("sha256:abd", "http://proxy", "left-pad", []string{"1.0.0", "1.1.0"}), + "proxy URL": keptVersionsETag("sha256:abc", "http://other", "left-pad", []string{"1.0.0", "1.1.0"}), + "name": keptVersionsETag("sha256:abc", "http://proxy", "right-pad", []string{"1.0.0", "1.1.0"}), + "kept versions": keptVersionsETag("sha256:abc", "http://proxy", "left-pad", []string{"1.0.0"}), + "field bounds": keptVersionsETag("sha256:abc", "http://proxy", "left-pad", []string{"1.0.0\x001.1.0"}), + } { + if etag == base { + t.Errorf("a different %s gives the same ETag", name) + } + } + if !strings.HasPrefix(base, `"`) || !strings.HasSuffix(base, `"`) { + t.Errorf("ETag %q is not a quoted strong validator", base) + } +} diff --git a/internal/handler/npm.go b/internal/handler/npm.go index b3d81eff..f819a6e5 100644 --- a/internal/handler/npm.go +++ b/internal/handler/npm.go @@ -10,6 +10,7 @@ import ( "net/http" "net/url" "reflect" + "slices" "sort" "strings" "sync" @@ -226,8 +227,8 @@ func (h *NPMHandler) handlePackageMetadata(w http.ResponseWriter, r *http.Reques return } - body, _, err := h.proxy.FetchOrCacheMetadata(r.Context(), "npm", metadataCacheKey, upstreamURL, accept) - if err != nil { + upstream := h.proxy.fetchMetadataDocument(r.Context(), "npm", metadataCacheKey, upstreamURL, accept) + if err := upstream.err; err != nil { if errors.Is(err, ErrUpstreamNotFound) { if versionLookup { JSONError(w, http.StatusNotFound, ErrVersionNotFound.Error()) @@ -241,8 +242,8 @@ func (h *NPMHandler) handlePackageMetadata(w http.ResponseWriter, r *http.Reques return } - rewritten, etag, err := h.proxy.cachedRewrite(r.Context(), "npm", h.proxyURL, metadataCacheKey, body, func(b []byte) ([]byte, error) { - return h.rewriteMetadata(packageName, b) + rewritten, etag, err := h.proxy.cachedRewrite(r.Context(), "npm", h.proxyURL, metadataCacheKey, upstream.body, upstream.digest, func(b []byte) ([]byte, []string, error) { + return h.rewriteMetadataKeeping(packageName, b) }) if err != nil { if r.Context().Err() != nil { @@ -264,11 +265,11 @@ func (h *NPMHandler) handlePackageMetadata(w http.ResponseWriter, r *http.Reques h.proxy.Logger.Warn("failed to rewrite metadata, proxying original", "error", err) w.Header().Set(headerContentType, contentTypeJSON) w.WriteHeader(http.StatusOK) - _, _ = w.Write(body) + _, _ = w.Write(upstream.body) return } - serveRewrittenMetadata(w, r, h.proxy.rewrittenMetadataFor("npm", metadataCacheKey, rewritten, etag)) + serveRewrittenMetadata(w, r, h.proxy.rewrittenMetadataFor(rewritten, etag, upstream.lastModified)) } // handleFilteredNPMVersionMetadata serves /pkg/ and /pkg/ from the @@ -286,8 +287,9 @@ func (h *NPMHandler) handleFilteredNPMVersionMetadata(w http.ResponseWriter, r * return } - rewritten, _, err := h.proxy.cachedRewrite(r.Context(), "npm", h.proxyURL, metadataCacheKey, packument, func(b []byte) ([]byte, error) { - return h.rewriteNPMVersionFromPackument(packageName, specifier, b) + rewritten, _, err := h.proxy.cachedRewrite(r.Context(), "npm", h.proxyURL, metadataCacheKey, packument, "", func(b []byte) ([]byte, []string, error) { + out, err := h.rewriteNPMVersionFromPackument(packageName, specifier, b) + return out, nil, err }) if err != nil { if r.Context().Err() != nil { @@ -388,29 +390,36 @@ func (h *NPMHandler) rewriteNPMVersionFromPackument(packageName, specifier strin // map and dist-tags when filtering changed them, are written fresh. Everything // else reaches the client byte for byte as upstream sent it. func (h *NPMHandler) rewriteMetadata(packageName string, body []byte) ([]byte, error) { + out, _, err := h.rewriteMetadataKeeping(packageName, body) + return out, err +} + +// rewriteMetadataKeeping is rewriteMetadata that also reports the versions it +// kept. +func (h *NPMHandler) rewriteMetadataKeeping(packageName string, body []byte) ([]byte, []string, error) { doc, err := indexNPMPackument(body) if err != nil { - return nil, err + return nil, nil, err } versionsRaw := doc.value(doc.versionsAt) if len(versionsRaw) == 0 || versionsRaw[0] != '{' { version, ok, err := lookupJSONString(body, "version") if err != nil { - return nil, err + return nil, nil, err } if ok { if h.proxy.versionDenied("npm", packageName, version) { - return nil, ErrVersionDenied + return nil, nil, ErrVersionDenied } var out bytes.Buffer h.writeNPMVersion(&out, packageName, version, body) - return out.Bytes(), nil + return out.Bytes(), []string{version}, nil } if len(h.proxy.Denylist.Versions(canonicalPackagePURL("npm", packageName))) != 0 { - return nil, errors.New("npm metadata has no versions object") + return nil, nil, errors.New("npm metadata has no versions object") } - return body, nil // No versions to rewrite + return body, nil, nil // No versions to rewrite } // The filters only look at which versions exist, so they run on a map of @@ -421,7 +430,7 @@ func (h *NPMHandler) rewriteMetadata(packageName string, body []byte) ([]byte, e versions[version] = nil return err }); err != nil { - return nil, err + return nil, nil, err } metadata := map[string]any{} timeMap := doc.decodeObject(doc.timeAt, metadata, "time") @@ -434,7 +443,7 @@ func (h *NPMHandler) rewriteMetadata(packageName string, body []byte) ([]byte, e // Untouched members are copied as they are; the three the filters // handled are written from their filtered state, and only when it changed. - return doc.write(func(out *bytes.Buffer, i int) error { + out, err := doc.write(func(out *bytes.Buffer, i int) error { switch { case i == doc.versionsAt: return h.writeNPMVersions(out, packageName, versionsRaw, versions) @@ -452,6 +461,7 @@ func (h *NPMHandler) rewriteMetadata(packageName string, body []byte) ([]byte, e return nil } }) + return out, slices.Collect(maps.Keys(versions)), err } // npmPackument is a packument's top-level members as offsets into its bytes, diff --git a/internal/handler/rewrite_cache.go b/internal/handler/rewrite_cache.go index 63f74483..853e49a7 100644 --- a/internal/handler/rewrite_cache.go +++ b/internal/handler/rewrite_cache.go @@ -3,10 +3,14 @@ package handler import ( "container/list" "context" + "crypto/rand" "crypto/sha256" + "encoding/binary" "encoding/hex" "errors" + "io" "net/http" + "slices" "strings" "sync" "time" @@ -75,6 +79,33 @@ func metadataETag(out []byte) string { return `"` + hex.EncodeToString(sum[:]) + `"` } +// rewriteConfigNonce stands for the configuration a rewrite reads at startup, +// such as the denylist and cooldown rules, in ETags derived from a rewrite's +// inputs. It changes on every start, so no ETag outlives the configuration it +// was derived under. +var rewriteConfigNonce = rand.Text() + +// keptVersionsETag returns the strong ETag of a rewrite from what fixes its +// output: the upstream document's digest, the proxy URL and package name its +// download URLs carry, the versions cooldown and the denylist kept, and the +// startup configuration. Hashing these costs far less than hashing the +// rewritten document. Each field is length-prefixed, so different inputs never +// hash the same bytes. +func keptVersionsETag(digest, proxyURL, name string, kept []string) string { + h := sha256.New() + var length []byte + for _, field := range append([]string{rewriteConfigNonce, digest, proxyURL, name}, slices.Sorted(slices.Values(kept))...) { + length = binary.AppendUvarint(length[:0], uint64(len(field))) + _, _ = h.Write(length) + _, _ = io.WriteString(h, field) + } + return `"` + hex.EncodeToString(h.Sum(nil)) + `"` +} + +// keepingRewrite rewrites a metadata document and reports the versions it +// kept. Together with the document they fix the rewrite's output. +type keepingRewrite func([]byte) ([]byte, []string, error) + // 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") @@ -204,14 +235,28 @@ func (c *rewriteCache) len() int { } // cachedRewrite rewrites a metadata document through the proxy's rewrite -// cache and returns it with its ETag. Cooldown filtering depends on the +// cache and returns it with its ETag. digest identifies in when the metadata +// cache stored it, and is empty otherwise. 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, string, error) { +// version past its cooldown; those rewrites always run, and when digest is +// known their ETag comes from their inputs rather than from hashing each +// output again. +func (p *Proxy) cachedRewrite(ctx context.Context, ecosystem, proxyURL, name string, in []byte, digest string, rewrite keepingRewrite) ([]byte, string, error) { + if p.cooldownEnabled() && digest != "" { + out, kept, err := rewrite(in) + if err != nil { + return nil, "", err + } + return out, keptVersionsETag(digest, proxyURL, name, kept), nil + } + rewriteOnly := func(b []byte) ([]byte, error) { + out, _, err := rewrite(b) + return out, err + } if p.rewrites == nil || p.cooldownEnabled() { - return rewriteWithETag(in, rewrite) + return rewriteWithETag(in, rewriteOnly) } - return p.rewrites.rewrite(ctx, rewriteCacheKey(ecosystem, proxyURL, name, in), in, rewrite) + return p.rewrites.rewrite(ctx, rewriteCacheKey(ecosystem, proxyURL, name, in), in, rewriteOnly) } func (p *Proxy) cooldownEnabled() bool { @@ -232,14 +277,10 @@ func (p *Proxy) rewriteLastModified(upstream time.Time) time.Time { return upstream } -// rewrittenMetadataFor returns a fresh rewrite of the metadata cached for -// ecosystem and cacheKey with its validators. -func (p *Proxy) rewrittenMetadataFor(ecosystem, cacheKey string, out []byte, etag string) rewrittenMetadata { - return rewrittenMetadata{ - body: out, - etag: etag, - lastModified: p.rewriteLastModified(p.lookupCachedMeta(ecosystem, cacheKey).lastModified), - } +// rewrittenMetadataFor returns a fresh rewrite of metadata that upstream last +// modified at upstreamModified, with its validators. +func (p *Proxy) rewrittenMetadataFor(out []byte, etag string, upstreamModified time.Time) rewrittenMetadata { + return rewrittenMetadata{body: out, etag: etag, lastModified: p.rewriteLastModified(upstreamModified)} } // storedRewrite returns the cached rewrite of the metadata stored for diff --git a/internal/handler/rewrite_cache_test.go b/internal/handler/rewrite_cache_test.go index 1581ed75..733ba31f 100644 --- a/internal/handler/rewrite_cache_test.go +++ b/internal/handler/rewrite_cache_test.go @@ -31,6 +31,11 @@ func (r *countingRewrite) fn(in []byte) ([]byte, error) { return []byte(strings.ToUpper(string(in))), nil } +func (r *countingRewrite) keeping(in []byte) ([]byte, []string, error) { + out, err := r.fn(in) + return out, nil, err +} + func TestRewriteCache_RepeatServesCachedRewrite(t *testing.T) { c := newRewriteCache(1 << 20) var r countingRewrite @@ -185,7 +190,7 @@ func TestCachedRewrite_DisabledRewritesEveryTime(t *testing.T) { var r countingRewrite for range 2 { - _, _, _ = proxy.cachedRewrite(context.Background(), "npm", "http://proxy", "left-pad", []byte("doc"), r.fn) + _, _, _ = proxy.cachedRewrite(context.Background(), "npm", "http://proxy", "left-pad", []byte("doc"), "", r.keeping) } if got := r.calls.Load(); got != 2 { t.Errorf("rewrites = %d, want 2", got) @@ -202,7 +207,7 @@ func TestCachedRewrite_CooldownBypassesCache(t *testing.T) { var r countingRewrite for range 2 { - _, _, _ = proxy.cachedRewrite(context.Background(), "npm", "http://proxy", "left-pad", []byte("doc"), r.fn) + _, _, _ = proxy.cachedRewrite(context.Background(), "npm", "http://proxy", "left-pad", []byte("doc"), "", r.keeping) } if got := r.calls.Load(); got != 2 { t.Errorf("rewrites = %d, want 2", got) From 8903a8c9c98003badb932de32a1f492a3a42ac90 Mon Sep 17 00:00:00 2001 From: Simon Chrzanowski Date: Sat, 10 Oct 2026 13:47:39 +0200 Subject: [PATCH 3/7] Date rewritten metadata by when the rewrite was made Last-Modified was upstream's date clamped to the process start, so different documents could share a date: after a restart, a cached document and the newer one a refresh fetched were both dated at startup, and a client holding the first got 304 for the second. - Date each rewrite by when the rewrite cache first made it, and keep that date while the entry stays cached - Give every new rewrite of a package a date later than the last one it got, even within the same second or after its entry was evicted, so no two representations share a date; dates are remembered only until the clock passes them - Send no Last-Modified when the rewrite cache is off, since nothing records when an uncached rewrite was made; cooldown still sends none - Drop the upstream Last-Modified from metadata lookups, which nothing uses now --- internal/handler/composer.go | 4 +- internal/handler/handler.go | 18 +- internal/handler/metadata_validators_test.go | 178 +++++++++++++++++-- internal/handler/npm.go | 8 +- internal/handler/rewrite_cache.go | 158 ++++++++-------- internal/handler/rewrite_cache_test.go | 4 +- 6 files changed, 265 insertions(+), 105 deletions(-) diff --git a/internal/handler/composer.go b/internal/handler/composer.go index 4c799534..b3480240 100644 --- a/internal/handler/composer.go +++ b/internal/handler/composer.go @@ -144,7 +144,7 @@ func (h *ComposerHandler) handlePackageMetadata(w http.ResponseWriter, r *http.R return } - rewritten, etag, err := h.proxy.cachedRewrite(r.Context(), "composer", h.proxyURL, cacheKey, upstream.body, upstream.digest, h.rewriteMetadataKeeping) + rewritten, err := h.proxy.cachedRewrite(r.Context(), "composer", h.proxyURL, cacheKey, upstream.body, upstream.digest, h.rewriteMetadataKeeping) if err != nil { if r.Context().Err() != nil { return // the client left while waiting on a shared rewrite @@ -155,7 +155,7 @@ func (h *ComposerHandler) handlePackageMetadata(w http.ResponseWriter, r *http.R return } - serveRewrittenMetadata(w, r, h.proxy.rewrittenMetadataFor(rewritten, etag, upstream.lastModified)) + serveRewrittenMetadata(w, r, rewritten) } // rewriteMetadata rewrites dist URLs in Composer metadata to point at this proxy. diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 6f7f7006..56d4cdd9 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -1096,7 +1096,7 @@ func (p *Proxy) fetchOrCacheMetadata(ctx context.Context, ecosystem, cacheKey, u // fetchMetadataDocument is FetchOrCacheMetadata for callers that rewrite the // document and need to know which one they got: the result carries its -// digest and upstream Last-Modified along with the body. +// digest along with the body. func (p *Proxy) fetchMetadataDocument(ctx context.Context, ecosystem, cacheKey, upstreamURL, accept string) metadataResult { return p.lookupMetadata(ctx, ecosystem, cacheKey, upstreamURL, accept, "", nil) } @@ -1183,7 +1183,7 @@ func (p *Proxy) fetchMetadataFromUpstream(ctx context.Context, ecosystem, cacheK if p.CacheMetadata { digest = p.cacheMetadataBlob(ctx, ecosystem, cacheKey, metadataStoragePath(ecosystem, cacheKey), meta) } - return metadataResult{body: meta.body, contentType: meta.contentType, contentEncoding: meta.contentEncoding, digest: digest, lastModified: meta.lastModified} + return metadataResult{body: meta.body, contentType: meta.contentType, contentEncoding: meta.contentEncoding, digest: digest} } // Upstream failed -- fall back to cache if available @@ -1216,21 +1216,17 @@ type metadataResult struct { contentType string contentEncoding string // digest identifies body when the metadata cache stored it, and is - // empty otherwise. lastModified is upstream's, zero when unknown. - digest string - lastModified time.Time - err error + // empty otherwise. + digest string + err error } -// describedBy returns res with the digest and Last-Modified that entry, the -// cache row its body was read for, records. +// describedBy returns res with the digest that entry, the cache row its body +// was read for, records. func (res metadataResult) describedBy(entry *database.MetadataCacheEntry) metadataResult { if entry.ContentDigest.Valid { res.digest = entry.ContentDigest.String } - if entry.LastModified.Valid { - res.lastModified = entry.LastModified.Time - } return res } diff --git a/internal/handler/metadata_validators_test.go b/internal/handler/metadata_validators_test.go index 00213e1c..5db4308e 100644 --- a/internal/handler/metadata_validators_test.go +++ b/internal/handler/metadata_validators_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/git-pkgs/cooldown" + "github.com/git-pkgs/proxy/internal/database" ) // validatorUpstream serves one npm packument and one Composer document whose @@ -53,28 +54,54 @@ func (u *validatorUpstream) set(npm, composer string) { u.npm, u.composer = npm, composer } +func (u *validatorUpstream) setLastModified(lastModified time.Time) { + u.mu.Lock() + defer u.mu.Unlock() + u.lastModified = lastModified +} + type validatorCase struct { - ecosystem, path string - handler func(*Proxy, *validatorUpstream) http.Handler + ecosystem, name, path string + handler func(*Proxy, *validatorUpstream) http.Handler } var validatorCases = []validatorCase{ - {"npm", "/left-pad", func(p *Proxy, u *validatorUpstream) http.Handler { + {"npm", "left-pad", "/left-pad", func(p *Proxy, u *validatorUpstream) http.Handler { return NewNPMHandler(p, "http://proxy.example", u.URL).Routes() }}, - {"composer", "/p2/vendor/pkg.json", func(p *Proxy, u *validatorUpstream) http.Handler { + {"composer", "vendor/pkg", "/p2/vendor/pkg.json", func(p *Proxy, u *validatorUpstream) http.Handler { return NewComposerHandlerWithUpstreams(p, "http://proxy.example", u.URL, u.URL).Routes() }}, } func validatorProxy(t *testing.T, u *validatorUpstream, ttl time.Duration) *Proxy { t.Helper() - proxy, _, _, _ := setupTestProxy(t) + proxy, _ := validatorProxyWithDB(t, u, ttl) + return proxy +} + +func validatorProxyWithDB(t *testing.T, u *validatorUpstream, ttl time.Duration) (*Proxy, *database.DB) { + t.Helper() + proxy, db, _, _ := setupTestProxy(t) proxy.HTTPClient = u.Client() proxy.CacheMetadata = true proxy.MetadataTTL = ttl proxy.SetMetadataRewriteCacheSize(1 << 20) - return proxy + return proxy, db +} + +// expireMetadata ages a metadata cache row past any TTL, so the next request +// refreshes it from upstream. +func expireMetadata(t *testing.T, db *database.DB, ecosystem, name string) { + t.Helper() + entry, err := db.GetMetadataCache(ecosystem, name) + if err != nil || entry == nil { + t.Fatalf("metadata row %s/%s = %v, %v", ecosystem, name, entry, err) + } + entry.FetchedAt.Time = time.Now().Add(-48 * time.Hour) + if err := db.UpsertMetadataCache(entry); err != nil { + t.Fatal(err) + } } func serve(handler http.Handler, path string, header http.Header) *httptest.ResponseRecorder { @@ -145,8 +172,9 @@ func TestRewrittenMetadataETagFollowsUpstream(t *testing.T) { } // TestRewrittenMetadataLastModified checks the validator Composer revalidates -// with. It is the upstream Last-Modified, but never earlier than the start of -// this process: a restart can change the rewrite without upstream changing. +// with. It is when this process made the rewrite, not upstream's date: the +// rewrite also depends on configuration read at startup, so the same upstream +// document can be rewritten differently after a restart. func TestRewrittenMetadataLastModified(t *testing.T) { for _, c := range validatorCases { for _, ttl := range []time.Duration{time.Hour, 0} { @@ -154,13 +182,14 @@ func TestRewrittenMetadataLastModified(t *testing.T) { u := newValidatorUpstream(t) handler := c.handler(validatorProxy(t, u, ttl), u) + before := time.Now().Truncate(time.Second) first := serve(handler, c.path, nil) lastModified, err := http.ParseTime(first.Header().Get("Last-Modified")) if err != nil { t.Fatalf("Last-Modified %q: %v", first.Header().Get("Last-Modified"), err) } - if lastModified.Before(processStartedAt.Truncate(time.Second)) { - t.Errorf("Last-Modified %v is before the process started at %v", lastModified, processStartedAt) + if lastModified.Before(before) || lastModified.After(time.Now()) { + t.Errorf("Last-Modified %v, want the time of the rewrite, at or after %v", lastModified, before) } current := serve(handler, c.path, http.Header{"If-Modified-Since": {first.Header().Get("Last-Modified")}}) @@ -186,6 +215,50 @@ func TestRewrittenMetadataLastModified(t *testing.T) { } } +// TestRewrittenMetadataLastModifiedFollowsRefresh is a regression test for a +// false 304. Upstream dates both documents before this process started, the +// cached one and the newer one a refresh fetches, and neither may give the +// newer document a Last-Modified a client holding the older one already has: +// If-Modified-Since from the first response must fetch the new body. +func TestRewrittenMetadataLastModifiedFollowsRefresh(t *testing.T) { + for _, c := range validatorCases { + for _, ttl := range []time.Duration{time.Hour, 0} { + t.Run(c.ecosystem+"/ttl="+ttl.String(), func(t *testing.T) { + u := newValidatorUpstream(t) + proxy, db := validatorProxyWithDB(t, u, ttl) + handler := c.handler(proxy, u) + + first := serve(handler, c.path, nil) + firstModified := first.Header().Get("Last-Modified") + if first.Code != http.StatusOK || firstModified == "" { + t.Fatalf("first response: status %d, Last-Modified %q; want 200 with a Last-Modified", first.Code, firstModified) + } + + u.set( + `{"name":"left-pad","versions":{"1.3.1":{"dist":{"tarball":"https://registry.npmjs.org/left-pad/-/left-pad-1.3.1.tgz"}}}}`, + `{"packages":{"vendor/pkg":[{"version":"1.0.1","dist":{"type":"zip","url":"https://example.com/pkg-1.0.1.zip"}}]}}`, + ) + u.setLastModified(u.lastModified.Add(24 * time.Hour)) + expireMetadata(t, db, c.ecosystem, c.name) + + rec := serve(handler, c.path, http.Header{"If-Modified-Since": {firstModified}}) + if rec.Code != http.StatusOK || rec.Body.String() == first.Body.String() { + t.Fatalf("If-Modified-Since from before the refresh: status %d, body changed %t; want 200 with the new body", + rec.Code, rec.Body.String() != first.Body.String()) + } + was, _ := http.ParseTime(firstModified) + now, err := http.ParseTime(rec.Header().Get("Last-Modified")) + if err != nil || !now.After(was) { + t.Errorf("Last-Modified after the refresh = %q, want later than %q", rec.Header().Get("Last-Modified"), firstModified) + } + if rec := serve(handler, c.path, http.Header{"If-Modified-Since": {rec.Header().Get("Last-Modified")}}); rec.Code != http.StatusNotModified { + t.Errorf("If-Modified-Since the new Last-Modified: status %d, want 304", rec.Code) + } + }) + } + } +} + // TestRewrittenMetadataValidatorsWithCooldown checks that with cooldown on, // rewritten metadata keeps an exact ETag but has no Last-Modified: cooldown // changes the rewrite as versions age in, while upstream stays the same. @@ -243,17 +316,92 @@ func TestRewriteCacheETag(t *testing.T) { if err != nil || string(out) != "doc!" { t.Fatalf("rewrite = %q, %v", out, err) } - if want := metadataETag([]byte("doc!")); etag != want { - t.Errorf("rewrite ETag = %q, want %q", etag, want) + if want := metadataETag([]byte("doc!")); etag.etag != want { + t.Errorf("rewrite ETag = %q, want %q", etag.etag, want) } - if _, cachedETag, ok := c.get(key); !ok || cachedETag != etag { - t.Errorf("get ETag = %q, %v; want %q", cachedETag, ok, etag) + if _, cached, ok := c.get(key); !ok || cached != etag { + t.Errorf("get validators = %+v, %v; want %+v", cached, ok, etag) } - if metadataETag([]byte("other")) == etag { + if metadataETag([]byte("other")) == etag.etag { t.Error("different output has the same ETag") } } +// TestRewriteCacheLastModified checks the Last-Modified the rewrite cache +// gives each rewrite: the second it was made, kept while it stays cached, and +// always later than any earlier rewrite of the same package, so that a client +// holding one representation never gets 304 for another. +func TestRewriteCacheLastModified(t *testing.T) { + c := newRewriteCache(8) + clock := time.Date(2026, 10, 10, 12, 0, 0, 400_000_000, time.UTC) + c.now = func() time.Time { return clock } + second := clock.Truncate(time.Second) + identity := func(b []byte) ([]byte, error) { return b, nil } + rewrite := func(name, doc string) time.Time { + t.Helper() + _, v, err := c.rewrite(t.Context(), rewriteCacheKey("npm", "http://proxy", name, []byte(doc)), []byte(doc), identity) + if err != nil { + t.Fatal(err) + } + return v.lastModified + } + + if got := rewrite("demo", "a"); !got.Equal(second) { + t.Errorf("first rewrite: Last-Modified %v, want %v", got, second) + } + clock = clock.Add(300 * time.Millisecond) + if got := rewrite("demo", "a"); !got.Equal(second) { + t.Errorf("cached rewrite: Last-Modified %v, want %v as before", got, second) + } + + // A new document in the same second must not share the date. + b := rewrite("demo", "b") + if !b.After(second) { + t.Errorf("new document in the same second: Last-Modified %v, want later than %v", b, second) + } + // Another package is not held back by it. + if got := rewrite("other", "x"); !got.Equal(second) { + t.Errorf("other package: Last-Modified %v, want %v", got, second) + } + + // Evicting what came before does not let a later rewrite reuse its date. + for _, doc := range []string{"ccc", "ddd", "eee"} { + rewrite("filler", doc) + } + if _, _, ok := c.get(rewriteCacheKey("npm", "http://proxy", "demo", []byte("b"))); ok { + t.Fatal("expected b to be evicted") + } + if got := rewrite("demo", "b"); !got.After(b) { + t.Errorf("rewrite after eviction: Last-Modified %v, want later than %v", got, b) + } + + // Once the clock moves on, dates follow it again instead of drifting ahead. + clock = clock.Add(10 * time.Second) + if got, want := rewrite("demo", "f"), clock.Truncate(time.Second); !got.Equal(want) { + t.Errorf("later rewrite: Last-Modified %v, want %v", got, want) + } +} + +// TestRewrittenMetadataLastModifiedNeedsRewriteCache checks that without the +// rewrite cache there is no Last-Modified: nothing records when a rewrite was +// first made, so no date can be relied on to change with it. +func TestRewrittenMetadataLastModifiedNeedsRewriteCache(t *testing.T) { + for _, c := range validatorCases { + t.Run(c.ecosystem, func(t *testing.T) { + u := newValidatorUpstream(t) + proxy := validatorProxy(t, u, time.Hour) + proxy.SetMetadataRewriteCacheSize(0) + rec := serve(c.handler(proxy, u), c.path, nil) + if rec.Code != http.StatusOK || rec.Header().Get("ETag") == "" { + t.Fatalf("status %d, ETag %q; want 200 with an ETag", rec.Code, rec.Header().Get("ETag")) + } + if lm := rec.Header().Get("Last-Modified"); lm != "" { + t.Errorf("Last-Modified = %q without the rewrite cache, want none", lm) + } + }) + } +} + // cooldownUpstream sets documents with one version published long ago and // one published two hours ago, so a cooldown of a day hides the recent one // and a cooldown of an hour keeps it. diff --git a/internal/handler/npm.go b/internal/handler/npm.go index f819a6e5..96ab10ea 100644 --- a/internal/handler/npm.go +++ b/internal/handler/npm.go @@ -242,7 +242,7 @@ func (h *NPMHandler) handlePackageMetadata(w http.ResponseWriter, r *http.Reques return } - rewritten, etag, err := h.proxy.cachedRewrite(r.Context(), "npm", h.proxyURL, metadataCacheKey, upstream.body, upstream.digest, func(b []byte) ([]byte, []string, error) { + rewritten, err := h.proxy.cachedRewrite(r.Context(), "npm", h.proxyURL, metadataCacheKey, upstream.body, upstream.digest, func(b []byte) ([]byte, []string, error) { return h.rewriteMetadataKeeping(packageName, b) }) if err != nil { @@ -269,7 +269,7 @@ func (h *NPMHandler) handlePackageMetadata(w http.ResponseWriter, r *http.Reques return } - serveRewrittenMetadata(w, r, h.proxy.rewrittenMetadataFor(rewritten, etag, upstream.lastModified)) + serveRewrittenMetadata(w, r, rewritten) } // handleFilteredNPMVersionMetadata serves /pkg/ and /pkg/ from the @@ -287,7 +287,7 @@ func (h *NPMHandler) handleFilteredNPMVersionMetadata(w http.ResponseWriter, r * return } - rewritten, _, err := h.proxy.cachedRewrite(r.Context(), "npm", h.proxyURL, metadataCacheKey, packument, "", func(b []byte) ([]byte, []string, error) { + rewritten, err := h.proxy.cachedRewrite(r.Context(), "npm", h.proxyURL, metadataCacheKey, packument, "", func(b []byte) ([]byte, []string, error) { out, err := h.rewriteNPMVersionFromPackument(packageName, specifier, b) return out, nil, err }) @@ -314,7 +314,7 @@ func (h *NPMHandler) handleFilteredNPMVersionMetadata(w http.ResponseWriter, r * w.Header().Set(headerContentType, contentTypeJSON) w.WriteHeader(http.StatusOK) - _, _ = w.Write(rewritten) + _, _ = w.Write(rewritten.body) } // npmMetadataCacheKey qualifies routed-package metadata cache entries with the diff --git a/internal/handler/rewrite_cache.go b/internal/handler/rewrite_cache.go index 853e49a7..5fc0b718 100644 --- a/internal/handler/rewrite_cache.go +++ b/internal/handler/rewrite_cache.go @@ -32,47 +32,51 @@ import ( // 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. // -// Each entry keeps the ETag of its output, so clients can revalidate their -// copy without the output being hashed again on every request. +// Each entry keeps the validators of its output, so clients can revalidate +// their copy without the output being hashed again on every request. type rewriteCache struct { maxBytes int64 + now func() time.Time mu sync.Mutex size int64 order *list.List // most recently used at the front entries map[string]*list.Element inFlight map[string]*inflightRewrite + // issued holds the Last-Modified last given to a rewrite of each package, + // keyed by its cache key without the digest, until the clock passes it. + issued map[string]time.Time } type rewriteEntry struct { - key string - out []byte - etag string + key string + out []byte + metadataValidators } -// inflightRewrite is one rewrite that concurrent callers share. out, etag and +// inflightRewrite is one rewrite that concurrent callers share. out, v 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 - etag string + v metadataValidators err error } -// processStartedAt bounds the Last-Modified of rewritten metadata from below. -// A rewrite depends on configuration read at startup as well as on the -// upstream document, so a restart can change it while upstream stays the same. -var processStartedAt = time.Now() - -// rewrittenMetadata is a rewritten metadata document with the validators a -// client revalidates its copy with. lastModified is zero when unknown. -type rewrittenMetadata struct { - body []byte +// metadataValidators are what a client revalidates its copy of rewritten +// metadata with. lastModified is zero when there is none to give. +type metadataValidators struct { etag string lastModified time.Time } +// rewrittenMetadata is a rewritten metadata document with its validators. +type rewrittenMetadata struct { + body []byte + metadataValidators +} + // metadataETag returns the strong ETag of a rewritten document. func metadataETag(out []byte) string { sum := sha256.Sum256(out) @@ -118,9 +122,11 @@ func newRewriteCache(maxBytes int64) *rewriteCache { } return &rewriteCache{ maxBytes: maxBytes, + now: time.Now, order: list.New(), entries: make(map[string]*list.Element), inFlight: make(map[string]*inflightRewrite), + issued: make(map[string]time.Time), } } @@ -132,33 +138,34 @@ func rewriteCacheKey(ecosystem, proxyURL, name string, in []byte) string { } // rewriteCacheKeyForDigest is rewriteCacheKey for upstream bytes known only by -// their digest, in the form the metadata cache records it. +// their digest, in the form the metadata cache records it. The digest comes +// last, so the key without it names the package the rewrite is for. func rewriteCacheKeyForDigest(ecosystem, proxyURL, name, digest string) string { return strings.Join([]string{ecosystem, proxyURL, name, digest}, "\x00") } -// get returns the cached rewrite for key and its ETag, if there is one. -func (c *rewriteCache) get(key string) ([]byte, string, bool) { +// get returns the cached rewrite for key and its validators, if there is one. +func (c *rewriteCache) get(key string) ([]byte, metadataValidators, bool) { if c == nil { - return nil, "", false + return nil, metadataValidators{}, false } c.mu.Lock() defer c.mu.Unlock() el, ok := c.entries[key] if !ok { - return nil, "", false + return nil, metadataValidators{}, false } c.order.MoveToFront(el) e := el.Value.(*rewriteEntry) - return e.out, e.etag, true + return e.out, e.metadataValidators, true } -// rewrite returns rewrite(in) and its ETag, from the cache when it can. +// rewrite returns rewrite(in) and its validators, 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, string, error) { +func (c *rewriteCache) rewrite(ctx context.Context, key string, in []byte, rewrite func([]byte) ([]byte, error)) (out []byte, v metadataValidators, err error) { if c == nil { return rewriteWithETag(in, rewrite) } @@ -168,15 +175,15 @@ func (c *rewriteCache) rewrite(ctx context.Context, key string, in []byte, rewri c.order.MoveToFront(el) e := el.Value.(*rewriteEntry) c.mu.Unlock() - return e.out, e.etag, nil + return e.out, e.metadataValidators, nil } if f, ok := c.inFlight[key]; ok { c.mu.Unlock() select { case <-ctx.Done(): - return nil, "", ctx.Err() + return nil, metadataValidators{}, ctx.Err() case <-f.done: - return f.out, f.etag, f.err + return f.out, f.v, f.err } } f := &inflightRewrite{done: make(chan struct{}), err: errSharedRewriteAbandoned} @@ -187,22 +194,50 @@ func (c *rewriteCache) rewrite(ctx context.Context, key string, in []byte, rewri c.mu.Lock() delete(c.inFlight, key) if f.err == nil { - c.add(&rewriteEntry{key: key, out: f.out, etag: f.etag}) + f.v.lastModified = c.issueLastModified(key) + c.add(&rewriteEntry{key: key, out: f.out, metadataValidators: f.v}) } c.mu.Unlock() close(f.done) + out, v, err = f.out, f.v, f.err }() - f.out, f.etag, f.err = rewriteWithETag(in, rewrite) - return f.out, f.etag, f.err + f.out, f.v, f.err = rewriteWithETag(in, rewrite) + return f.out, f.v, f.err } -// rewriteWithETag runs rewrite and computes the ETag of what it returns. -func rewriteWithETag(in []byte, rewrite func([]byte) ([]byte, error)) ([]byte, string, error) { +// issueLastModified returns the Last-Modified of a rewrite that has just been +// made for key: the current second, or a second after the date last given to +// a rewrite of the same package when that is no earlier. Upstream's own date +// cannot serve, since the rewrite also depends on configuration read at +// startup, and two documents can share a second. A strictly later date for +// every new rewrite means a client holding one rewrite never gets 304 for +// another, even after its entry was evicted. Dates are remembered only until +// the clock passes them; after that the clock alone keeps them increasing. +// Called with mu held. +func (c *rewriteCache) issueLastModified(key string) time.Time { + now := c.now().Truncate(time.Second) + for pkg, issued := range c.issued { + if issued.Before(now) { + delete(c.issued, pkg) + } + } + pkg := key[:strings.LastIndexByte(key, 0)] + lastModified := now + if issued, ok := c.issued[pkg]; ok && !issued.Before(now) { + lastModified = issued.Add(time.Second) + } + c.issued[pkg] = lastModified + return lastModified +} + +// rewriteWithETag runs rewrite and computes the ETag of what it returns. It +// gives no Last-Modified: nothing records when an uncached rewrite was made. +func rewriteWithETag(in []byte, rewrite func([]byte) ([]byte, error)) ([]byte, metadataValidators, error) { out, err := rewrite(in) if err != nil { - return nil, "", err + return nil, metadataValidators{}, err } - return out, metadataETag(out), nil + return out, metadataValidators{etag: metadataETag(out)}, nil } // add stores e and evicts least recently used entries until the cache is @@ -235,54 +270,39 @@ func (c *rewriteCache) len() int { } // cachedRewrite rewrites a metadata document through the proxy's rewrite -// cache and returns it with its ETag. digest identifies in when the metadata -// cache stored it, and is empty otherwise. 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, and when digest is -// known their ETag comes from their inputs rather than from hashing each -// output again. -func (p *Proxy) cachedRewrite(ctx context.Context, ecosystem, proxyURL, name string, in []byte, digest string, rewrite keepingRewrite) ([]byte, string, error) { +// cache and returns it with its validators. digest identifies in when the +// metadata cache stored it, and is empty otherwise. 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, with no +// Last-Modified, and when digest is known their ETag comes from their inputs +// rather than from hashing each output again. +func (p *Proxy) cachedRewrite(ctx context.Context, ecosystem, proxyURL, name string, in []byte, digest string, rewrite keepingRewrite) (rewrittenMetadata, error) { if p.cooldownEnabled() && digest != "" { out, kept, err := rewrite(in) if err != nil { - return nil, "", err + return rewrittenMetadata{}, err } - return out, keptVersionsETag(digest, proxyURL, name, kept), nil + return rewrittenMetadata{out, metadataValidators{etag: keptVersionsETag(digest, proxyURL, name, kept)}}, nil } rewriteOnly := func(b []byte) ([]byte, error) { out, _, err := rewrite(b) return out, err } + var out []byte + var v metadataValidators + var err error if p.rewrites == nil || p.cooldownEnabled() { - return rewriteWithETag(in, rewriteOnly) + out, v, err = rewriteWithETag(in, rewriteOnly) + } else { + out, v, err = p.rewrites.rewrite(ctx, rewriteCacheKey(ecosystem, proxyURL, name, in), in, rewriteOnly) } - return p.rewrites.rewrite(ctx, rewriteCacheKey(ecosystem, proxyURL, name, in), in, rewriteOnly) + return rewrittenMetadata{out, v}, err } func (p *Proxy) cooldownEnabled() bool { return p.Cooldown != nil && p.Cooldown.Enabled() } -// rewriteLastModified returns the Last-Modified of a rewrite of metadata that -// upstream last modified at upstream, or zero when there is none to give. -// With cooldown on there is none: versions leaving cooldown change the -// rewrite while upstream stays the same. -func (p *Proxy) rewriteLastModified(upstream time.Time) time.Time { - if upstream.IsZero() || p.cooldownEnabled() { - return time.Time{} - } - if upstream.Before(processStartedAt) { - return processStartedAt - } - return upstream -} - -// rewrittenMetadataFor returns a fresh rewrite of metadata that upstream last -// modified at upstreamModified, with its validators. -func (p *Proxy) rewrittenMetadataFor(out []byte, etag string, upstreamModified time.Time) rewrittenMetadata { - return rewrittenMetadata{body: out, etag: etag, lastModified: p.rewriteLastModified(upstreamModified)} -} - // storedRewrite returns the cached rewrite of the metadata stored for // ecosystem and cacheKey, when that metadata is still within its TTL and a // rewrite of it is cached. It reads only the cache row, never the stored @@ -303,16 +323,12 @@ func (p *Proxy) storedRewrite(ecosystem, cacheKey, proxyURL, name string) (rewri if !entry.FetchedAt.Valid || time.Since(entry.FetchedAt.Time) >= p.MetadataTTL { return rewrittenMetadata{}, false } - out, etag, ok := p.rewrites.get(rewriteCacheKeyForDigest(ecosystem, proxyURL, name, entry.ContentDigest.String)) + out, v, ok := p.rewrites.get(rewriteCacheKeyForDigest(ecosystem, proxyURL, name, entry.ContentDigest.String)) if !ok { return rewrittenMetadata{}, false } metrics.RecordCacheHit(ecosystem) - var upstreamModified time.Time - if entry.LastModified.Valid { - upstreamModified = entry.LastModified.Time - } - return rewrittenMetadata{body: out, etag: etag, lastModified: p.rewriteLastModified(upstreamModified)}, true + return rewrittenMetadata{out, v}, true } // serveRewrittenMetadata writes rewritten metadata with its validators, or diff --git a/internal/handler/rewrite_cache_test.go b/internal/handler/rewrite_cache_test.go index 733ba31f..473f80a9 100644 --- a/internal/handler/rewrite_cache_test.go +++ b/internal/handler/rewrite_cache_test.go @@ -190,7 +190,7 @@ func TestCachedRewrite_DisabledRewritesEveryTime(t *testing.T) { var r countingRewrite for range 2 { - _, _, _ = proxy.cachedRewrite(context.Background(), "npm", "http://proxy", "left-pad", []byte("doc"), "", r.keeping) + _, _ = proxy.cachedRewrite(context.Background(), "npm", "http://proxy", "left-pad", []byte("doc"), "", r.keeping) } if got := r.calls.Load(); got != 2 { t.Errorf("rewrites = %d, want 2", got) @@ -207,7 +207,7 @@ func TestCachedRewrite_CooldownBypassesCache(t *testing.T) { var r countingRewrite for range 2 { - _, _, _ = proxy.cachedRewrite(context.Background(), "npm", "http://proxy", "left-pad", []byte("doc"), "", r.keeping) + _, _ = proxy.cachedRewrite(context.Background(), "npm", "http://proxy", "left-pad", []byte("doc"), "", r.keeping) } if got := r.calls.Load(); got != 2 { t.Errorf("rewrites = %d, want 2", got) From cff4b5188d3129260520af6f4795b20f66561559 Mon Sep 17 00:00:00 2001 From: Simon Chrzanowski Date: Mon, 12 Oct 2026 07:07:03 +0200 Subject: [PATCH 4/7] Date rewritten metadata by when its package started serving it Two false 304s remained. A rewrite kept the date it was made, so when upstream went from A to B and back to A, A's cached rewrite came back with a date earlier than B's and a client holding B got 304. And a second rewrite in the same second was dated a second ahead of the clock, a date only that process remembered: after a restart a changed document could be dated earlier than one the client held. - Record which rewrite each package is serving and since when, and date a rewrite by the second its package switched to it, including back to one still cached - Never date ahead of the clock: a rewrite switched to in a second that already dated another for the package goes undated until the next second - Date nothing in the second the cache starts, the last second the previous process may have dated a rewrite in - Drop a package's record when its current rewrite is evicted, so records stay bounded by the cache; forgetting one dated in the current second holds back dates for new packages until the next second --- internal/handler/metadata_validators_test.go | 271 +++++++++++++++---- internal/handler/rewrite_cache.go | 162 +++++++---- 2 files changed, 339 insertions(+), 94 deletions(-) diff --git a/internal/handler/metadata_validators_test.go b/internal/handler/metadata_validators_test.go index 5db4308e..8e9f90bb 100644 --- a/internal/handler/metadata_validators_test.go +++ b/internal/handler/metadata_validators_test.go @@ -76,18 +76,61 @@ var validatorCases = []validatorCase{ func validatorProxy(t *testing.T, u *validatorUpstream, ttl time.Duration) *Proxy { t.Helper() - proxy, _ := validatorProxyWithDB(t, u, ttl) + proxy, _, _ := validatorProxyWithDB(t, u, ttl) return proxy } -func validatorProxyWithDB(t *testing.T, u *validatorUpstream, ttl time.Duration) (*Proxy, *database.DB) { +// validatorProxyWithDB returns a proxy whose rewrite cache runs on a clock +// the test moves, starting a second after the process start it models. +func validatorProxyWithDB(t *testing.T, u *validatorUpstream, ttl time.Duration) (*Proxy, *database.DB, *testClock) { t.Helper() proxy, db, _, _ := setupTestProxy(t) proxy.HTTPClient = u.Client() proxy.CacheMetadata = true proxy.MetadataTTL = ttl + clock := &testClock{t: time.Date(2026, 10, 10, 12, 0, 0, 0, time.UTC)} + restartRewriteCache(proxy, clock) + clock.advance(time.Second) + return proxy, db, clock +} + +// restartRewriteCache gives proxy a new, empty rewrite cache started at the +// clock's current time, as a restart of the process would. +func restartRewriteCache(proxy *Proxy, clock *testClock) { proxy.SetMetadataRewriteCacheSize(1 << 20) - return proxy, db + proxy.rewrites.useClock(clock.now) +} + +// useClock makes the cache read time from now, as if it was made at now(). +func (c *rewriteCache) useClock(now func() time.Time) { + c.now = now + c.forgotten = now().Truncate(time.Second) +} + +type testClock struct { + mu sync.Mutex + t time.Time +} + +func (c *testClock) now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.t +} + +func (c *testClock) advance(d time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + c.t = c.t.Add(d) +} + +// npmV and composerV are upstream documents with a single version v. +func npmV(v string) string { + return `{"name":"left-pad","versions":{"` + v + `":{"dist":{"tarball":"https://registry.npmjs.org/left-pad/-/left-pad-` + v + `.tgz"}}}}` +} + +func composerV(v string) string { + return `{"packages":{"vendor/pkg":[{"version":"` + v + `","dist":{"type":"zip","url":"https://example.com/pkg-` + v + `.zip"}}]}}` } // expireMetadata ages a metadata cache row past any TTL, so the next request @@ -172,25 +215,28 @@ func TestRewrittenMetadataETagFollowsUpstream(t *testing.T) { } // TestRewrittenMetadataLastModified checks the validator Composer revalidates -// with. It is when this process made the rewrite, not upstream's date: the -// rewrite also depends on configuration read at startup, so the same upstream -// document can be rewritten differently after a restart. +// with. It is when this process started serving the rewrite, not upstream's +// date: the rewrite also depends on configuration read at startup, so the +// same upstream document can be rewritten differently after a restart. It +// stays the same while the rewrite does. func TestRewrittenMetadataLastModified(t *testing.T) { for _, c := range validatorCases { for _, ttl := range []time.Duration{time.Hour, 0} { t.Run(c.ecosystem+"/ttl="+ttl.String(), func(t *testing.T) { u := newValidatorUpstream(t) - handler := c.handler(validatorProxy(t, u, ttl), u) + proxy, _, clock := validatorProxyWithDB(t, u, ttl) + handler := c.handler(proxy, u) - before := time.Now().Truncate(time.Second) + clock.advance(400 * time.Millisecond) first := serve(handler, c.path, nil) lastModified, err := http.ParseTime(first.Header().Get("Last-Modified")) if err != nil { t.Fatalf("Last-Modified %q: %v", first.Header().Get("Last-Modified"), err) } - if lastModified.Before(before) || lastModified.After(time.Now()) { - t.Errorf("Last-Modified %v, want the time of the rewrite, at or after %v", lastModified, before) + if want := clock.now().Truncate(time.Second); !lastModified.Equal(want) { + t.Errorf("Last-Modified %v, want %v, the second of the rewrite", lastModified, want) } + clock.advance(time.Hour) current := serve(handler, c.path, http.Header{"If-Modified-Since": {first.Header().Get("Last-Modified")}}) if current.Code != http.StatusNotModified || current.Body.Len() != 0 { @@ -225,7 +271,7 @@ func TestRewrittenMetadataLastModifiedFollowsRefresh(t *testing.T) { for _, ttl := range []time.Duration{time.Hour, 0} { t.Run(c.ecosystem+"/ttl="+ttl.String(), func(t *testing.T) { u := newValidatorUpstream(t) - proxy, db := validatorProxyWithDB(t, u, ttl) + proxy, db, clock := validatorProxyWithDB(t, u, ttl) handler := c.handler(proxy, u) first := serve(handler, c.path, nil) @@ -234,10 +280,8 @@ func TestRewrittenMetadataLastModifiedFollowsRefresh(t *testing.T) { t.Fatalf("first response: status %d, Last-Modified %q; want 200 with a Last-Modified", first.Code, firstModified) } - u.set( - `{"name":"left-pad","versions":{"1.3.1":{"dist":{"tarball":"https://registry.npmjs.org/left-pad/-/left-pad-1.3.1.tgz"}}}}`, - `{"packages":{"vendor/pkg":[{"version":"1.0.1","dist":{"type":"zip","url":"https://example.com/pkg-1.0.1.zip"}}]}}`, - ) + clock.advance(time.Minute) + u.set(npmV("1.3.1"), composerV("1.0.1")) u.setLastModified(u.lastModified.Add(24 * time.Hour)) expireMetadata(t, db, c.ecosystem, c.name) @@ -259,6 +303,121 @@ func TestRewrittenMetadataLastModifiedFollowsRefresh(t *testing.T) { } } +// TestRewrittenMetadataLastModifiedReturningDocument is a regression test +// for a false 304. Upstream serves A, then B, then A again; A's cached +// rewrite must not bring back the date it was first served with, which is +// earlier than B's, or a client holding B gets 304 for A. +func TestRewrittenMetadataLastModifiedReturningDocument(t *testing.T) { + for _, c := range validatorCases { + for _, ttl := range []time.Duration{time.Hour, 0} { + t.Run(c.ecosystem+"/ttl="+ttl.String(), func(t *testing.T) { + u := newValidatorUpstream(t) + proxy, db, clock := validatorProxyWithDB(t, u, ttl) + handler := c.handler(proxy, u) + change := func(v string) { + clock.advance(10 * time.Second) + u.set(npmV(v), composerV(v)) + if ttl > 0 { + expireMetadata(t, db, c.ecosystem, c.name) + } + } + + u.set(npmV("1.0.0"), composerV("1.0.0")) + a := serve(handler, c.path, nil) + change("2.0.0") + b := serve(handler, c.path, nil) + if b.Body.String() == a.Body.String() || b.Header().Get("Last-Modified") == "" { + t.Fatalf("B: Last-Modified %q, body changed %t; want a new body with a date", b.Header().Get("Last-Modified"), b.Body.String() != a.Body.String()) + } + change("1.0.0") + + rec := serve(handler, c.path, http.Header{"If-Modified-Since": {b.Header().Get("Last-Modified")}}) + if rec.Code != http.StatusOK || rec.Body.String() != a.Body.String() { + t.Fatalf("A again, If-Modified-Since B's date: status %d, body is A %t; want 200 with A", rec.Code, rec.Body.String() == a.Body.String()) + } + was, _ := http.ParseTime(b.Header().Get("Last-Modified")) + if now, err := http.ParseTime(rec.Header().Get("Last-Modified")); err != nil || !now.After(was) { + t.Errorf("A again: Last-Modified %q, want later than B's %q", rec.Header().Get("Last-Modified"), b.Header().Get("Last-Modified")) + } + }) + } + } +} + +// TestRewrittenMetadataLastModifiedSameSecond checks that a document that +// replaces another within the second the other was dated gets no date of +// its own until a later second, rather than one ahead of the clock. The +// clock-time date it would share is one a client may already hold. +func TestRewrittenMetadataLastModifiedSameSecond(t *testing.T) { + for _, c := range validatorCases { + t.Run(c.ecosystem, func(t *testing.T) { + u := newValidatorUpstream(t) + proxy, _, clock := validatorProxyWithDB(t, u, 0) + handler := c.handler(proxy, u) + + a := serve(handler, c.path, nil) + aModified := a.Header().Get("Last-Modified") + u.set(npmV("2.0.0"), composerV("2.0.0")) + for i, header := range []http.Header{nil, {"If-Modified-Since": {aModified}}} { + rec := serve(handler, c.path, header) + if rec.Code != http.StatusOK || rec.Body.String() == a.Body.String() { + t.Fatalf("request %d for B: status %d; want 200 with B", i, rec.Code) + } + if lm := rec.Header().Get("Last-Modified"); lm != "" { + t.Errorf("request %d for B in A's second: Last-Modified %q, want none", i, lm) + } + } + + clock.advance(time.Second) + rec := serve(handler, c.path, nil) + lastModified, err := http.ParseTime(rec.Header().Get("Last-Modified")) + if want := clock.now().Truncate(time.Second); err != nil || !lastModified.Equal(want) { + t.Errorf("B a second later: Last-Modified %q, want %v", rec.Header().Get("Last-Modified"), want) + } + }) + } +} + +// TestRewrittenMetadataLastModifiedAcrossRestart is a regression test for a +// false 304 after a restart. Dates are never ahead of the clock, and the +// restarted process dates nothing in the second it starts, the last in which +// the previous process may have dated a document. +func TestRewrittenMetadataLastModifiedAcrossRestart(t *testing.T) { + for _, c := range validatorCases { + t.Run(c.ecosystem, func(t *testing.T) { + u := newValidatorUpstream(t) + proxy, _, clock := validatorProxyWithDB(t, u, 0) + handler := c.handler(proxy, u) + + // Two documents in one second; the client keeps the latest date. + latest := serve(handler, c.path, nil).Header().Get("Last-Modified") + u.set(npmV("2.0.0"), composerV("2.0.0")) + if lm := serve(handler, c.path, nil).Header().Get("Last-Modified"); lm != "" { + latest = lm + } + if held, _ := http.ParseTime(latest); held.After(clock.now()) { + t.Errorf("Last-Modified %q is ahead of the clock at %v", latest, clock.now()) + } + + restartRewriteCache(proxy, clock) + u.set(npmV("3.0.0"), composerV("3.0.0")) + rec := serve(handler, c.path, http.Header{"If-Modified-Since": {latest}}) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "3.0.0") { + t.Fatalf("changed document after a restart, If-Modified-Since %q: status %d; want 200 with it", latest, rec.Code) + } + if lm := rec.Header().Get("Last-Modified"); lm != "" { + t.Errorf("Last-Modified %q in the second the process started, want none", lm) + } + + clock.advance(time.Second) + rec = serve(handler, c.path, http.Header{"If-Modified-Since": {latest}}) + if rec.Code != http.StatusOK || rec.Header().Get("Last-Modified") == "" { + t.Errorf("a second after the restart: status %d, Last-Modified %q; want 200 with a date", rec.Code, rec.Header().Get("Last-Modified")) + } + }) + } +} + // TestRewrittenMetadataValidatorsWithCooldown checks that with cooldown on, // rewritten metadata keeps an exact ETag but has no Last-Modified: cooldown // changes the rewrite as versions age in, while upstream stays the same. @@ -328,58 +487,76 @@ func TestRewriteCacheETag(t *testing.T) { } // TestRewriteCacheLastModified checks the Last-Modified the rewrite cache -// gives each rewrite: the second it was made, kept while it stays cached, and -// always later than any earlier rewrite of the same package, so that a client -// holding one representation never gets 304 for another. +// serves each rewrite with: the second the package started serving it, so a +// client holding one representation never gets 304 for another. A date is +// never ahead of the clock, so none is given in a second that may already +// have dated another representation of the package. func TestRewriteCacheLastModified(t *testing.T) { + clock := &testClock{t: time.Date(2026, 10, 10, 12, 0, 0, 400_000_000, time.UTC)} c := newRewriteCache(8) - clock := time.Date(2026, 10, 10, 12, 0, 0, 400_000_000, time.UTC) - c.now = func() time.Time { return clock } - second := clock.Truncate(time.Second) + c.useClock(clock.now) + second := func() time.Time { return clock.now().Truncate(time.Second) } identity := func(b []byte) ([]byte, error) { return b, nil } + key := func(name, doc string) string { return rewriteCacheKey("npm", "http://proxy", name, []byte(doc)) } rewrite := func(name, doc string) time.Time { t.Helper() - _, v, err := c.rewrite(t.Context(), rewriteCacheKey("npm", "http://proxy", name, []byte(doc)), []byte(doc), identity) + _, v, err := c.rewrite(t.Context(), key(name, doc), []byte(doc), identity) if err != nil { t.Fatal(err) } return v.lastModified } - - if got := rewrite("demo", "a"); !got.Equal(second) { - t.Errorf("first rewrite: Last-Modified %v, want %v", got, second) + expect := func(what string, got, want time.Time) { + t.Helper() + if !got.Equal(want) { + t.Errorf("%s: Last-Modified %v, want %v", what, got, want) + } } - clock = clock.Add(300 * time.Millisecond) - if got := rewrite("demo", "a"); !got.Equal(second) { - t.Errorf("cached rewrite: Last-Modified %v, want %v as before", got, second) + var undated time.Time + + expect("in the second the cache started", rewrite("demo", "a"), undated) + clock.advance(time.Second) + a := second() + expect("a second later", rewrite("demo", "a"), a) + clock.advance(300 * time.Millisecond) + if _, v, ok := c.get(key("demo", "a")); !ok || !v.lastModified.Equal(a) { + t.Errorf("get: Last-Modified %v, %v; want %v", v.lastModified, ok, a) } - // A new document in the same second must not share the date. - b := rewrite("demo", "b") - if !b.After(second) { - t.Errorf("new document in the same second: Last-Modified %v, want later than %v", b, second) - } + // A new document in the second a was dated gets no date, and neither + // does a, back in the same second, until a later one. + expect("new document in the same second", rewrite("demo", "b"), undated) + expect("a back in the same second", rewrite("demo", "a"), undated) // Another package is not held back by it. - if got := rewrite("other", "x"); !got.Equal(second) { - t.Errorf("other package: Last-Modified %v, want %v", got, second) - } - - // Evicting what came before does not let a later rewrite reuse its date. + expect("other package", rewrite("other", "x"), a) + clock.advance(time.Second) + expect("a in the next second", rewrite("demo", "a"), second()) + + // A cached document coming back is dated when it comes back. + clock.advance(10 * time.Second) + b := second() + expect("b", rewrite("demo", "b"), b) + clock.advance(10 * time.Second) + expect("a after b", rewrite("demo", "a"), second()) + + // Evicting the package's current document forgets the package. Its next + // document can be dated at once unless the forgotten one was dated in + // this second. + clock.advance(10 * time.Second) for _, doc := range []string{"ccc", "ddd", "eee"} { rewrite("filler", doc) } - if _, _, ok := c.get(rewriteCacheKey("npm", "http://proxy", "demo", []byte("b"))); ok { - t.Fatal("expected b to be evicted") + if _, _, ok := c.get(key("demo", "a")); ok { + t.Fatal("expected a to be evicted") } - if got := rewrite("demo", "b"); !got.After(b) { - t.Errorf("rewrite after eviction: Last-Modified %v, want later than %v", got, b) + if _, ok := c.serving[rewritePackage(key("demo", "a"))]; ok { + t.Error("the package is still recorded after its rewrite was evicted") } - - // Once the clock moves on, dates follow it again instead of drifting ahead. - clock = clock.Add(10 * time.Second) - if got, want := rewrite("demo", "f"), clock.Truncate(time.Second); !got.Equal(want) { - t.Errorf("later rewrite: Last-Modified %v, want %v", got, want) + expect("after a clean eviction", rewrite("demo", "f"), second()) + for _, doc := range []string{"ggg", "hhh", "iii"} { + rewrite("filler", doc) } + expect("after an eviction in the second f was dated", rewrite("demo", "j"), undated) } // TestRewrittenMetadataLastModifiedNeedsRewriteCache checks that without the diff --git a/internal/handler/rewrite_cache.go b/internal/handler/rewrite_cache.go index 5fc0b718..540a13f4 100644 --- a/internal/handler/rewrite_cache.go +++ b/internal/handler/rewrite_cache.go @@ -32,8 +32,9 @@ import ( // 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. // -// Each entry keeps the validators of its output, so clients can revalidate -// their copy without the output being hashed again on every request. +// Each entry keeps the ETag of its output, so clients can revalidate their +// copy without the output being hashed again on every request. The cache also +// dates what it serves for each package; see lastModified. type rewriteCache struct { maxBytes int64 now func() time.Time @@ -43,24 +44,38 @@ type rewriteCache struct { order *list.List // most recently used at the front entries map[string]*list.Element inFlight map[string]*inflightRewrite - // issued holds the Last-Modified last given to a rewrite of each package, - // keyed by its cache key without the digest, until the clock passes it. - issued map[string]time.Time + // serving holds the rewrite each package is serving, keyed by its cache + // key without the digest, while that rewrite is cached. + serving map[string]servedRewrite + // forgotten is the latest second in which the cache may have dated a + // rewrite of a package it no longer has in serving: the second it started, + // as the previous process may have dated one then, or a later one. + forgotten time.Time +} + +// servedRewrite is the rewrite a package is serving and since when. +type servedRewrite struct { + etag string + // switched is the second the package started serving it. + switched time.Time + // since is its Last-Modified: the first second, from switched on, that + // can have dated no other rewrite of the package. Zero until there is one. + since time.Time } type rewriteEntry struct { - key string - out []byte - metadataValidators + key string + out []byte + etag string } -// inflightRewrite is one rewrite that concurrent callers share. out, v and -// err are written before done closes and read only after, so the close is the -// handoff. +// inflightRewrite is one rewrite that concurrent callers share. out, etag +// 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 - v metadataValidators + etag string err error } @@ -121,12 +136,13 @@ func newRewriteCache(maxBytes int64) *rewriteCache { return nil } return &rewriteCache{ - maxBytes: maxBytes, - now: time.Now, - order: list.New(), - entries: make(map[string]*list.Element), - inFlight: make(map[string]*inflightRewrite), - issued: make(map[string]time.Time), + maxBytes: maxBytes, + now: time.Now, + order: list.New(), + entries: make(map[string]*list.Element), + inFlight: make(map[string]*inflightRewrite), + serving: make(map[string]servedRewrite), + forgotten: time.Now().Truncate(time.Second), } } @@ -157,7 +173,7 @@ func (c *rewriteCache) get(key string) ([]byte, metadataValidators, bool) { } c.order.MoveToFront(el) e := el.Value.(*rewriteEntry) - return e.out, e.metadataValidators, true + return e.out, c.validators(e), true } // rewrite returns rewrite(in) and its validators, from the cache when it can. @@ -173,9 +189,9 @@ func (c *rewriteCache) rewrite(ctx context.Context, key string, in []byte, rewri c.mu.Lock() if el, ok := c.entries[key]; ok { c.order.MoveToFront(el) - e := el.Value.(*rewriteEntry) + v := c.validators(el.Value.(*rewriteEntry)) c.mu.Unlock() - return e.out, e.metadataValidators, nil + return el.Value.(*rewriteEntry).out, v, nil } if f, ok := c.inFlight[key]; ok { c.mu.Unlock() @@ -183,7 +199,7 @@ func (c *rewriteCache) rewrite(ctx context.Context, key string, in []byte, rewri case <-ctx.Done(): return nil, metadataValidators{}, ctx.Err() case <-f.done: - return f.out, f.v, f.err + return f.out, c.servedValidators(key, f.etag), f.err } } f := &inflightRewrite{done: make(chan struct{}), err: errSharedRewriteAbandoned} @@ -194,44 +210,95 @@ func (c *rewriteCache) rewrite(ctx context.Context, key string, in []byte, rewri c.mu.Lock() delete(c.inFlight, key) if f.err == nil { - f.v.lastModified = c.issueLastModified(key) - c.add(&rewriteEntry{key: key, out: f.out, metadataValidators: f.v}) + c.add(&rewriteEntry{key: key, out: f.out, etag: f.etag}) } c.mu.Unlock() close(f.done) - out, v, err = f.out, f.v, f.err + out, v, err = f.out, c.servedValidators(key, f.etag), f.err }() - f.out, f.v, f.err = rewriteWithETag(in, rewrite) - return f.out, f.v, f.err + var fv metadataValidators + f.out, fv, f.err = rewriteWithETag(in, rewrite) + f.etag = fv.etag + return f.out, fv, f.err } -// issueLastModified returns the Last-Modified of a rewrite that has just been -// made for key: the current second, or a second after the date last given to -// a rewrite of the same package when that is no earlier. Upstream's own date -// cannot serve, since the rewrite also depends on configuration read at -// startup, and two documents can share a second. A strictly later date for -// every new rewrite means a client holding one rewrite never gets 304 for -// another, even after its entry was evicted. Dates are remembered only until -// the clock passes them; after that the clock alone keeps them increasing. -// Called with mu held. -func (c *rewriteCache) issueLastModified(key string) time.Time { +// servedValidators returns the validators to serve the rewrite made for key +// with, if it was cached, and its bare ETag otherwise. +func (c *rewriteCache) servedValidators(key, etag string) metadataValidators { + c.mu.Lock() + defer c.mu.Unlock() + if el, ok := c.entries[key]; ok && el.Value.(*rewriteEntry).etag == etag { + return c.validators(el.Value.(*rewriteEntry)) + } + return metadataValidators{etag: etag} +} + +// validators returns the validators to serve cached rewrite e with, and +// records that its package is serving it. Called with mu held. +func (c *rewriteCache) validators(e *rewriteEntry) metadataValidators { + return metadataValidators{etag: e.etag, lastModified: c.lastModified(rewritePackage(e.key), e.etag)} +} + +// rewritePackage returns the part of a rewrite cache key that names the +// package, without the digest of the upstream document. +func rewritePackage(key string) string { + return key[:strings.LastIndexByte(key, 0)] +} + +// lastModified returns the Last-Modified to serve the rewrite of pkg with +// the given ETag with, or zero for none, and records that pkg is serving it. +// +// The date is the second pkg started serving the rewrite, which is later than +// any date given to a rewrite pkg served before, so a client holding one never +// gets 304 for another. Upstream's date cannot serve, since the rewrite also +// depends on configuration read at startup, and a rewrite cached earlier and +// served again keeps no date of its own. Dates are never ahead of the clock, +// so they hold across a restart. When pkg switches rewrites in a second that +// already dated one, the new one shares that second with a date a client may +// hold, so it goes undated until the next second. Called with mu held. +func (c *rewriteCache) lastModified(pkg, etag string) time.Time { now := c.now().Truncate(time.Second) - for pkg, issued := range c.issued { - if issued.Before(now) { - delete(c.issued, pkg) + cur, ok := c.serving[pkg] + switch { + case ok && cur.etag == etag: + if cur.since.IsZero() && now.After(cur.switched) { + cur.since = now + } + case ok: + since := now + if !cur.switched.Before(now) || !cur.since.Before(now) { + since = time.Time{} + } + cur = servedRewrite{etag: etag, switched: now, since: since} + default: + since := now + if !now.After(c.forgotten) { + since = time.Time{} } + cur = servedRewrite{etag: etag, switched: now, since: since} } - pkg := key[:strings.LastIndexByte(key, 0)] - lastModified := now - if issued, ok := c.issued[pkg]; ok && !issued.Before(now) { - lastModified = issued.Add(time.Second) + c.serving[pkg] = cur + return cur.since +} + +// forget drops what serving records for e's package when e is the rewrite +// the package is serving, so the map holds only packages with a cached +// rewrite. Called with mu held, as e is evicted. +func (c *rewriteCache) forget(e *rewriteEntry) { + pkg := rewritePackage(e.key) + cur, ok := c.serving[pkg] + if !ok || cur.etag != e.etag { + return + } + delete(c.serving, pkg) + now := c.now().Truncate(time.Second) + if !cur.switched.Before(now) || !cur.since.Before(now) { + c.forgotten = now } - c.issued[pkg] = lastModified - return lastModified } // rewriteWithETag runs rewrite and computes the ETag of what it returns. It -// gives no Last-Modified: nothing records when an uncached rewrite was made. +// gives no Last-Modified: nothing records when an uncached rewrite was served. func rewriteWithETag(in []byte, rewrite func([]byte) ([]byte, error)) ([]byte, metadataValidators, error) { out, err := rewrite(in) if err != nil { @@ -255,6 +322,7 @@ func (c *rewriteCache) add(e *rewriteEntry) { e := oldest.Value.(*rewriteEntry) c.order.Remove(oldest) delete(c.entries, e.key) + c.forget(e) c.size -= int64(len(e.out)) } } From 599b2e1473d7a43861a5b9563ad2ddd83f5cc83f Mon Sep 17 00:00:00 2001 From: Simon Chrzanowski Date: Mon, 12 Oct 2026 12:52:02 +0200 Subject: [PATCH 5/7] Add a metadata_dates table for rewritten metadata dates Replicas sharing a database need one place to agree on the Last-Modified of rewritten metadata, which process memory cannot give them. - Add metadata_dates, keyed by ecosystem, proxy URL and package, holding the ETag being dated and its date in Unix seconds, so no zone is involved - Insert a package's first date only if no replica recorded one first - Swap a date only if it is still the one read, so of two replicas switching a package at once only one succeeds - Create it in both schemas and in migration 011 --- docs/architecture.md | 11 ++ internal/database/database_test.go | 2 +- internal/database/metadata_dates.go | 77 ++++++++++++++ internal/database/metadata_dates_test.go | 128 +++++++++++++++++++++++ internal/database/schema.go | 40 +++++++ 5 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 internal/database/metadata_dates.go create mode 100644 internal/database/metadata_dates_test.go diff --git a/docs/architecture.md b/docs/architecture.md index 0d37a60a..c96ae339 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -189,8 +189,19 @@ metadata_cache ( updated_at DATETIME ) -- indexes: (ecosystem, name) unique + +metadata_dates ( + ecosystem TEXT NOT NULL, + proxy_url TEXT NOT NULL, -- each proxy URL rewrites to its own download URLs + name TEXT NOT NULL, + etag TEXT NOT NULL, -- the rewritten representation being dated + last_modified INTEGER NOT NULL, -- Unix seconds; BIGINT on Postgres + PRIMARY KEY (ecosystem, proxy_url, name) +) ``` +`metadata_dates` holds the `Last-Modified` served with rewritten npm and Composer metadata. A package's date is the second it started serving its current representation, and a new representation is only dated in a later second. Keeping it in the database means replicas sharing one serve the same date, and never give one representation's date to another, so `If-Modified-Since` cannot return 304 for a body the client does not have. + On PostgreSQL, `INTEGER PRIMARY KEY` becomes `SERIAL`, `DATETIME` becomes `TIMESTAMP`, `INTEGER DEFAULT 0` booleans become `BOOLEAN DEFAULT FALSE`, and size/count columns use `BIGINT`. The `MigrateSchema()` function handles backward compatibility with older git-pkgs databases by running named migrations that add missing columns and tables. See [migrations.md](migrations.md) for how to add new schema changes. diff --git a/internal/database/database_test.go b/internal/database/database_test.go index 13f6828c..f09a86a0 100644 --- a/internal/database/database_test.go +++ b/internal/database/database_test.go @@ -613,7 +613,7 @@ func createTestPostgresDB(t *testing.T) *DB { // Drop and recreate every table CreateSchema creates for clean test state; // leftover migration records make the next CreateSchema fail on the // migrations primary key. - tables := []string{"artifacts", "pending_deletes", "versions", "packages", "vulnerabilities", "metadata_cache", "migrations", "schema_info"} + tables := []string{"artifacts", "pending_deletes", "versions", "packages", "vulnerabilities", "metadata_cache", "metadata_dates", "migrations", "schema_info"} for _, table := range tables { _, _ = db.Exec("DROP TABLE IF EXISTS " + table + " CASCADE") } diff --git a/internal/database/metadata_dates.go b/internal/database/metadata_dates.go new file mode 100644 index 00000000..f705f29a --- /dev/null +++ b/internal/database/metadata_dates.go @@ -0,0 +1,77 @@ +package database + +import ( + "database/sql" + "errors" + "fmt" + "time" +) + +// MetadataDate is the Last-Modified a package's rewritten metadata is served +// with, and the ETag of the rewrite it dates. +// +// Rewritten metadata has no date of its own, so the proxy assigns one when a +// package starts serving a new rewrite. Keeping it in the database rather than +// in process memory is what lets replicas sharing the database agree on it: a +// date one replica gave out is never given by another to a different rewrite. +type MetadataDate struct { + ETag string + LastModified time.Time +} + +type metadataDateRow struct { + ETag string `db:"etag"` + LastModified int64 `db:"last_modified"` +} + +// GetMetadataDate returns the date recorded for a package's metadata as +// rewritten for proxyURL, or nil when there is none. +func (db *DB) GetMetadataDate(ecosystem, proxyURL, name string) (*MetadataDate, error) { + var row metadataDateRow + err := db.Get(&row, db.Rebind(` + SELECT etag, last_modified FROM metadata_dates + WHERE ecosystem = ? AND proxy_url = ? AND name = ? + `), ecosystem, proxyURL, name) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("getting metadata date: %w", err) + } + return &MetadataDate{ETag: row.ETag, LastModified: time.Unix(row.LastModified, 0).UTC()}, nil +} + +// InsertMetadataDate records the first date of a package's metadata and +// reports whether it did; it leaves a date another replica recorded first. +func (db *DB) InsertMetadataDate(ecosystem, proxyURL, name string, date MetadataDate) (bool, error) { + result, err := db.Exec(db.Rebind(` + INSERT INTO metadata_dates (ecosystem, proxy_url, name, etag, last_modified) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (ecosystem, proxy_url, name) DO NOTHING + `), ecosystem, proxyURL, name, date.ETag, date.LastModified.Unix()) + if err != nil { + return false, fmt.Errorf("inserting metadata date: %w", err) + } + return affectedOne(result) +} + +// SwapMetadataDate replaces a package's date with next if it is still old, +// and reports whether it did; it leaves a date another replica swapped in. +func (db *DB) SwapMetadataDate(ecosystem, proxyURL, name string, old, next MetadataDate) (bool, error) { + result, err := db.Exec(db.Rebind(` + UPDATE metadata_dates SET etag = ?, last_modified = ? + WHERE ecosystem = ? AND proxy_url = ? AND name = ? AND etag = ? AND last_modified = ? + `), next.ETag, next.LastModified.Unix(), ecosystem, proxyURL, name, old.ETag, old.LastModified.Unix()) + if err != nil { + return false, fmt.Errorf("swapping metadata date: %w", err) + } + return affectedOne(result) +} + +func affectedOne(result sql.Result) (bool, error) { + n, err := result.RowsAffected() + if err != nil { + return false, fmt.Errorf("counting affected rows: %w", err) + } + return n == 1, nil +} diff --git a/internal/database/metadata_dates_test.go b/internal/database/metadata_dates_test.go new file mode 100644 index 00000000..864723e5 --- /dev/null +++ b/internal/database/metadata_dates_test.go @@ -0,0 +1,128 @@ +package database + +import ( + "path/filepath" + "testing" + "time" +) + +const testProxyURL = "http://proxy.example" + +func TestMetadataDateMissing(t *testing.T) { + runWithBothDatabases(t, func(t *testing.T, db *DB) { + got, err := db.GetMetadataDate(testEcosystemNPM, testProxyURL, "missing") + if err != nil || got != nil { + t.Errorf("GetMetadataDate() = %+v, %v; want nil, nil", got, err) + } + }) +} + +func TestMetadataDateInsertOnce(t *testing.T) { + runWithBothDatabases(t, func(t *testing.T, db *DB) { + first := MetadataDate{ETag: `"a"`, LastModified: time.Date(2026, 10, 10, 12, 0, 1, 0, time.UTC)} + second := MetadataDate{ETag: `"b"`, LastModified: first.LastModified.Add(time.Second)} + + inserted, err := db.InsertMetadataDate(testEcosystemNPM, testProxyURL, "left-pad", first) + if err != nil || !inserted { + t.Fatalf("first InsertMetadataDate() = %v, %v; want true", inserted, err) + } + inserted, err = db.InsertMetadataDate(testEcosystemNPM, testProxyURL, "left-pad", second) + if err != nil || inserted { + t.Fatalf("second InsertMetadataDate() = %v, %v; want false, the row exists", inserted, err) + } + + got, err := db.GetMetadataDate(testEcosystemNPM, testProxyURL, "left-pad") + if err != nil || got == nil || *got != first { + t.Errorf("GetMetadataDate() = %+v, %v; want %+v", got, err, first) + } + }) +} + +// A package's date is kept per proxy URL: each one rewrites the document to +// different download URLs, so each serves a different representation. +func TestMetadataDateKeyedByProxyURL(t *testing.T) { + runWithBothDatabases(t, func(t *testing.T, db *DB) { + date := MetadataDate{ETag: `"a"`, LastModified: time.Date(2026, 10, 10, 12, 0, 1, 0, time.UTC)} + if _, err := db.InsertMetadataDate(testEcosystemNPM, testProxyURL, "left-pad", date); err != nil { + t.Fatal(err) + } + inserted, err := db.InsertMetadataDate(testEcosystemNPM, "http://other.example", "left-pad", date) + if err != nil || !inserted { + t.Errorf("InsertMetadataDate() for another proxy URL = %v, %v; want true", inserted, err) + } + }) +} + +// SwapMetadataDate is a compare-and-swap: it only replaces the date it was +// given, so of two replicas swapping from the same date only one succeeds. +func TestMetadataDateSwap(t *testing.T) { + runWithBothDatabases(t, func(t *testing.T, db *DB) { + a := MetadataDate{ETag: `"a"`, LastModified: time.Date(2026, 10, 10, 12, 0, 1, 0, time.UTC)} + b := MetadataDate{ETag: `"b"`, LastModified: a.LastModified.Add(time.Second)} + c := MetadataDate{ETag: `"c"`, LastModified: b.LastModified.Add(time.Second)} + if _, err := db.InsertMetadataDate(testEcosystemNPM, testProxyURL, "left-pad", a); err != nil { + t.Fatal(err) + } + + swapped, err := db.SwapMetadataDate(testEcosystemNPM, testProxyURL, "left-pad", a, b) + if err != nil || !swapped { + t.Fatalf("SwapMetadataDate(a, b) = %v, %v; want true", swapped, err) + } + swapped, err = db.SwapMetadataDate(testEcosystemNPM, testProxyURL, "left-pad", a, c) + if err != nil || swapped { + t.Fatalf("SwapMetadataDate(a, c) after a was replaced = %v, %v; want false", swapped, err) + } + + got, err := db.GetMetadataDate(testEcosystemNPM, testProxyURL, "left-pad") + if err != nil || got == nil || *got != b { + t.Errorf("GetMetadataDate() = %+v, %v; want %+v", got, err, b) + } + }) +} + +// Dates are whole seconds in UTC whatever the local zone, so every replica +// reads back the date another one wrote. +func TestMetadataDateIsUTCSeconds(t *testing.T) { + tokyo, err := time.LoadLocation("Asia/Tokyo") + if err != nil { + t.Skipf("time zone data unavailable: %v", err) + } + prev := time.Local + time.Local = tokyo + t.Cleanup(func() { time.Local = prev }) + + runWithBothDatabases(t, func(t *testing.T, db *DB) { + written := time.Date(2026, 10, 10, 21, 0, 1, 700_000_000, tokyo) + if _, err := db.InsertMetadataDate(testEcosystemNPM, testProxyURL, "tz", MetadataDate{ETag: `"a"`, LastModified: written}); err != nil { + t.Fatal(err) + } + got, err := db.GetMetadataDate(testEcosystemNPM, testProxyURL, "tz") + if err != nil || got == nil { + t.Fatalf("GetMetadataDate() = %+v, %v", got, err) + } + if want := time.Date(2026, 10, 10, 12, 0, 1, 0, time.UTC); !got.LastModified.Equal(want) || got.LastModified.Location() != time.UTC { + t.Errorf("LastModified read back as %v, want %v", got.LastModified, want) + } + }) +} + +func TestMetadataDatesTableCreatedByMigration(t *testing.T) { + db, err := Create(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + defer func() { _ = db.Close() }() + if _, err := db.Exec("DROP TABLE metadata_dates"); err != nil { + t.Fatal(err) + } + if _, err := db.Exec("DELETE FROM migrations WHERE name = '011_add_metadata_dates'"); err != nil { + t.Fatal(err) + } + + if err := db.MigrateSchema(); err != nil { + t.Fatalf("MigrateSchema() error = %v", err) + } + if has, err := db.HasTable("metadata_dates"); err != nil || !has { + t.Errorf("HasTable(metadata_dates) = %v, %v; want true after migration", has, err) + } +} diff --git a/internal/database/schema.go b/internal/database/schema.go index 4aef75c6..b8d3f93f 100644 --- a/internal/database/schema.go +++ b/internal/database/schema.go @@ -119,6 +119,15 @@ CREATE TABLE IF NOT EXISTS metadata_cache ( ); CREATE UNIQUE INDEX IF NOT EXISTS idx_metadata_eco_name ON metadata_cache(ecosystem, name); +CREATE TABLE IF NOT EXISTS metadata_dates ( + ecosystem TEXT NOT NULL, + proxy_url TEXT NOT NULL, + name TEXT NOT NULL, + etag TEXT NOT NULL, + last_modified INTEGER NOT NULL, + PRIMARY KEY (ecosystem, proxy_url, name) +); + CREATE TABLE IF NOT EXISTS migrations ( name TEXT NOT NULL PRIMARY KEY, applied_at DATETIME NOT NULL @@ -228,6 +237,15 @@ CREATE TABLE IF NOT EXISTS metadata_cache ( ); CREATE UNIQUE INDEX IF NOT EXISTS idx_metadata_eco_name ON metadata_cache(ecosystem, name); +CREATE TABLE IF NOT EXISTS metadata_dates ( + ecosystem TEXT NOT NULL, + proxy_url TEXT NOT NULL, + name TEXT NOT NULL, + etag TEXT NOT NULL, + last_modified BIGINT NOT NULL, + PRIMARY KEY (ecosystem, proxy_url, name) +); + CREATE TABLE IF NOT EXISTS migrations ( name TEXT NOT NULL PRIMARY KEY, applied_at TIMESTAMP NOT NULL @@ -382,6 +400,7 @@ var migrations = []migration{ {"008_add_metadata_content_encoding", migrateAddMetadataContentEncoding}, {"009_add_pending_deletes", migrateAddPendingDeletes}, {"010_add_pending_deletes_queued_at_index", migrateAddPendingDeletesQueuedAtIndex}, + {"011_add_metadata_dates", migrateAddMetadataDates}, } // isTableNotFound returns true if the error indicates a missing table. @@ -681,6 +700,27 @@ func migrateAddPendingDeletesQueuedAtIndex(db *DB) error { return nil } +// migrateAddMetadataDates creates the table of Last-Modified dates for +// rewritten metadata, which replicas sharing the database agree on. +func migrateAddMetadataDates(db *DB) error { + seconds := "INTEGER" + if db.dialect == DialectPostgres { + seconds = "BIGINT" + } + query := fmt.Sprintf(`CREATE TABLE IF NOT EXISTS metadata_dates ( + ecosystem TEXT NOT NULL, + proxy_url TEXT NOT NULL, + name TEXT NOT NULL, + etag TEXT NOT NULL, + last_modified %s NOT NULL, + PRIMARY KEY (ecosystem, proxy_url, name) + )`, seconds) + if _, err := db.Exec(query); err != nil { + return fmt.Errorf("creating metadata_dates table: %w", err) + } + return nil +} + // EnsureMetadataCacheTable creates the metadata_cache table if it doesn't exist. func (db *DB) EnsureMetadataCacheTable() error { has, err := db.HasTable("metadata_cache") From 054c45c5a76be02c5f9099344ecb8da967ba5b76 Mon Sep 17 00:00:00 2001 From: Simon Chrzanowski Date: Mon, 12 Oct 2026 12:52:02 +0200 Subject: [PATCH 6/7] Share the dates of rewritten metadata across replicas Dates were kept in each process's rewrite cache, so replicas sharing a database and storage dated independently: one dated a document, upstream changed in that second, and another gave the new document the same date, so a client holding it got 304 and kept the old document. - Date from metadata_dates instead of process memory: a package's date is the second it started serving its current representation, recorded once for every replica - Switch a package to another representation only in a second later than its recorded date, and serve it undated until then - Date nothing in the second the process started, so dates clients hold from before this change, or from the process it replaced, stay older - Serve no date ahead of the local clock, recorded by a replica whose clock is ahead - Date rewrites without the rewrite cache too; the cache keeps only ETags - Add two-replica, pre-fix date and clock skew regression tests --- internal/handler/handler.go | 7 + internal/handler/metadata_dates.go | 83 ++++++ internal/handler/metadata_validators_test.go | 257 ++++++++++++------- internal/handler/rewrite_cache.go | 136 ++-------- 4 files changed, 282 insertions(+), 201 deletions(-) create mode 100644 internal/handler/metadata_dates.go diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 56d4cdd9..e867961e 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -206,6 +206,11 @@ type Proxy struct { // rewrites caches metadata documents after their handler rewrites them. // Nil leaves every request to rewrite its own copy. rewrites *rewriteCache + + // now is the clock rewritten metadata is dated by, and started the time + // the process started; see metadataDate. + now func() time.Time + started time.Time } // CooldownPolicy supplies version-age filtering and package-specific durations. @@ -230,6 +235,8 @@ func NewProxy(db *database.DB, store storage.Storage, fetcher fetch.FetcherInter HTTPClient: &http.Client{ Timeout: defaultHTTPTimeout, }, + now: time.Now, + started: time.Now(), } } diff --git a/internal/handler/metadata_dates.go b/internal/handler/metadata_dates.go new file mode 100644 index 00000000..fca5a1f9 --- /dev/null +++ b/internal/handler/metadata_dates.go @@ -0,0 +1,83 @@ +package handler + +import ( + "time" + + "github.com/git-pkgs/proxy/internal/database" +) + +// metadataDateAttempts bounds how often metadataDate retries when other +// replicas keep changing a package's date between its read and its write. +const metadataDateAttempts = 3 + +// metadataDate returns the Last-Modified to serve a package's metadata with, +// as rewritten for proxyURL into the representation with the given ETag, or +// zero for none. +// +// The date is the second the package started serving that representation, +// recorded in the database so that every replica sharing it serves the same +// date. A switch to another representation is dated only in a second later +// than the recorded date, and then with that second, so a date is never given +// to two representations of a package: a client holding one never gets 304 +// for another, whichever replica it asks. Until such a second the +// representation goes undated, and If-Modified-Since is not evaluated. +// +// A package's first date is likewise later than the second this process +// started, as the process it replaced may have dated the package then without +// recording it. A date ahead of this process's clock, recorded by a replica +// whose clock is ahead, is not served until the clock catches up. Without a +// database, or when it fails, nothing is dated. +func (p *Proxy) metadataDate(ecosystem, proxyURL, name, etag string) time.Time { + if p.DB == nil || etag == "" { + return time.Time{} + } + now := p.clock().Truncate(time.Second) + for range metadataDateAttempts { + date, settled, err := p.claimMetadataDate(ecosystem, proxyURL, name, etag, now) + if err != nil { + p.Logger.Warn("failed to date rewritten metadata", "ecosystem", ecosystem, "name", name, "error", err) + return time.Time{} + } + if settled { + if date.After(now) { + return time.Time{} + } + return date + } + } + return time.Time{} +} + +// claimMetadataDate makes one attempt at recording the date of etag for a +// package at now. It reports false when another replica changed the date +// first, and the attempt should be made again. +func (p *Proxy) claimMetadataDate(ecosystem, proxyURL, name, etag string, now time.Time) (time.Time, bool, error) { + current, err := p.DB.GetMetadataDate(ecosystem, proxyURL, name) + if err != nil { + return time.Time{}, false, err + } + next := database.MetadataDate{ETag: etag, LastModified: now} + switch { + case current == nil: + if !now.After(p.started.Truncate(time.Second)) { + return time.Time{}, true, nil + } + inserted, err := p.DB.InsertMetadataDate(ecosystem, proxyURL, name, next) + return now, inserted, err + case current.ETag == etag: + return current.LastModified, true, nil + case !now.After(current.LastModified): + return time.Time{}, true, nil + default: + swapped, err := p.DB.SwapMetadataDate(ecosystem, proxyURL, name, *current, next) + return now, swapped, err + } +} + +// clock reads the time metadata is dated by. +func (p *Proxy) clock() time.Time { + if p.now == nil { + return time.Now() + } + return p.now() +} diff --git a/internal/handler/metadata_validators_test.go b/internal/handler/metadata_validators_test.go index 8e9f90bb..a057e5eb 100644 --- a/internal/handler/metadata_validators_test.go +++ b/internal/handler/metadata_validators_test.go @@ -80,8 +80,8 @@ func validatorProxy(t *testing.T, u *validatorUpstream, ttl time.Duration) *Prox return proxy } -// validatorProxyWithDB returns a proxy whose rewrite cache runs on a clock -// the test moves, starting a second after the process start it models. +// validatorProxyWithDB returns a proxy that dates metadata by a clock the +// test moves, starting a second after the process start it models. func validatorProxyWithDB(t *testing.T, u *validatorUpstream, ttl time.Duration) (*Proxy, *database.DB, *testClock) { t.Helper() proxy, db, _, _ := setupTestProxy(t) @@ -89,22 +89,32 @@ func validatorProxyWithDB(t *testing.T, u *validatorUpstream, ttl time.Duration) proxy.CacheMetadata = true proxy.MetadataTTL = ttl clock := &testClock{t: time.Date(2026, 10, 10, 12, 0, 0, 0, time.UTC)} - restartRewriteCache(proxy, clock) + restartProxy(proxy, clock) clock.advance(time.Second) return proxy, db, clock } -// restartRewriteCache gives proxy a new, empty rewrite cache started at the -// clock's current time, as a restart of the process would. -func restartRewriteCache(proxy *Proxy, clock *testClock) { +// restartProxy gives proxy a new, empty rewrite cache and makes it date +// metadata by clock from the clock's current time on, as a restart of the +// process would. What the database holds survives. +func restartProxy(proxy *Proxy, clock *testClock) { proxy.SetMetadataRewriteCacheSize(1 << 20) - proxy.rewrites.useClock(clock.now) + proxy.now = clock.now + proxy.started = clock.now() } -// useClock makes the cache read time from now, as if it was made at now(). -func (c *rewriteCache) useClock(now func() time.Time) { - c.now = now - c.forgotten = now().Truncate(time.Second) +// validatorReplica returns a second proxy process sharing proxy's database +// and storage, as another replica behind the same load balancer would, with +// its own rewrite cache and its own clock. +func validatorReplica(proxy *Proxy, clock *testClock) *Proxy { + replica := NewProxy(proxy.DB, proxy.Storage, proxy.Fetcher, proxy.Resolver, proxy.Logger) + replica.HTTPClient = proxy.HTTPClient + replica.CacheMetadata = proxy.CacheMetadata + replica.MetadataTTL = proxy.MetadataTTL + replica.SetMetadataRewriteCacheSize(1 << 20) + replica.now = clock.now + replica.started = proxy.started + return replica } type testClock struct { @@ -399,7 +409,7 @@ func TestRewrittenMetadataLastModifiedAcrossRestart(t *testing.T) { t.Errorf("Last-Modified %q is ahead of the clock at %v", latest, clock.now()) } - restartRewriteCache(proxy, clock) + restartProxy(proxy, clock) u.set(npmV("3.0.0"), composerV("3.0.0")) rec := serve(handler, c.path, http.Header{"If-Modified-Since": {latest}}) if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "3.0.0") { @@ -486,94 +496,169 @@ func TestRewriteCacheETag(t *testing.T) { } } -// TestRewriteCacheLastModified checks the Last-Modified the rewrite cache -// serves each rewrite with: the second the package started serving it, so a -// client holding one representation never gets 304 for another. A date is -// never ahead of the clock, so none is given in a second that may already -// have dated another representation of the package. -func TestRewriteCacheLastModified(t *testing.T) { - clock := &testClock{t: time.Date(2026, 10, 10, 12, 0, 0, 400_000_000, time.UTC)} - c := newRewriteCache(8) - c.useClock(clock.now) - second := func() time.Time { return clock.now().Truncate(time.Second) } - identity := func(b []byte) ([]byte, error) { return b, nil } - key := func(name, doc string) string { return rewriteCacheKey("npm", "http://proxy", name, []byte(doc)) } - rewrite := func(name, doc string) time.Time { - t.Helper() - _, v, err := c.rewrite(t.Context(), key(name, doc), []byte(doc), identity) - if err != nil { - t.Fatal(err) - } - return v.lastModified - } - expect := func(what string, got, want time.Time) { - t.Helper() - if !got.Equal(want) { - t.Errorf("%s: Last-Modified %v, want %v", what, got, want) - } +// TestRewrittenMetadataLastModifiedWithoutRewriteCache checks that dates do +// not depend on the rewrite cache: the database records them. +func TestRewrittenMetadataLastModifiedWithoutRewriteCache(t *testing.T) { + for _, c := range validatorCases { + t.Run(c.ecosystem, func(t *testing.T) { + u := newValidatorUpstream(t) + proxy, _, clock := validatorProxyWithDB(t, u, time.Hour) + proxy.SetMetadataRewriteCacheSize(0) + handler := c.handler(proxy, u) + + first := serve(handler, c.path, nil) + if first.Code != http.StatusOK || first.Header().Get("ETag") == "" { + t.Fatalf("status %d, ETag %q; want 200 with an ETag", first.Code, first.Header().Get("ETag")) + } + lastModified, err := http.ParseTime(first.Header().Get("Last-Modified")) + if want := clock.now().Truncate(time.Second); err != nil || !lastModified.Equal(want) { + t.Errorf("Last-Modified %q without the rewrite cache, want %v", first.Header().Get("Last-Modified"), want) + } + clock.advance(time.Minute) + if rec := serve(handler, c.path, http.Header{"If-Modified-Since": {first.Header().Get("Last-Modified")}}); rec.Code != http.StatusNotModified { + t.Errorf("If-Modified-Since Last-Modified: status %d, want 304", rec.Code) + } + }) } - var undated time.Time +} - expect("in the second the cache started", rewrite("demo", "a"), undated) - clock.advance(time.Second) - a := second() - expect("a second later", rewrite("demo", "a"), a) - clock.advance(300 * time.Millisecond) - if _, v, ok := c.get(key("demo", "a")); !ok || !v.lastModified.Equal(a) { - t.Errorf("get: Last-Modified %v, %v; want %v", v.lastModified, ok, a) - } - - // A new document in the second a was dated gets no date, and neither - // does a, back in the same second, until a later one. - expect("new document in the same second", rewrite("demo", "b"), undated) - expect("a back in the same second", rewrite("demo", "a"), undated) - // Another package is not held back by it. - expect("other package", rewrite("other", "x"), a) - clock.advance(time.Second) - expect("a in the next second", rewrite("demo", "a"), second()) +// TestRewrittenMetadataLastModifiedAcrossReplicas is a regression test for a +// false 304 between replicas sharing a database and storage. One replica +// dates a document, upstream changes in that second, and a client holding +// the date asks another replica: the date must not suppress the new body. +// Once a second has passed, both replicas date the new document alike. +func TestRewrittenMetadataLastModifiedAcrossReplicas(t *testing.T) { + for _, c := range validatorCases { + t.Run(c.ecosystem, func(t *testing.T) { + u := newValidatorUpstream(t) + a, _, clock := validatorProxyWithDB(t, u, 0) + b := validatorReplica(a, clock) + replicas := map[string]http.Handler{"A": c.handler(a, u), "B": c.handler(b, u)} + + first := serve(replicas["A"], c.path, nil) + held := first.Header().Get("Last-Modified") + if first.Code != http.StatusOK || held == "" { + t.Fatalf("replica A: status %d, Last-Modified %q; want 200 with a date", first.Code, held) + } + u.set(npmV("2.0.0"), composerV("2.0.0")) + for _, name := range []string{"B", "A"} { + expectChangedUndated(t, name, serve(replicas[name], c.path, http.Header{"If-Modified-Since": {held}}), first) + } - // A cached document coming back is dated when it comes back. - clock.advance(10 * time.Second) - b := second() - expect("b", rewrite("demo", "b"), b) - clock.advance(10 * time.Second) - expect("a after b", rewrite("demo", "a"), second()) + clock.advance(time.Second) + shared := expectSharedDate(t, replicas, c.path, held) + for name, handler := range replicas { + if rec := serve(handler, c.path, http.Header{"If-Modified-Since": {shared}}); rec.Code != http.StatusNotModified { + t.Errorf("replica %s, If-Modified-Since the shared date: status %d, want 304", name, rec.Code) + } + if rec := serve(handler, c.path, http.Header{"If-Modified-Since": {held}}); rec.Code != http.StatusOK { + t.Errorf("replica %s, If-Modified-Since the old date: status %d, want 200", name, rec.Code) + } + } + }) + } +} - // Evicting the package's current document forgets the package. Its next - // document can be dated at once unless the forgotten one was dated in - // this second. - clock.advance(10 * time.Second) - for _, doc := range []string{"ccc", "ddd", "eee"} { - rewrite("filler", doc) +// expectChangedUndated checks that a replica answered a conditional request +// with a body and ETag other than first's, and no date: the second first was +// dated in may not date another representation. +func expectChangedUndated(t *testing.T, replica string, rec *httptest.ResponseRecorder, first *httptest.ResponseRecorder) { + t.Helper() + if rec.Code != http.StatusOK || rec.Body.String() == first.Body.String() { + t.Fatalf("replica %s after upstream changed: status %d; want 200 with the new body", replica, rec.Code) } - if _, _, ok := c.get(key("demo", "a")); ok { - t.Fatal("expected a to be evicted") + if rec.Header().Get("ETag") == first.Header().Get("ETag") { + t.Errorf("replica %s: the new body has the old ETag", replica) } - if _, ok := c.serving[rewritePackage(key("demo", "a"))]; ok { - t.Error("the package is still recorded after its rewrite was evicted") + if lm := rec.Header().Get("Last-Modified"); lm != "" { + t.Errorf("replica %s: Last-Modified %q in the second the old body was dated, want none", replica, lm) } - expect("after a clean eviction", rewrite("demo", "f"), second()) - for _, doc := range []string{"ggg", "hhh", "iii"} { - rewrite("filler", doc) +} + +// expectSharedDate checks that every replica serves the same Last-Modified, +// later than held, and returns it. +func expectSharedDate(t *testing.T, replicas map[string]http.Handler, path, held string) string { + t.Helper() + was, _ := http.ParseTime(held) + shared := "" + for name, handler := range replicas { + lm := serve(handler, path, nil).Header().Get("Last-Modified") + if now, err := http.ParseTime(lm); err != nil || !now.After(was) { + t.Fatalf("replica %s: Last-Modified %q, want later than %q", name, lm, held) + } + if shared != "" && lm != shared { + t.Fatalf("replica %s: Last-Modified %q, another replica serves %q", name, lm, shared) + } + shared = lm } - expect("after an eviction in the second f was dated", rewrite("demo", "j"), undated) + return shared } -// TestRewrittenMetadataLastModifiedNeedsRewriteCache checks that without the -// rewrite cache there is no Last-Modified: nothing records when a rewrite was -// first made, so no date can be relied on to change with it. -func TestRewrittenMetadataLastModifiedNeedsRewriteCache(t *testing.T) { +// TestRewrittenMetadataLastModifiedFromBeforeTheFix checks that dates clients +// hold from before dates were shared cannot suppress a body. Those were never +// later than the clock of the process that gave them, which stopped before +// this one started, so no date is given in the second this process started +// and every later one is later than any of them. +func TestRewrittenMetadataLastModifiedFromBeforeTheFix(t *testing.T) { for _, c := range validatorCases { t.Run(c.ecosystem, func(t *testing.T) { u := newValidatorUpstream(t) - proxy := validatorProxy(t, u, time.Hour) - proxy.SetMetadataRewriteCacheSize(0) - rec := serve(c.handler(proxy, u), c.path, nil) - if rec.Code != http.StatusOK || rec.Header().Get("ETag") == "" { - t.Fatalf("status %d, ETag %q; want 200 with an ETag", rec.Code, rec.Header().Get("ETag")) + a, _, clock := validatorProxyWithDB(t, u, 0) + restartProxy(a, clock) + b := validatorReplica(a, clock) + held := clock.now().Truncate(time.Second).Format(http.TimeFormat) + + for name, handler := range map[string]http.Handler{"A": c.handler(a, u), "B": c.handler(b, u)} { + rec := serve(handler, c.path, http.Header{"If-Modified-Since": {held}}) + if rec.Code != http.StatusOK || rec.Header().Get("Last-Modified") != "" { + t.Errorf("replica %s in its first second, If-Modified-Since %q: status %d, Last-Modified %q; want 200 without a date", + name, held, rec.Code, rec.Header().Get("Last-Modified")) + } } - if lm := rec.Header().Get("Last-Modified"); lm != "" { - t.Errorf("Last-Modified = %q without the rewrite cache, want none", lm) + + clock.advance(time.Second) + for name, handler := range map[string]http.Handler{"A": c.handler(a, u), "B": c.handler(b, u)} { + rec := serve(handler, c.path, http.Header{"If-Modified-Since": {held}}) + was, _ := http.ParseTime(held) + now, err := http.ParseTime(rec.Header().Get("Last-Modified")) + if rec.Code != http.StatusOK || err != nil || !now.After(was) { + t.Errorf("replica %s a second later, If-Modified-Since %q: status %d, Last-Modified %q; want 200 with a later date", + name, held, rec.Code, rec.Header().Get("Last-Modified")) + } + } + }) + } +} + +// TestRewrittenMetadataLastModifiedWithClockSkew checks that a replica whose +// clock is behind gives no date ahead of its own clock, neither for a new +// document nor for one another replica dated. +func TestRewrittenMetadataLastModifiedWithClockSkew(t *testing.T) { + for _, c := range validatorCases { + t.Run(c.ecosystem, func(t *testing.T) { + u := newValidatorUpstream(t) + a, _, clock := validatorProxyWithDB(t, u, 0) + behind := &testClock{t: clock.now().Add(-2 * time.Second)} + b := validatorReplica(a, behind) + handlerA, handlerB := c.handler(a, u), c.handler(b, u) + + held := serve(handlerA, c.path, nil).Header().Get("Last-Modified") + u.set(npmV("2.0.0"), composerV("2.0.0")) + if rec := serve(handlerB, c.path, http.Header{"If-Modified-Since": {held}}); rec.Code != http.StatusOK || rec.Header().Get("Last-Modified") != "" { + t.Fatalf("lagging replica, new document: status %d, Last-Modified %q; want 200 without a date", rec.Code, rec.Header().Get("Last-Modified")) + } + + clock.advance(time.Second) + dated := serve(handlerA, c.path, nil).Header().Get("Last-Modified") + if dated == "" { + t.Fatal("replica A gave no date a second later") + } + if lm := serve(handlerB, c.path, nil).Header().Get("Last-Modified"); lm != "" { + t.Errorf("lagging replica: Last-Modified %q ahead of its clock at %v, want none", lm, behind.now()) + } + behind.advance(3 * time.Second) + if lm := serve(handlerB, c.path, nil).Header().Get("Last-Modified"); lm != dated { + t.Errorf("lagging replica once its clock caught up: Last-Modified %q, want %q", lm, dated) } }) } diff --git a/internal/handler/rewrite_cache.go b/internal/handler/rewrite_cache.go index 540a13f4..7348bbe5 100644 --- a/internal/handler/rewrite_cache.go +++ b/internal/handler/rewrite_cache.go @@ -33,34 +33,17 @@ import ( // the key, and the denylist is loaded at startup. // // Each entry keeps the ETag of its output, so clients can revalidate their -// copy without the output being hashed again on every request. The cache also -// dates what it serves for each package; see lastModified. +// copy without the output being hashed again on every request. Dates are not +// the cache's to give: they must agree across replicas, so the database holds +// them (see metadataDate). type rewriteCache struct { maxBytes int64 - now func() time.Time mu sync.Mutex size int64 order *list.List // most recently used at the front entries map[string]*list.Element inFlight map[string]*inflightRewrite - // serving holds the rewrite each package is serving, keyed by its cache - // key without the digest, while that rewrite is cached. - serving map[string]servedRewrite - // forgotten is the latest second in which the cache may have dated a - // rewrite of a package it no longer has in serving: the second it started, - // as the previous process may have dated one then, or a later one. - forgotten time.Time -} - -// servedRewrite is the rewrite a package is serving and since when. -type servedRewrite struct { - etag string - // switched is the second the package started serving it. - switched time.Time - // since is its Last-Modified: the first second, from switched on, that - // can have dated no other rewrite of the package. Zero until there is one. - since time.Time } type rewriteEntry struct { @@ -136,13 +119,10 @@ func newRewriteCache(maxBytes int64) *rewriteCache { return nil } return &rewriteCache{ - maxBytes: maxBytes, - now: time.Now, - order: list.New(), - entries: make(map[string]*list.Element), - inFlight: make(map[string]*inflightRewrite), - serving: make(map[string]servedRewrite), - forgotten: time.Now().Truncate(time.Second), + maxBytes: maxBytes, + order: list.New(), + entries: make(map[string]*list.Element), + inFlight: make(map[string]*inflightRewrite), } } @@ -154,13 +134,12 @@ func rewriteCacheKey(ecosystem, proxyURL, name string, in []byte) string { } // rewriteCacheKeyForDigest is rewriteCacheKey for upstream bytes known only by -// their digest, in the form the metadata cache records it. The digest comes -// last, so the key without it names the package the rewrite is for. +// their digest, in the form the metadata cache records it. func rewriteCacheKeyForDigest(ecosystem, proxyURL, name, digest string) string { return strings.Join([]string{ecosystem, proxyURL, name, digest}, "\x00") } -// get returns the cached rewrite for key and its validators, if there is one. +// get returns the cached rewrite for key and its ETag, if there is one. func (c *rewriteCache) get(key string) ([]byte, metadataValidators, bool) { if c == nil { return nil, metadataValidators{}, false @@ -173,10 +152,10 @@ func (c *rewriteCache) get(key string) ([]byte, metadataValidators, bool) { } c.order.MoveToFront(el) e := el.Value.(*rewriteEntry) - return e.out, c.validators(e), true + return e.out, metadataValidators{etag: e.etag}, true } -// rewrite returns rewrite(in) and its validators, from the cache when it can. +// rewrite returns rewrite(in) and its ETag, 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 @@ -189,9 +168,9 @@ func (c *rewriteCache) rewrite(ctx context.Context, key string, in []byte, rewri c.mu.Lock() if el, ok := c.entries[key]; ok { c.order.MoveToFront(el) - v := c.validators(el.Value.(*rewriteEntry)) + e := el.Value.(*rewriteEntry) c.mu.Unlock() - return el.Value.(*rewriteEntry).out, v, nil + return e.out, metadataValidators{etag: e.etag}, nil } if f, ok := c.inFlight[key]; ok { c.mu.Unlock() @@ -199,7 +178,7 @@ func (c *rewriteCache) rewrite(ctx context.Context, key string, in []byte, rewri case <-ctx.Done(): return nil, metadataValidators{}, ctx.Err() case <-f.done: - return f.out, c.servedValidators(key, f.etag), f.err + return f.out, metadataValidators{etag: f.etag}, f.err } } f := &inflightRewrite{done: make(chan struct{}), err: errSharedRewriteAbandoned} @@ -214,7 +193,6 @@ func (c *rewriteCache) rewrite(ctx context.Context, key string, in []byte, rewri } c.mu.Unlock() close(f.done) - out, v, err = f.out, c.servedValidators(key, f.etag), f.err }() var fv metadataValidators f.out, fv, f.err = rewriteWithETag(in, rewrite) @@ -222,83 +200,7 @@ func (c *rewriteCache) rewrite(ctx context.Context, key string, in []byte, rewri return f.out, fv, f.err } -// servedValidators returns the validators to serve the rewrite made for key -// with, if it was cached, and its bare ETag otherwise. -func (c *rewriteCache) servedValidators(key, etag string) metadataValidators { - c.mu.Lock() - defer c.mu.Unlock() - if el, ok := c.entries[key]; ok && el.Value.(*rewriteEntry).etag == etag { - return c.validators(el.Value.(*rewriteEntry)) - } - return metadataValidators{etag: etag} -} - -// validators returns the validators to serve cached rewrite e with, and -// records that its package is serving it. Called with mu held. -func (c *rewriteCache) validators(e *rewriteEntry) metadataValidators { - return metadataValidators{etag: e.etag, lastModified: c.lastModified(rewritePackage(e.key), e.etag)} -} - -// rewritePackage returns the part of a rewrite cache key that names the -// package, without the digest of the upstream document. -func rewritePackage(key string) string { - return key[:strings.LastIndexByte(key, 0)] -} - -// lastModified returns the Last-Modified to serve the rewrite of pkg with -// the given ETag with, or zero for none, and records that pkg is serving it. -// -// The date is the second pkg started serving the rewrite, which is later than -// any date given to a rewrite pkg served before, so a client holding one never -// gets 304 for another. Upstream's date cannot serve, since the rewrite also -// depends on configuration read at startup, and a rewrite cached earlier and -// served again keeps no date of its own. Dates are never ahead of the clock, -// so they hold across a restart. When pkg switches rewrites in a second that -// already dated one, the new one shares that second with a date a client may -// hold, so it goes undated until the next second. Called with mu held. -func (c *rewriteCache) lastModified(pkg, etag string) time.Time { - now := c.now().Truncate(time.Second) - cur, ok := c.serving[pkg] - switch { - case ok && cur.etag == etag: - if cur.since.IsZero() && now.After(cur.switched) { - cur.since = now - } - case ok: - since := now - if !cur.switched.Before(now) || !cur.since.Before(now) { - since = time.Time{} - } - cur = servedRewrite{etag: etag, switched: now, since: since} - default: - since := now - if !now.After(c.forgotten) { - since = time.Time{} - } - cur = servedRewrite{etag: etag, switched: now, since: since} - } - c.serving[pkg] = cur - return cur.since -} - -// forget drops what serving records for e's package when e is the rewrite -// the package is serving, so the map holds only packages with a cached -// rewrite. Called with mu held, as e is evicted. -func (c *rewriteCache) forget(e *rewriteEntry) { - pkg := rewritePackage(e.key) - cur, ok := c.serving[pkg] - if !ok || cur.etag != e.etag { - return - } - delete(c.serving, pkg) - now := c.now().Truncate(time.Second) - if !cur.switched.Before(now) || !cur.since.Before(now) { - c.forgotten = now - } -} - -// rewriteWithETag runs rewrite and computes the ETag of what it returns. It -// gives no Last-Modified: nothing records when an uncached rewrite was served. +// rewriteWithETag runs rewrite and computes the ETag of what it returns. func rewriteWithETag(in []byte, rewrite func([]byte) ([]byte, error)) ([]byte, metadataValidators, error) { out, err := rewrite(in) if err != nil { @@ -322,7 +224,6 @@ func (c *rewriteCache) add(e *rewriteEntry) { e := oldest.Value.(*rewriteEntry) c.order.Remove(oldest) delete(c.entries, e.key) - c.forget(e) c.size -= int64(len(e.out)) } } @@ -343,7 +244,8 @@ func (c *rewriteCache) len() int { // on the current time, so with cooldown on a cached rewrite could keep hiding // a version past its cooldown; those rewrites always run, with no // Last-Modified, and when digest is known their ETag comes from their inputs -// rather than from hashing each output again. +// rather than from hashing each output again. Their ETag also changes with +// every restart, so replicas would never agree on a date for them. func (p *Proxy) cachedRewrite(ctx context.Context, ecosystem, proxyURL, name string, in []byte, digest string, rewrite keepingRewrite) (rewrittenMetadata, error) { if p.cooldownEnabled() && digest != "" { out, kept, err := rewrite(in) @@ -364,6 +266,9 @@ func (p *Proxy) cachedRewrite(ctx context.Context, ecosystem, proxyURL, name str } else { out, v, err = p.rewrites.rewrite(ctx, rewriteCacheKey(ecosystem, proxyURL, name, in), in, rewriteOnly) } + if err == nil && !p.cooldownEnabled() { + v.lastModified = p.metadataDate(ecosystem, proxyURL, name, v.etag) + } return rewrittenMetadata{out, v}, err } @@ -395,6 +300,7 @@ func (p *Proxy) storedRewrite(ecosystem, cacheKey, proxyURL, name string) (rewri if !ok { return rewrittenMetadata{}, false } + v.lastModified = p.metadataDate(ecosystem, proxyURL, name, v.etag) metrics.RecordCacheHit(ecosystem) return rewrittenMetadata{out, v}, true } From 9f424256460378f3d47bbb1f7ea20bb5ad347402 Mon Sep 17 00:00:00 2001 From: simon chrzanowski Date: Mon, 12 Oct 2026 16:11:49 +0200 Subject: [PATCH 7/7] Test upgrading cached metadata through the metadata_dates migration - Start from the schema before migration 011 on SQLite and PostgreSQL, with npm and Composer metadata cached, and check the rows and stored bytes survive the migration unchanged - After the upgrade, check two replicas serve the cached document with matching ETag and Last-Modified and answer conditional requests with 304 - Change the upstream document and check neither replica answers the old validators with 304, and both agree on the new ones - Give the PostgreSQL handler test a database of its own, so it shares no tables with the database package tests on the same server --- internal/database/metadata_dates_test.go | 71 +++++- .../handler/metadata_dates_upgrade_test.go | 234 ++++++++++++++++++ 2 files changed, 294 insertions(+), 11 deletions(-) create mode 100644 internal/handler/metadata_dates_upgrade_test.go diff --git a/internal/database/metadata_dates_test.go b/internal/database/metadata_dates_test.go index 864723e5..66c178a2 100644 --- a/internal/database/metadata_dates_test.go +++ b/internal/database/metadata_dates_test.go @@ -1,7 +1,8 @@ package database import ( - "path/filepath" + "database/sql" + "strings" "testing" "time" ) @@ -106,23 +107,71 @@ func TestMetadataDateIsUTCSeconds(t *testing.T) { }) } -func TestMetadataDatesTableCreatedByMigration(t *testing.T) { - db, err := Create(filepath.Join(t.TempDir(), "test.db")) +// TestMetadataDatesMigrationKeepsCachedMetadata upgrades a database from +// before migration 011 that holds cached npm and Composer metadata, and checks +// that the cached rows come through unchanged and dates can be recorded. +func TestMetadataDatesMigrationKeepsCachedMetadata(t *testing.T) { + runWithBothDatabases(t, func(t *testing.T, db *DB) { + rollBackMetadataDates(t, db) + seeded := map[string]string{testEcosystemNPM: "left-pad", "composer": "vendor/pkg"} + before := make(map[string]*MetadataCacheEntry, len(seeded)) + for ecosystem, name := range seeded { + before[ecosystem] = seedMetadataCache(t, db, ecosystem, name) + } + + if err := db.MigrateSchema(); err != nil { + t.Fatalf("MigrateSchema() error = %v", err) + } + + for ecosystem, name := range seeded { + after, err := db.GetMetadataCache(ecosystem, name) + if err != nil || after == nil || *after != *before[ecosystem] { + t.Errorf("metadata row %s/%s after migration = %+v, %v; want %+v", ecosystem, name, after, err, before[ecosystem]) + } + date := MetadataDate{ETag: `"a"`, LastModified: time.Date(2026, 10, 10, 12, 0, 1, 0, time.UTC)} + if inserted, err := db.InsertMetadataDate(ecosystem, testProxyURL, name, date); err != nil || !inserted { + t.Errorf("InsertMetadataDate(%s/%s) after migration = %v, %v; want true", ecosystem, name, inserted, err) + } + } + }) +} + +// seedMetadataCache stores a metadata row as the metadata cache writes one +// and returns it as read back. +func seedMetadataCache(t *testing.T, db *DB, ecosystem, name string) *MetadataCacheEntry { + t.Helper() + err := db.UpsertMetadataCache(&MetadataCacheEntry{ + Ecosystem: ecosystem, + Name: name, + StoragePath: "_metadata/" + ecosystem + "/" + name + "/metadata", + ETag: sql.NullString{String: `"upstream"`, Valid: true}, + ContentType: sql.NullString{String: "application/json", Valid: true}, + ContentDigest: sql.NullString{String: "sha256:" + strings.Repeat("ab", 32), Valid: true}, + Size: sql.NullInt64{Int64: 123, Valid: true}, + LastModified: sql.NullTime{Time: time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC), Valid: true}, + FetchedAt: sql.NullTime{Time: time.Now().UTC().Truncate(time.Second), Valid: true}, + }) if err != nil { - t.Fatalf("Create failed: %v", err) + t.Fatal(err) + } + entry, err := db.GetMetadataCache(ecosystem, name) + if err != nil || entry == nil { + t.Fatalf("GetMetadataCache(%s/%s) = %v, %v", ecosystem, name, entry, err) } - defer func() { _ = db.Close() }() + return entry +} + +// rollBackMetadataDates returns db to the schema from before migration 011, +// which only added the metadata_dates table. +func rollBackMetadataDates(t *testing.T, db *DB) { + t.Helper() if _, err := db.Exec("DROP TABLE metadata_dates"); err != nil { t.Fatal(err) } if _, err := db.Exec("DELETE FROM migrations WHERE name = '011_add_metadata_dates'"); err != nil { t.Fatal(err) } - - if err := db.MigrateSchema(); err != nil { - t.Fatalf("MigrateSchema() error = %v", err) - } - if has, err := db.HasTable("metadata_dates"); err != nil || !has { - t.Errorf("HasTable(metadata_dates) = %v, %v; want true after migration", has, err) + if has, err := db.HasTable("metadata_dates"); err != nil || has { + t.Fatalf("HasTable(metadata_dates) after rollback = %v, %v; want false", has, err) } } diff --git a/internal/handler/metadata_dates_upgrade_test.go b/internal/handler/metadata_dates_upgrade_test.go new file mode 100644 index 00000000..4d1ca74c --- /dev/null +++ b/internal/handler/metadata_dates_upgrade_test.go @@ -0,0 +1,234 @@ +package handler + +import ( + "context" + "crypto/rand" + "encoding/hex" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "testing" + "time" + + "github.com/git-pkgs/proxy/internal/database" + "github.com/git-pkgs/registries/fetch" +) + +// upgradeDatabases opens an empty database with the current schema for each +// dialect the proxy supports. A nil database means the dialect is unavailable. +var upgradeDatabases = []struct { + dialect string + open func(t *testing.T) *database.DB +}{ + {"sqlite", func(t *testing.T) *database.DB { + db, err := database.Create(t.TempDir() + "/test.db") + if err != nil { + t.Fatalf("Create failed: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + return db + }}, + {"postgres", openTestPostgres}, +} + +// openTestPostgres creates a database of its own on the server at +// PROXY_DATABASE_URL, so the test shares no tables with tests elsewhere that +// use the same server, and drops it afterwards. +func openTestPostgres(t *testing.T) *database.DB { + t.Helper() + serverURL := os.Getenv("PROXY_DATABASE_URL") + if serverURL == "" { + return nil + } + server, err := database.OpenPostgres(serverURL) + if err != nil { + t.Fatalf("OpenPostgres failed: %v", err) + } + t.Cleanup(func() { _ = server.Close() }) + + suffix := make([]byte, 8) + _, _ = rand.Read(suffix) + name := "proxy_upgrade_" + hex.EncodeToString(suffix) + if _, err := server.Exec("CREATE DATABASE " + name); err != nil { + t.Fatalf("creating database %s: %v", name, err) + } + t.Cleanup(func() { _, _ = server.Exec("DROP DATABASE IF EXISTS " + name + " WITH (FORCE)") }) + + dbURL, err := url.Parse(serverURL) + if err != nil { + t.Fatal(err) + } + dbURL.Path = "/" + name + db, err := database.OpenPostgres(dbURL.String()) + if err != nil { + t.Fatalf("OpenPostgres(%s) failed: %v", name, err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := db.CreateSchema(); err != nil { + t.Fatalf("CreateSchema failed: %v", err) + } + return db +} + +// rollBackMetadataDates returns db to the schema from before migration 011, +// which only added the metadata_dates table. +func rollBackMetadataDates(t *testing.T, db *database.DB) { + t.Helper() + if _, err := db.Exec("DROP TABLE metadata_dates"); err != nil { + t.Fatal(err) + } + if _, err := db.Exec("DELETE FROM migrations WHERE name = '011_add_metadata_dates'"); err != nil { + t.Fatal(err) + } +} + +// upgradeProxy returns a proxy on db that dates metadata by a clock the test +// moves, a second after the process start it models. +func upgradeProxy(t *testing.T, db *database.DB, u *validatorUpstream) (*Proxy, *testClock) { + t.Helper() + proxy := NewProxy(db, newMockStorage(), &mockFetcher{}, fetch.NewResolver(), slog.New(slog.NewTextHandler(io.Discard, nil))) + proxy.HTTPClient = u.Client() + proxy.CacheMetadata = true + proxy.MetadataTTL = time.Hour + clock := &testClock{t: time.Date(2026, 10, 10, 12, 0, 0, 0, time.UTC)} + restartProxy(proxy, clock) + clock.advance(time.Second) + return proxy, clock +} + +// cachedMetadata is a metadata cache row and the bytes stored for it. +type cachedMetadata struct { + entry database.MetadataCacheEntry + body string +} + +func readCachedMetadata(t *testing.T, proxy *Proxy, ecosystem, name string) cachedMetadata { + t.Helper() + entry, err := proxy.DB.GetMetadataCache(ecosystem, name) + if err != nil || entry == nil { + t.Fatalf("metadata row %s/%s = %v, %v", ecosystem, name, entry, err) + } + r, err := proxy.Storage.Open(context.Background(), entry.StoragePath) + if err != nil { + t.Fatalf("opening stored metadata %s: %v", entry.StoragePath, err) + } + defer func() { _ = r.Close() }() + body, err := io.ReadAll(r) + if err != nil { + t.Fatal(err) + } + return cachedMetadata{entry: *entry, body: string(body)} +} + +// TestRewrittenMetadataAfterMetadataDatesMigration upgrades a database from +// before migration 011 that holds cached npm and Composer metadata. The cached +// rows and stored bytes must come through unchanged, and two replicas sharing +// the upgraded database must serve the cached document with validators that +// revalidate, then date a changed document alike. +func TestRewrittenMetadataAfterMetadataDatesMigration(t *testing.T) { + for _, d := range upgradeDatabases { + for _, c := range validatorCases { + t.Run(d.dialect+"/"+c.ecosystem, func(t *testing.T) { + db := d.open(t) + if db == nil { + t.Skip("PROXY_DATABASE_URL not set, skipping postgres test") + } + u := newValidatorUpstream(t) + a, clock := upgradeProxy(t, db, u) + upgradeWithCachedMetadata(t, a, u, c) + + replicas := map[string]http.Handler{"A": c.handler(a, u), "B": c.handler(validatorReplica(a, clock), u)} + cached := serve(replicas["A"], c.path, nil) + if cached.Code != http.StatusOK || strings.Contains(cached.Body.String(), "2.0.0") { + t.Fatalf("first request after the upgrade: status %d, body %s; want 200 with the document cached before it", cached.Code, cached.Body) + } + expectRevalidates(t, replicas, c.path, cached) + + expireMetadata(t, db, c.ecosystem, c.name) + clock.advance(time.Second) + changed := serve(replicas["A"], c.path, nil) + expectChangedDocument(t, cached, changed) + expectChangedForCachedValidators(t, replicas, c.path, cached, changed) + expectRevalidates(t, replicas, c.path, changed) + }) + } + } +} + +// upgradeWithCachedMetadata rolls proxy's database back to before migration +// 011, caches c's document as the proxy did then, moves upstream on to version +// 2.0.0 and migrates. The cached row and stored bytes must survive. +func upgradeWithCachedMetadata(t *testing.T, proxy *Proxy, u *validatorUpstream, c validatorCase) { + t.Helper() + rollBackMetadataDates(t, proxy.DB) + if _, _, err := proxy.FetchOrCacheMetadata(context.Background(), c.ecosystem, c.name, u.URL+c.path); err != nil { + t.Fatalf("seeding metadata before the upgrade: %v", err) + } + seeded := readCachedMetadata(t, proxy, c.ecosystem, c.name) + u.set(npmV("2.0.0"), composerV("2.0.0")) + + if err := proxy.DB.MigrateSchema(); err != nil { + t.Fatalf("MigrateSchema() error = %v", err) + } + if upgraded := readCachedMetadata(t, proxy, c.ecosystem, c.name); upgraded != seeded { + t.Fatalf("cached metadata after the upgrade = %+v, want %+v", upgraded, seeded) + } +} + +// expectChangedDocument checks that changed is the new upstream document, +// with an ETag of its own and a later date than cached. +func expectChangedDocument(t *testing.T, cached, changed *httptest.ResponseRecorder) { + t.Helper() + if changed.Code != http.StatusOK || !strings.Contains(changed.Body.String(), "2.0.0") { + t.Fatalf("after upstream changed: status %d, body %s; want 200 with the new document", changed.Code, changed.Body) + } + if changed.Header().Get("ETag") == cached.Header().Get("ETag") { + t.Error("the changed document has the cached document's ETag") + } + was, _ := http.ParseTime(cached.Header().Get("Last-Modified")) + if now, err := http.ParseTime(changed.Header().Get("Last-Modified")); err != nil || !now.After(was) { + t.Errorf("changed document Last-Modified %q, want later than %q", changed.Header().Get("Last-Modified"), cached.Header().Get("Last-Modified")) + } +} + +// expectChangedForCachedValidators checks that no replica answers a client +// holding the cached document's validators with 304 once it changed. +func expectChangedForCachedValidators(t *testing.T, replicas map[string]http.Handler, path string, cached, changed *httptest.ResponseRecorder) { + t.Helper() + held := []http.Header{ + {"If-None-Match": {cached.Header().Get("ETag")}}, + {"If-Modified-Since": {cached.Header().Get("Last-Modified")}}, + } + for name, handler := range replicas { + for _, header := range held { + if rec := serve(handler, path, header); rec.Code != http.StatusOK || rec.Body.String() != changed.Body.String() { + t.Errorf("replica %s, %v of the cached document: status %d; want 200 with the changed document", name, header, rec.Code) + } + } + } +} + +// expectRevalidates checks that every replica serves want's validators and +// answers a conditional request with either of them with 304. +func expectRevalidates(t *testing.T, replicas map[string]http.Handler, path string, want *httptest.ResponseRecorder) { + t.Helper() + etag, lastModified := want.Header().Get("ETag"), want.Header().Get("Last-Modified") + if etag == "" || lastModified == "" { + t.Fatalf("ETag %q, Last-Modified %q; want both", etag, lastModified) + } + for name, handler := range replicas { + rec := serve(handler, path, nil) + if rec.Header().Get("ETag") != etag || rec.Header().Get("Last-Modified") != lastModified { + t.Errorf("replica %s: ETag %q, Last-Modified %q; want %q, %q", name, rec.Header().Get("ETag"), rec.Header().Get("Last-Modified"), etag, lastModified) + } + for _, header := range []http.Header{{"If-None-Match": {etag}}, {"If-Modified-Since": {lastModified}}} { + if rec := serve(handler, path, header); rec.Code != http.StatusNotModified { + t.Errorf("replica %s, %v: status %d, want 304", name, header, rec.Code) + } + } + } +}