From f276db036b56fd01ad3b399406d2fe652ece307e Mon Sep 17 00:00:00 2001 From: Sendya <18x@loacg.com> Date: Wed, 19 Aug 2026 22:16:20 +0800 Subject: [PATCH 1/2] refactor: implement deterministic HTTP header fingerprinting and migrate Vary data serialization to a robust JSON-based schema --- pkg/x/http/request_key.go | 68 +++ pkg/x/http/request_key_test.go | 36 ++ pkg/x/http/varycontrol/vary.go | 167 +++--- pkg/x/http/varycontrol/vary_test.go | 51 ++ proxy/proxy.go | 5 +- proxy/proxy_test.go | 21 + server/middleware/caching/caching.go | 11 +- server/middleware/caching/caching_vary.go | 488 ++++++++++-------- .../middleware/caching/caching_vary_test.go | 294 +++++++++++ server/middleware/caching/internal.go | 5 + server/middleware/caching/processor.go | 16 +- 11 files changed, 868 insertions(+), 294 deletions(-) create mode 100644 pkg/x/http/request_key.go create mode 100644 pkg/x/http/request_key_test.go create mode 100644 pkg/x/http/varycontrol/vary_test.go create mode 100644 proxy/proxy_test.go create mode 100644 server/middleware/caching/caching_vary_test.go diff --git a/pkg/x/http/request_key.go b/pkg/x/http/request_key.go new file mode 100644 index 0000000..66e77ca --- /dev/null +++ b/pkg/x/http/request_key.go @@ -0,0 +1,68 @@ +package http + +import ( + "crypto/sha256" + "encoding/binary" + "encoding/hex" + stdhttp "net/http" + "sort" + "strings" +) + +// HeaderFingerprint returns a deterministic digest of an HTTP header map. +// It is intended for request collapsing, where requests with different +// representation-affecting headers must never share an upstream response. +func HeaderFingerprint(header stdhttp.Header) string { + type entry struct { + name string + values []string + } + + grouped := make(map[string][]entry, len(header)) + for name, values := range header { + lowerName := strings.ToLower(strings.TrimSpace(name)) + grouped[lowerName] = append(grouped[lowerName], entry{ + name: name, + values: values, + }) + } + + names := make([]string, 0, len(grouped)) + for name := range grouped { + names = append(names, name) + } + sort.Strings(names) + + digest := sha256.New() + writeSize := func(value uint64) { + var size [8]byte + binary.BigEndian.PutUint64(size[:], value) + _, _ = digest.Write(size[:]) + } + writePart := func(value string) { + writeSize(uint64(len(value))) + _, _ = digest.Write([]byte(value)) + } + + writeSize(uint64(len(names))) + for _, name := range names { + writePart(name) + + entries := grouped[name] + sort.Slice(entries, func(i, j int) bool { + return entries[i].name < entries[j].name + }) + valueCount := 0 + for _, item := range entries { + valueCount += len(item.values) + } + writeSize(uint64(valueCount)) + for _, item := range entries { + for _, value := range item.values { + writePart(value) + } + } + } + + return hex.EncodeToString(digest.Sum(nil)) +} diff --git a/pkg/x/http/request_key_test.go b/pkg/x/http/request_key_test.go new file mode 100644 index 0000000..461931e --- /dev/null +++ b/pkg/x/http/request_key_test.go @@ -0,0 +1,36 @@ +package http + +import ( + stdhttp "net/http" + "testing" +) + +func TestHeaderFingerprintIsStable(t *testing.T) { + first := stdhttp.Header{ + "User-Agent": {"test"}, + "Accept-Encoding": {"gzip"}, + } + second := stdhttp.Header{ + "accept-encoding": {"gzip"}, + "user-agent": {"test"}, + } + if HeaderFingerprint(first) != HeaderFingerprint(second) { + t.Fatal("equivalent header maps produced different fingerprints") + } +} + +func TestHeaderFingerprintIsolatesRepresentations(t *testing.T) { + gzip := stdhttp.Header{"Accept-Encoding": {"gzip"}} + brotli := stdhttp.Header{"Accept-Encoding": {"br"}} + if HeaderFingerprint(gzip) == HeaderFingerprint(brotli) { + t.Fatal("different representation headers produced the same fingerprint") + } +} + +func TestHeaderFingerprintEncodesFieldBoundaries(t *testing.T) { + first := stdhttp.Header{"A": {"b"}, "C": {"d"}} + second := stdhttp.Header{"A": {"b", "c", "d"}} + if HeaderFingerprint(first) == HeaderFingerprint(second) { + t.Fatal("different header structures produced the same fingerprint") + } +} diff --git a/pkg/x/http/varycontrol/vary.go b/pkg/x/http/varycontrol/vary.go index a4161f3..46a30d3 100644 --- a/pkg/x/http/varycontrol/vary.go +++ b/pkg/x/http/varycontrol/vary.go @@ -1,7 +1,9 @@ package varycontrol import ( + "encoding/json" "net/http" + "net/textproto" "slices" "sort" "strings" @@ -9,32 +11,72 @@ import ( const ( VaryEmptyIdentity = "tr_identity" + dataVersion = "v2:" ) type Key []string -func (k *Key) String() string { - return strings.Join(*k, ",") +type selectedHeader struct { + Name string `json:"name"` + Present bool `json:"present"` + Value string `json:"value,omitempty"` } -func (k *Key) Append(val string) { - keys := canonical(val) - if len(keys) > 0 { - if slices.Contains(*k, val) { - return +func (k Key) String() string { + return strings.Join(k, ",") +} + +// Append adds canonical Vary field names while preserving API compatibility +// with the original key helper. +func (k *Key) Append(value string) { + for _, key := range Clean(value) { + if !slices.Contains(*k, key) { + *k = append(*k, key) + } + } +} + +// HasWildcard reports whether the Vary field contains "*". Such responses +// must not be reused from a cache for a later request. +func (k Key) HasWildcard() bool { + return slices.Contains(k, "*") +} + +// VaryData serializes the selected request fields without delimiter +// ambiguity. Header names are case-insensitive, field order is stable, and an +// absent field remains distinct from a present field with an empty value. +func (k Key) VaryData(h http.Header) string { + if len(k) == 0 { + return "" + } + + fields := make([]selectedHeader, 0, len(k)) + for _, key := range k { + values, present := headerValues(h, key) + for i := range values { + values[i] = strings.TrimSpace(values[i]) } - *k = append(*k, keys...) + fields = append(fields, selectedHeader{ + Name: strings.ToLower(key), + Present: present, + Value: strings.Join(values, ","), + }) } + + data, _ := json.Marshal(fields) + return dataVersion + string(data) } -func (k *Key) VaryData(h http.Header) string { - l := len(*k) - if l <= 0 { +// LegacyVaryData reproduces the pre-v2 key format. It is retained only so +// caches written by older Tavern versions remain readable during migration. +func (k Key) LegacyVaryData(h http.Header) string { + l := len(k) + if l == 0 { return "" } kv := make(map[string]string, l) - for _, key := range *k { + for _, key := range k { v := sortValues(h.Values(key)) kv[key] = v } @@ -43,71 +85,88 @@ func (k *Key) VaryData(h http.Header) string { for key := range kv { keys = append(keys, key) } - sort.Strings(keys) var buf strings.Builder for _, key := range keys { - v := kv[key] if buf.Len() > 0 { buf.WriteByte('&') } buf.WriteString(key) buf.WriteByte('=') - buf.WriteString(v) + buf.WriteString(kv[key]) } return buf.String() } -func (k Key) Compare(k2 Key) bool { - if len(k) != len(k2) { - return false - } - - for i, vk1 := range k { - if vk1 != k2[i] { - return false - } - } - return true +func (k Key) Compare(other Key) bool { + return slices.Equal(k, other) } +// Clean parses, canonicalizes, sorts, and de-duplicates Vary field names. +// Field names are case-insensitive per HTTP semantics. func Clean(values ...string) Key { keys := make([]string, 0) - - for _, val := range values { - key := canonical(val) - if len(key) > 0 { - keys = append(keys, key...) + for _, value := range values { + for _, key := range split(value) { + if key == "*" { + keys = append(keys, key) + continue + } + keys = append(keys, textproto.CanonicalMIMEHeaderKey(key)) } } - sort.Strings(keys) + sort.Slice(keys, func(i, j int) bool { + return strings.ToLower(keys[i]) < strings.ToLower(keys[j]) + }) + return slices.CompactFunc(keys, strings.EqualFold) +} +// LegacyClean preserves the original field-name casing used in legacy cache +// keys while retaining the old sort and de-duplication behavior. +func LegacyClean(values ...string) Key { + keys := make([]string, 0) + for _, value := range values { + keys = append(keys, split(value)...) + } + sort.Strings(keys) return slices.Compact(keys) } -func canonical(val string) []string { - s := strings.TrimSpace(val) - if s == "" || s == "," { +func split(value string) []string { + value = strings.TrimSpace(value) + if value == "" || value == "," { return nil } - keys := make([]string, 0) - - vk := strings.Split(s, ",") - for _, k := range vk { - key := strings.TrimSpace(k) - if key != "" { + parts := strings.Split(value, ",") + keys := make([]string, 0, len(parts)) + for _, part := range parts { + if key := strings.TrimSpace(part); key != "" { keys = append(keys, key) } } + return keys +} - if len(keys) == 0 { - return nil +func headerValues(h http.Header, name string) ([]string, bool) { + matchingNames := make([]string, 0, 1) + for key := range h { + if strings.EqualFold(key, name) { + matchingNames = append(matchingNames, key) + } + } + if len(matchingNames) == 0 { + return nil, false } - return keys + sort.Strings(matchingNames) + values := make([]string, 0, len(matchingNames)) + for _, key := range matchingNames { + values = append(values, h[key]...) + } + return values, true } func sortValues(vals []string) string { @@ -119,9 +178,7 @@ func sortValues(vals []string) string { for _, val := range vals { v = append(v, sortValue(val)) } - sort.Strings(v) - return strings.Join(v, ",") } @@ -131,22 +188,10 @@ func sortValue(val string) string { return "" } - v := splitTrimSpace(val) - - sort.Strings(v) - - return strings.Join(v, ",") -} - -func splitTrimSpace(s string) []string { - if s == "" { - return nil - } - - v := strings.Split(s, ",") + v := strings.Split(val, ",") for i := range v { v[i] = strings.TrimSpace(v[i]) } - - return v + sort.Strings(v) + return strings.Join(v, ",") } diff --git a/pkg/x/http/varycontrol/vary_test.go b/pkg/x/http/varycontrol/vary_test.go new file mode 100644 index 0000000..fb7ad96 --- /dev/null +++ b/pkg/x/http/varycontrol/vary_test.go @@ -0,0 +1,51 @@ +package varycontrol + +import ( + "net/http" + "testing" +) + +func TestCleanCanonicalizesAndDeduplicatesFieldNames(t *testing.T) { + got := Clean("accept-encoding, User-Agent", "ACCEPT-ENCODING") + want := Key{"Accept-Encoding", "User-Agent"} + if !got.Compare(want) { + t.Fatalf("Clean() = %#v, want %#v", got, want) + } +} + +func TestVaryDataDistinguishesAbsentAndEmptyFields(t *testing.T) { + key := Clean("X-Mode") + absent := key.VaryData(http.Header{}) + empty := key.VaryData(http.Header{"X-Mode": {""}}) + if absent == empty { + t.Fatalf("absent and empty headers produced the same key %q", absent) + } +} + +func TestVaryDataPreservesValueOrder(t *testing.T) { + key := Clean("Accept-Encoding") + first := key.VaryData(http.Header{"Accept-Encoding": {"gzip, br"}}) + second := key.VaryData(http.Header{"Accept-Encoding": {"br, gzip"}}) + if first == second { + t.Fatalf("ordered header values produced the same key %q", first) + } +} + +func TestVaryDataHasNoDelimiterCollision(t *testing.T) { + key := Clean("A, B") + firstHeader := http.Header{"A": {"x&B=y"}, "B": {"z"}} + secondHeader := http.Header{"A": {"x"}, "B": {"y&B=z"}} + + if key.LegacyVaryData(firstHeader) != key.LegacyVaryData(secondHeader) { + t.Fatal("test setup does not reproduce the legacy delimiter collision") + } + if key.VaryData(firstHeader) == key.VaryData(secondHeader) { + t.Fatal("v2 Vary data retained the legacy delimiter collision") + } +} + +func TestWildcard(t *testing.T) { + if !Clean("*").HasWildcard() { + t.Fatal("Vary wildcard was not detected") + } +} diff --git a/proxy/proxy.go b/proxy/proxy.go index db92d93..0b67fbf 100644 --- a/proxy/proxy.go +++ b/proxy/proxy.go @@ -15,6 +15,7 @@ import ( "github.com/omalloc/proxy/selector/node/direct" "github.com/omalloc/proxy/selector/random" + xhttp "github.com/omalloc/tavern/pkg/x/http" "github.com/omalloc/tavern/proxy/singleflight" "github.com/prometheus/client_golang/prometheus" @@ -193,8 +194,10 @@ func (r *ReverseProxy) uncompress(resp *http.Response, err error) (*http.Respons func onceKey(req *http.Request) string { sb := strings.Builder{} sb.WriteString(req.Method) + sb.WriteByte('\x00') sb.WriteString(req.URL.String()) - sb.WriteString(req.Header.Get("Range")) + sb.WriteByte('\x00') + sb.WriteString(xhttp.HeaderFingerprint(req.Header)) return sb.String() } diff --git a/proxy/proxy_test.go b/proxy/proxy_test.go new file mode 100644 index 0000000..3f4f26a --- /dev/null +++ b/proxy/proxy_test.go @@ -0,0 +1,21 @@ +package proxy + +import ( + "net/http" + "testing" +) + +func TestOnceKeyIncludesRepresentationHeaders(t *testing.T) { + gzip, _ := http.NewRequest(http.MethodGet, "http://example.com/object", nil) + gzip.Header.Set("Accept-Encoding", "gzip") + brotli := gzip.Clone(gzip.Context()) + brotli.Header = gzip.Header.Clone() + brotli.Header.Set("Accept-Encoding", "br") + + if onceKey(gzip) == onceKey(brotli) { + t.Fatal("requests for different representations share a proxy collapse key") + } + if onceKey(gzip) != onceKey(gzip.Clone(gzip.Context())) { + t.Fatal("equivalent requests have different proxy collapse keys") + } +} diff --git a/server/middleware/caching/caching.go b/server/middleware/caching/caching.go index 0639a51..9a7b636 100644 --- a/server/middleware/caching/caching.go +++ b/server/middleware/caching/caching.go @@ -172,7 +172,8 @@ func Middleware(c *configv1.Middleware) (middleware.Middleware, func(), error) { // concurrent requests for the same cache object share one // origin fetch (Squid-style collapsed_forwarding). if opts.CollapsedRequest { - flightResp, _, flightErr := objectFlight.Do(caching.id.HashStr(), opts.CollapsedRequestWaitTimeout.AsDuration(), func() (*http.Response, error) { + flightKey := caching.id.HashStr() + ":" + xhttp.HeaderFingerprint(req.Header) + flightResp, _, flightErr := objectFlight.Do(flightKey, opts.CollapsedRequestWaitTimeout.AsDuration(), func() (*http.Response, error) { r, e := caching.doProxy(req, false) if e != nil { return nil, e @@ -515,6 +516,10 @@ func (c *Caching) flushbufferSlice(respRange xhttp.ContentRange) (iobuf.EventSuc }() writerBuffer := func(buf []byte, index uint32, current uint64, eof bool) error { + if !c.cacheable { + return nil + } + f, wpath, err := c.bucket.WriteChunkFile(c.req.Context(), c.id, index) if err != nil { cacheChunkWriteTotal.With(prometheus.Labels{"result": "failed", "store_type": c.bucket.StoreType()}).Inc() @@ -576,6 +581,10 @@ func (c *Caching) flushbufferSlice(respRange xhttp.ContentRange) (iobuf.EventSuc } writerCloser := func(eof bool) { + if !c.cacheable { + return + } + if !eof && chunked { _ = c.bucket.DiscardWithMessage(c.req.Context(), c.id, "incomplete chunked file discard") return diff --git a/server/middleware/caching/caching_vary.go b/server/middleware/caching/caching_vary.go index 0ffb0fa..6d5c6f2 100644 --- a/server/middleware/caching/caching_vary.go +++ b/server/middleware/caching/caching_vary.go @@ -1,35 +1,26 @@ package caching import ( - "context" "errors" "fmt" "net/http" "os" "slices" - "strconv" + "strings" "time" "github.com/kelindar/bitmap" + storagev1 "github.com/omalloc/tavern/api/defined/v1/storage" "github.com/omalloc/tavern/api/defined/v1/storage/object" - "github.com/omalloc/tavern/contrib/log" - xhttp "github.com/omalloc/tavern/pkg/x/http" "github.com/omalloc/tavern/pkg/x/http/varycontrol" ) var ( - ErrHeaderNoMatchVaryKey = errors.New("header no match vary key") - ErrHeaderNoMatchVaryData = errors.New("header no match vary data") - - // ErrVarySizeLimited indicates the number of Vary versions has exceeded the maximum limit. ErrVarySizeLimited = errors.New("vary size exceed limit") - - // ErrVaryDowngradeNormal indicates the cache has been downgraded from Vary to normal cache. - ErrVaryDowngradeNormal = errors.New("vary downgrade to normal cache") + ErrVaryWildcard = errors.New("vary wildcard response is not reusable") ) -// Compile-time interface implementation check. var _ Processor = (*VaryProcessor)(nil) type VaryOption func(r *VaryProcessor) @@ -39,309 +30,354 @@ type VaryProcessor struct { varyIgnoreKey map[string]struct{} } -// Lookup checks if a cached response exists for the given request. -// It returns true if a matching Vary cache entry is found, false otherwise. -// -// The lookup process: -// 1. Check if the request has no-cache directive -// 2. Verify if the cached object has Vary index -// 3. Find the matching Vary cache based on request headers -func (v *VaryProcessor) Lookup(caching *Caching, req *http.Request) (bool, error) { - if caching.hasNoCache() { +func (v *VaryProcessor) Lookup(c *Caching, req *http.Request) (bool, error) { + if c.hasNoCache() { return false, nil } - - // Check if the cached object has Vary index. - if !caching.md.IsVary() { + if !c.md.IsVary() { return true, nil } - // Find the matching Vary cache. - vmd := v.lookup(caching, req) + rawValues := c.md.Headers.Values("Vary") + rawVary := varycontrol.Clean(rawValues...) + varyKey := v.filter(rawVary) + if rawVary.HasWildcard() || len(varyKey) == 0 { + v.discardRoot(c, "invalid or fully ignored Vary index") + c.md = nil + c.rootmd = nil + c.id = c.cacheRootID() + return false, nil + } + + varyData := varyKey.VaryData(req.Header) + vid, err := newObjectIDFromRequest(req, varyData, c.opt.IncludeQueryInCacheKey) + if err != nil { + return false, err + } + + vmd, lookupErr := c.bucket.Lookup(req.Context(), vid) + if lookupErr != nil && !errors.Is(lookupErr, storagev1.ErrKeyNotFound) { + return false, lookupErr + } + + // Fall back to the pre-v2 key so existing cache entries remain readable. + if vmd == nil { + legacyKey := v.filterLegacy(varycontrol.LegacyClean(rawValues...)) + legacyData := legacyKey.LegacyVaryData(req.Header) + legacyID, legacyErr := newObjectIDFromRequest(req, legacyData, c.opt.IncludeQueryInCacheKey) + if legacyErr != nil { + return false, legacyErr + } + if legacyID.HashStr() != vid.HashStr() { + vmd, lookupErr = c.bucket.Lookup(req.Context(), legacyID) + if lookupErr != nil && !errors.Is(lookupErr, storagev1.ErrKeyNotFound) { + return false, lookupErr + } + } + } + if vmd == nil { - // MISS: No matching Vary cache found for current request. + // A known Vary index lets request collapsing isolate this missing + // representation before the origin response arrives. + c.id = vid return false, nil } - // HIT: Found matching Vary cache, update caching context. - caching.rootmd = caching.md - caching.id = vmd.ID - caching.md = vmd + c.rootmd = c.md + c.id = vmd.ID + c.md = vmd return true, nil } -// PreRequest performs pre-processing before the request is forwarded to the origin. -// Currently, this is a no-op for VaryProcessor. func (v *VaryProcessor) PreRequest(_ *Caching, req *http.Request) (*http.Request, error) { return req, nil } -// PostRequest processes the response from the origin server and handles Vary caching. -// It converts the response to a Vary-aware cache structure if the response contains -// Vary headers. -func (v *VaryProcessor) PostRequest(caching *Caching, req *http.Request, resp *http.Response) (*http.Response, error) { - if caching.md.IsVaryCache() { - return resp, nil - } - - // Convert to Vary metadata and upgrade cache structure if needed. - varyMetadata, err := v.convertVaryMetadata(caching, resp) - if err != nil && !errors.Is(err, ErrHeaderNoMatchVaryKey) { - caching.log.Errorf("PostRequest convertVaryMetadata failed: %s", err) +func (v *VaryProcessor) PostRequest(c *Caching, _ *http.Request, resp *http.Response) (*http.Response, error) { + rawResponseVary := varycontrol.Clean(resp.Header.Values("Vary")...) + if rawResponseVary.HasWildcard() { + c.cacheable = false + if resp.StatusCode != http.StatusNotModified { + v.discardRoot(c, ErrVaryWildcard.Error()) + } return resp, nil } - // No Vary header matched, return original response. - if varyMetadata == nil { - return resp, nil + responseVary := v.filter(rawResponseVary) + var err error + + switch { + case c.md.IsVaryCache(): + err = v.handleCachedVariant(c, resp, responseVary) + case c.md.IsVary(): + err = v.handleVaryIndex(c, resp, responseVary) + case len(responseVary) > 0: + if c.md.Chunks.Count() > 0 { + v.discardRoot(c, "upgrading cache to Vary structure") + c.md.Chunks.Clear() + c.md.Parts.Clear() + } + err = v.createVariant(c, responseVary) } - // Build Vary index for retrieving all Vary versions of this URL. - originVary := caching.md.Headers.Values("Vary") - originVary = append(originVary, resp.Header.Values("Vary")...) - - caching.rootmd = caching.md - caching.rootmd.ID = caching.id - caching.rootmd.Size = 0 - caching.rootmd.Flags = object.FlagVaryIndex - caching.rootmd.Chunks = bitmap.Bitmap{} - caching.rootmd.Parts = bitmap.Bitmap{} - caching.rootmd.Headers = http.Header{ - "Vary": varycontrol.Clean(originVary...), + if err != nil { + c.log.Errorf("Vary processing failed: %v", err) + c.cacheable = false } - - // Inherit timestamps from root metadata. - varyMetadata.RespUnix = caching.rootmd.RespUnix - varyMetadata.LastRefUnix = caching.rootmd.LastRefUnix - varyMetadata.ExpiresAt = caching.rootmd.ExpiresAt - - caching.md = varyMetadata - caching.id = varyMetadata.ID - return resp, nil } -// lookup finds the matching Vary cache entry based on request headers. -func (v *VaryProcessor) lookup(caching *Caching, req *http.Request) *object.Metadata { - varyKey := varycontrol.Clean(caching.md.Headers.Values("Vary")...) +func (v *VaryProcessor) handleCachedVariant(c *Caching, resp *http.Response, responseVary varycontrol.Key) error { + storedVary := v.storedVary(c) - // Generate object ID based on Vary data from request headers. - vid, err := newObjectIDFromRequest(req, varyKey.VaryData(req.Header), caching.opt.IncludeQueryInCacheKey) - if err != nil { + // A 304 response commonly omits representation metadata. Keep serving + // the selected stored response; a non-empty changed Vary is logged and + // handled on the next full representation response. + if resp.StatusCode == http.StatusNotModified { + if len(responseVary) > 0 && !storedVary.Compare(responseVary) { + c.log.Warnf("origin changed Vary on a 304 response; retaining stored selection fields") + } return nil } - vmd, err := caching.bucket.Lookup(req.Context(), vid) - if err != nil { + // Partial responses are allowed to omit Vary in practice. The root + // selection fields remain authoritative for range filling. + if resp.StatusCode == http.StatusPartialContent && len(responseVary) == 0 { return nil } - return vmd -} + if len(responseVary) == 0 { + return v.downgradeToNormal(c, "origin removed Vary") + } + if storedVary.Compare(responseVary) { + return nil + } -// convertVaryMetadata handles the conversion and creation of Vary-aware cache metadata. -// It processes both cases: -// - When the origin response has no Vary header but cached metadata has Vary info -// - When the origin response contains Vary header -func (v *VaryProcessor) convertVaryMetadata(caching *Caching, resp *http.Response) (*object.Metadata, error) { - metaVary := varycontrol.Clean(caching.md.Headers.Values("Vary")...) - respVary := varycontrol.Clean(resp.Header.Values("Vary")...) + return v.rebuildVary(c, responseVary, "origin changed Vary") +} - if caching.log.Enabled(log.LevelDebug) && (len(metaVary) > 0 || len(respVary) > 0) { - caching.log.Debugf("convertVaryMetadata: metaVaryKey: %s, respVaryKey: %s", metaVary, respVary) +func (v *VaryProcessor) handleVaryIndex(c *Caching, resp *http.Response, responseVary varycontrol.Key) error { + storedVary := v.filter(varycontrol.Clean(c.md.Headers.Values("Vary")...)) + if len(responseVary) == 0 { + if resp.StatusCode == http.StatusPartialContent { + responseVary = storedVary + } else { + return v.downgradeToNormal(c, "origin removed Vary") + } } - // Case 1: Origin response has no Vary header. - if len(respVary) <= 0 { - return v.handleNoResponseVary(caching, resp, metaVary) + if !storedVary.Compare(responseVary) { + return v.rebuildVary(c, responseVary, "origin changed Vary") } + return v.createVariant(c, responseVary) +} - // Case 2: Origin response has Vary header. - return v.handleResponseVary(caching, resp, metaVary, respVary) +func (v *VaryProcessor) rebuildVary(c *Caching, responseVary varycontrol.Key, reason string) error { + v.discardRoot(c, reason) + v.resetToRoot(c) + return v.createVariant(c, responseVary) } -// handleNoResponseVary processes the case when origin response has no Vary header. -func (v *VaryProcessor) handleNoResponseVary(caching *Caching, resp *http.Response, metaVary varycontrol.Key) (*object.Metadata, error) { - // No cached Vary info exists, skip Vary processing. - if len(metaVary) <= 0 { - return nil, ErrHeaderNoMatchVaryKey +func (v *VaryProcessor) downgradeToNormal(c *Caching, reason string) error { + v.discardRoot(c, reason) + v.resetToRoot(c) + return nil +} + +func (v *VaryProcessor) resetToRoot(c *Caching) { + rootID := c.cacheRootID() + metadata := c.md.Clone() + metadata.ID = rootID + metadata.Flags = object.FlagCache + metadata.VirtualKey = nil + metadata.Chunks = bitmap.Bitmap{} + metadata.Parts = bitmap.Bitmap{} + metadata.Headers.Del("Vary") + + c.id = rootID + c.md = metadata + c.rootmd = nil +} + +func (v *VaryProcessor) createVariant(c *Caching, varyKey varycontrol.Key) error { + varyData := varyKey.VaryData(c.req.Header) + virtualKeys, err := appendVariantKey(c.md.VirtualKey, varyData, v.maxLimit) + if err != nil { + return err } - // Generate Vary data from current request headers. - varyData := metaVary.VaryData(caching.req.Header) - if varyData == "" { - // Request headers don't match Vary requirements, discard old cache. - if err := caching.bucket.Discard(context.Background(), caching.id); err != nil { - caching.log.Errorf("request header not match vary, discard old cache err: %v", err) - } - if caching.rootmd != nil { - caching.rootmd = nil - } - return nil, ErrHeaderNoMatchVaryData + variantID, err := newObjectIDFromRequest(c.req, varyData, c.opt.IncludeQueryInCacheKey) + if err != nil { + return fmt.Errorf("create Vary object ID: %w", err) } - // Check Vary version limit. - if len(caching.md.VirtualKey) >= v.maxLimit { - caching.log.Errorf("vary version exceed limit: %d", len(caching.md.VirtualKey)) - return nil, ErrVarySizeLimited + root := c.md.Clone() + root.ID = c.cacheRootID() + root.Size = 0 + root.Flags = object.FlagVaryIndex + root.Chunks = bitmap.Bitmap{} + root.Parts = bitmap.Bitmap{} + root.VirtualKey = virtualKeys + root.Headers = http.Header{"Vary": append([]string(nil), varyKey...)} + + headers := c.md.Headers.Clone() + headers.Del("Vary") + for _, key := range varyKey { + headers.Add("Vary", key) } - // Append new Vary data if not already exists. - if !slices.Contains(caching.md.VirtualKey, varyData) { - caching.md.VirtualKey = append(caching.md.VirtualKey, varyData) - } else { - caching.log.Debugf("vary data already exist: %s", varyData) + now := time.Now().Unix() + variant := &object.Metadata{ + ID: variantID, + RespUnix: c.md.RespUnix, + LastRefUnix: now, + Code: c.md.Code, + Size: c.md.Size, + BlockSize: c.md.BlockSize, + Chunks: bitmap.Bitmap{}, + Parts: bitmap.Bitmap{}, + Headers: headers, + ExpiresAt: c.md.ExpiresAt, + Flags: object.FlagVaryCache, } - l2MetaID, err := newObjectIDFromRequest(caching.req, varyData, caching.opt.IncludeQueryInCacheKey) - if err != nil { - return nil, fmt.Errorf("failed to create hash-key: %w", err) - } - - cl, _ := strconv.Atoi(resp.Header.Get("Content-Length")) - return &object.Metadata{ - ID: l2MetaID, - RespUnix: time.Now().Unix(), - Code: resp.StatusCode, - Size: uint64(cl), - BlockSize: caching.md.BlockSize, - Chunks: bitmap.Bitmap{}, - Parts: bitmap.Bitmap{}, - Headers: resp.Header.Clone(), - ExpiresAt: caching.md.ExpiresAt, - Flags: object.FlagVaryCache, - }, nil + c.rootmd = root + c.md = variant + c.id = variantID + return nil } -// handleResponseVary processes the case when origin response has Vary header. -func (v *VaryProcessor) handleResponseVary(caching *Caching, resp *http.Response, metaVary, respVary varycontrol.Key) (*object.Metadata, error) { - var varyData string - - // Cached Vary key exists, compare with response Vary key. - if len(metaVary) > 0 { - if metaVary.Compare(respVary) { - // Vary keys match, try to find existing Vary cache. - varyData = metaVary.VaryData(caching.req.Header) - varyKey, _ := newObjectIDFromRequest(caching.req, varyData, caching.opt.IncludeQueryInCacheKey) - varyMeta, err := caching.bucket.Lookup(caching.req.Context(), varyKey) - if err != nil { - caching.log.Warnf("Vary key lookup failed: %v", err) - } - if varyMeta != nil { - caching.log.Debugf("Vary header match, returning existing vary metadata") - return varyMeta, nil - } - } else { - // Vary keys differ, origin has updated Vary header, rebuild cache. - caching.log.Infof("Vary header changed, rebuilding vary cache") - if discardErr := caching.bucket.Discard(context.Background(), caching.id); discardErr != nil && !os.IsNotExist(discardErr) { - caching.log.Errorf("error discarding old vary cache: %s", discardErr) - } +func (v *VaryProcessor) storedVary(c *Caching) varycontrol.Key { + if c.rootmd != nil { + return v.filter(varycontrol.Clean(c.rootmd.Headers.Values("Vary")...)) + } + return v.filter(varycontrol.Clean(c.md.Headers.Values("Vary")...)) +} - caching.md.VirtualKey = nil - if len(respVary) <= 0 { - // Origin removed Vary header, downgrade to normal cache. - caching.md.Headers.Del("Vary") - caching.md.Flags = object.FlagCache - caching.log.Debugf("Vary header removed by origin, downgrading to normal cache") - return nil, ErrVaryDowngradeNormal - } +func (v *VaryProcessor) filter(keys varycontrol.Key) varycontrol.Key { + return slices.DeleteFunc(keys, func(key string) bool { + _, ignored := v.varyIgnoreKey[strings.ToLower(key)] + return ignored + }) +} - varyData = respVary.VaryData(caching.req.Header) - } +func (v *VaryProcessor) filterLegacy(keys varycontrol.Key) varycontrol.Key { + return slices.DeleteFunc(keys, func(key string) bool { + _, ignored := v.varyIgnoreKey[strings.ToLower(key)] + return ignored + }) +} - // Build new Vary cache object. - varyObjectID, _ := newObjectIDFromRequest(caching.req, varyData, caching.opt.IncludeQueryInCacheKey) - return v.upgrade(caching, resp, varyObjectID, varyData) +func (v *VaryProcessor) discardRoot(c *Caching, reason string) { + rootID := c.cacheRootID() + if rootID == nil || c.bucket == nil { + return + } + if err := c.bucket.DiscardWithMessage(c.req.Context(), rootID, reason); err != nil && + !errors.Is(err, storagev1.ErrKeyNotFound) && !os.IsNotExist(err) { + c.log.Errorf("discard Vary root failed: %v", err) } - // No metaVary exists, this is the first Vary request for this resource. - if caching.md.Chunks.Count() > 0 { - if discardErr := caching.bucket.DiscardWithMessage(context.Background(), caching.id, "upgrading cache to vary structure"); discardErr != nil { - caching.log.Errorf("error discarding cache for vary upgrade: %s", discardErr) + // Old or damaged root indexes may not list the active child. Remove it + // explicitly as well so a rebuild cannot leave that variant orphaned. + if c.id != nil && c.id.HashStr() != rootID.HashStr() { + if err := c.bucket.DiscardWithMessage(c.req.Context(), c.id, reason); err != nil && + !errors.Is(err, storagev1.ErrKeyNotFound) && !os.IsNotExist(err) { + c.log.Errorf("discard active Vary variant failed: %v", err) } } - - caching.md.VirtualKey = nil - varyData = respVary.VaryData(caching.req.Header) - varyObjectID, _ := newObjectIDFromRequest(caching.req, varyData, caching.opt.IncludeQueryInCacheKey) - return v.upgrade(caching, resp, varyObjectID, varyData) } -// upgrade converts a normal cache object to a Vary-aware cache structure. -// It creates a new Vary metadata entry and updates the cache flags. -func (v *VaryProcessor) upgrade(c *Caching, resp *http.Response, id *object.ID, varyData string) (*object.Metadata, error) { - virtualKey := varycontrol.Clean(append(c.md.VirtualKey, varyData)...) - - if len(virtualKey) > v.maxLimit { - c.log.Errorf("Vary version limit exceeded: %d", len(c.md.VirtualKey)) +func appendVariantKey(existing []string, key string, limit int) ([]string, error) { + keys := make([]string, 0, len(existing)+1) + for _, item := range existing { + if !slices.Contains(keys, item) { + keys = append(keys, item) + } + } + if slices.Contains(keys, key) { + return keys, nil + } + if limit <= 0 || len(keys) >= limit { return nil, ErrVarySizeLimited } + return append(keys, key), nil +} - c.md.VirtualKey = virtualKey - c.md.ID = id - c.md.Flags = object.FlagVaryIndex +func (c *Caching) cacheRootID() *object.ID { + if c.rootID != nil { + return c.rootID + } + if c.rootmd != nil && c.rootmd.ID != nil { + c.rootID = c.rootmd.ID + return c.rootID + } + if c.md != nil && c.md.IsVary() && c.md.ID != nil { + c.rootID = c.md.ID + return c.rootID + } + if c.id != nil { + c.rootID = object.NewVirtualID(c.id.Path(), "") + } + return c.rootID +} - // Merge Vary headers from response and existing metadata. - headers := c.md.Headers.Clone() - newVaryKey := resp.Header.Values("Vary") - newVaryKey = append(newVaryKey, headers.Values("Vary")...) - headers.Del("Vary") - for _, key := range varycontrol.Clean(newVaryKey...) { - headers.Add("Vary", key) +// storeRootMetadata serializes read/merge/write of the root Vary index inside +// this process. This prevents concurrent representation misses from losing +// each other's VirtualKey entries. +func (c *Caching) storeRootMetadata() error { + root := c.rootmd + if root == nil || root.ID == nil { + return nil } - // Parse content length from response. - cr, err := xhttp.ParseContentRange(resp.Header) - if err != nil { - c.log.Debugf("ParseContentRange failed (may be chunked response): %v", err) + lock := globalLocker.getLock(root.ID.HashStr()) + lock.Lock() + defer lock.Unlock() + + latest, err := c.bucket.Lookup(c.req.Context(), root.ID) + if err != nil && !errors.Is(err, storagev1.ErrKeyNotFound) && !os.IsNotExist(err) { + return err + } + if latest != nil && latest.IsVary() { + latestVary := varycontrol.Clean(latest.Headers.Values("Vary")...) + rootVary := varycontrol.Clean(root.Headers.Values("Vary")...) + if latestVary.Compare(rootVary) { + merged := append([]string(nil), latest.VirtualKey...) + for _, key := range root.VirtualKey { + merged, err = appendVariantKey(merged, key, c.opt.VaryLimit) + if err != nil { + return err + } + } + root.VirtualKey = merged + } } - c.log.Infof("Vary upgrade completed, content-length: %d", cr.ObjSize) - // Create new Vary metadata. - now := time.Now().Unix() - return &object.Metadata{ - ID: id, - RespUnix: now, - LastRefUnix: now, - Code: resp.StatusCode, - Size: cr.ObjSize, - BlockSize: c.md.BlockSize, - Chunks: bitmap.Bitmap{}, - Parts: bitmap.Bitmap{}, - Headers: headers, - ExpiresAt: c.md.ExpiresAt, - Flags: object.FlagVaryCache, - }, nil + return c.bucket.Store(c.req.Context(), root) } -// NewVaryProcessor creates a new VaryProcessor with the given options. -// Default configuration: -// - maxLimit: 100 (maximum Vary versions per URL) func NewVaryProcessor(opts ...VaryOption) *VaryProcessor { v := &VaryProcessor{ maxLimit: 100, varyIgnoreKey: make(map[string]struct{}), } - for _, opt := range opts { opt(v) } return v } -// WithVaryMaxLimit sets the maximum number of Vary versions allowed per URL. func WithVaryMaxLimit(limit int) VaryOption { return func(r *VaryProcessor) { r.maxLimit = limit } } -// WithVaryIgnoreKeys specifies header keys to be ignored during Vary processing. func WithVaryIgnoreKeys(keys ...string) VaryOption { return func(r *VaryProcessor) { for _, key := range keys { - r.varyIgnoreKey[key] = struct{}{} + r.varyIgnoreKey[strings.ToLower(strings.TrimSpace(key))] = struct{}{} } } } diff --git a/server/middleware/caching/caching_vary_test.go b/server/middleware/caching/caching_vary_test.go new file mode 100644 index 0000000..e2b5b02 --- /dev/null +++ b/server/middleware/caching/caching_vary_test.go @@ -0,0 +1,294 @@ +package caching + +import ( + "errors" + "net/http" + "slices" + "strings" + "testing" + "time" + + "github.com/kelindar/bitmap" + + storagev1 "github.com/omalloc/tavern/api/defined/v1/storage" + "github.com/omalloc/tavern/api/defined/v1/storage/object" + "github.com/omalloc/tavern/contrib/log" + "github.com/omalloc/tavern/pkg/x/http/varycontrol" + "github.com/omalloc/tavern/storage/bucket/memory" + "github.com/omalloc/tavern/storage/sharedkv" +) + +func newVaryTestCaching(t *testing.T, req *http.Request) *Caching { + t.Helper() + + bucket, err := memory.New(&storagev1.BucketConfig{}, sharedkv.NewEmpty()) + if err != nil { + t.Fatalf("create memory bucket: %v", err) + } + t.Cleanup(func() { _ = bucket.Close() }) + + rootID, err := newObjectIDFromRequest(req, "", true) + if err != nil { + t.Fatalf("create root ID: %v", err) + } + now := time.Now().Unix() + return &Caching{ + log: log.NewHelper(log.GetLogger()), + req: req, + ctx: req.Context(), + rootID: rootID, + id: rootID, + bucket: bucket, + cacheable: true, + opt: &cachingOption{ + IncludeQueryInCacheKey: true, + SliceSize: 1024, + VaryLimit: 100, + }, + md: &object.Metadata{ + ID: rootID, + BlockSize: 1024, + Size: 16, + Code: http.StatusOK, + RespUnix: now, + LastRefUnix: now, + ExpiresAt: now + 60, + Headers: make(http.Header), + Chunks: bitmap.Bitmap{}, + Parts: bitmap.Bitmap{}, + }, + } +} + +func TestVaryPostRequestStoresCommaValueAsOneVariant(t *testing.T) { + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://example.com/object", nil) + req.Header.Set("Accept-Encoding", "gzip, br") + c := newVaryTestCaching(t, req) + + processor := NewVaryProcessor() + resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Vary": {"Accept-Encoding"}}} + _, err := processor.PostRequest(c, req, resp) + if err != nil { + t.Fatalf("PostRequest() error = %v", err) + } + + if c.rootmd == nil || len(c.rootmd.VirtualKey) != 1 { + t.Fatalf("root variants = %#v, want one opaque variant key", c.rootmd) + } + if !strings.HasPrefix(c.rootmd.VirtualKey[0], "v2:") { + t.Fatalf("variant key = %q, want v2 key", c.rootmd.VirtualKey[0]) + } + if !c.md.IsVaryCache() { + t.Fatalf("metadata flag = %s, want VARY_CACHE", c.md.Flags) + } +} + +func TestVaryIgnoreKeyPreventsVariantIndex(t *testing.T) { + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://example.com/object", nil) + req.Header.Set("Cookie", "session=one") + c := newVaryTestCaching(t, req) + + processor := NewVaryProcessor(WithVaryIgnoreKeys("cookie")) + resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Vary": {"Cookie"}}} + _, err := processor.PostRequest(c, req, resp) + if err != nil { + t.Fatalf("PostRequest() error = %v", err) + } + if c.rootmd != nil || c.md.IsVaryCache() { + t.Fatalf("ignored Vary field created a variant index: root=%v flags=%s", c.rootmd, c.md.Flags) + } +} + +func TestVaryIgnoreKeyInvalidatesExistingIndex(t *testing.T) { + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://example.com/object", nil) + c := newVaryTestCaching(t, req) + c.md.Flags = object.FlagVaryIndex + c.md.Size = 0 + c.md.Headers.Set("Vary", "Cookie") + c.md.VirtualKey = []string{"legacy-cookie-variant"} + if err := c.bucket.Store(t.Context(), c.md); err != nil { + t.Fatalf("store root metadata: %v", err) + } + + hit, err := NewVaryProcessor(WithVaryIgnoreKeys("COOKIE")).Lookup(c, req) + if err != nil { + t.Fatalf("Lookup() error = %v", err) + } + if hit || c.md != nil { + t.Fatalf("fully ignored index hit=%t metadata=%v", hit, c.md) + } + if md, lookupErr := c.bucket.Lookup(t.Context(), c.rootID); md != nil || !errors.Is(lookupErr, storagev1.ErrKeyNotFound) { + t.Fatalf("ignored root still exists: metadata=%v error=%v", md, lookupErr) + } +} + +func TestVaryWildcardDisablesAndDiscardsCache(t *testing.T) { + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://example.com/object", nil) + c := newVaryTestCaching(t, req) + if err := c.bucket.Store(t.Context(), c.md); err != nil { + t.Fatalf("store root metadata: %v", err) + } + + processor := NewVaryProcessor() + resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Vary": {"*"}}} + _, err := processor.PostRequest(c, req, resp) + if err != nil { + t.Fatalf("PostRequest() error = %v", err) + } + if c.cacheable { + t.Fatal("Vary wildcard response remained cacheable") + } + if md, lookupErr := c.bucket.Lookup(t.Context(), c.rootID); md != nil || !errors.Is(lookupErr, storagev1.ErrKeyNotFound) { + t.Fatalf("wildcard root still exists: metadata=%v error=%v", md, lookupErr) + } +} + +func TestVaryLookupFallsBackToLegacyKey(t *testing.T) { + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://example.com/object", nil) + req.Header.Set("Accept-Encoding", "gzip") + c := newVaryTestCaching(t, req) + + root := c.md + root.Flags = object.FlagVaryIndex + root.Size = 0 + root.Headers.Set("Vary", "accept-encoding") + legacyKey := varycontrol.LegacyClean(root.Headers.Values("Vary")...) + legacyData := legacyKey.LegacyVaryData(req.Header) + legacyID, _ := newObjectIDFromRequest(req, legacyData, true) + root.VirtualKey = []string{legacyData} + variant := root.Clone() + variant.ID = legacyID + variant.Flags = object.FlagVaryCache + variant.Size = 16 + if err := c.bucket.Store(t.Context(), root); err != nil { + t.Fatalf("store root: %v", err) + } + if err := c.bucket.Store(t.Context(), variant); err != nil { + t.Fatalf("store legacy variant: %v", err) + } + + hit, err := NewVaryProcessor().Lookup(c, req) + if err != nil { + t.Fatalf("Lookup() error = %v", err) + } + if !hit || c.id.HashStr() != legacyID.HashStr() { + t.Fatalf("legacy lookup hit=%t id=%s, want %s", hit, c.id.HashStr(), legacyID.HashStr()) + } +} + +func TestVaryMissSelectsVariantIDBeforeRequestCollapse(t *testing.T) { + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://example.com/object", nil) + req.Header.Set("Accept-Encoding", "br") + c := newVaryTestCaching(t, req) + c.md.Flags = object.FlagVaryIndex + c.md.Size = 0 + c.md.Headers.Set("Vary", "Accept-Encoding") + + hit, err := NewVaryProcessor().Lookup(c, req) + if err != nil { + t.Fatalf("Lookup() error = %v", err) + } + if hit { + t.Fatal("unexpected Vary hit") + } + if c.id.HashStr() == c.rootID.HashStr() || !strings.HasPrefix(c.id.Ext(), "v2:") { + t.Fatalf("miss ID = %s, want a v2 variant ID distinct from root", c.id.String()) + } +} + +func TestVaryRemovalDowngradesToNormalCache(t *testing.T) { + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://example.com/object", nil) + req.Header.Set("User-Agent", "agent-a") + c := newVaryTestCaching(t, req) + + varyKey := varycontrol.Clean("User-Agent") + varyData := varyKey.VaryData(req.Header) + variantID, _ := newObjectIDFromRequest(req, varyData, true) + root := c.md.Clone() + root.Flags = object.FlagVaryIndex + root.Size = 0 + root.Headers = http.Header{"Vary": {"User-Agent"}} + root.VirtualKey = []string{varyData} + variant := c.md.Clone() + variant.ID = variantID + variant.Flags = object.FlagVaryCache + variant.Headers.Set("Vary", "User-Agent") + if err := c.bucket.Store(t.Context(), root); err != nil { + t.Fatalf("store root: %v", err) + } + if err := c.bucket.Store(t.Context(), variant); err != nil { + t.Fatalf("store variant: %v", err) + } + c.rootmd = root + c.md = variant + c.id = variantID + + resp := &http.Response{StatusCode: http.StatusOK, Header: make(http.Header)} + _, err := NewVaryProcessor().PostRequest(c, req, resp) + if err != nil { + t.Fatalf("PostRequest() error = %v", err) + } + if c.rootmd != nil || c.md.Flags != object.FlagCache || c.id.HashStr() != c.rootID.HashStr() { + t.Fatalf("cache was not downgraded: root=%v flags=%s id=%s", c.rootmd, c.md.Flags, c.id.HashStr()) + } +} + +func TestStoreRootMetadataMergesConcurrentVariants(t *testing.T) { + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://example.com/object", nil) + first := newVaryTestCaching(t, req) + second := &Caching{ + log: first.log, + req: first.req, + ctx: first.ctx, + rootID: first.rootID, + id: first.id, + bucket: first.bucket, + cacheable: true, + opt: first.opt, + } + + newRoot := func(key string) *object.Metadata { + root := first.md.Clone() + root.ID = first.rootID + root.Flags = object.FlagVaryIndex + root.Size = 0 + root.Headers = http.Header{"Vary": {"User-Agent"}} + root.VirtualKey = []string{key} + return root + } + newVariant := func(key string) *object.Metadata { + variant := first.md.Clone() + variant.ID = object.NewVirtualID(first.rootID.Path(), key) + variant.Flags = object.FlagVaryCache + return variant + } + + first.rootmd = newRoot("variant-a") + first.md = newVariant("variant-a") + second.rootmd = newRoot("variant-b") + second.md = newVariant("variant-b") + if err := first.storeRootMetadata(); err != nil { + t.Fatalf("store first root: %v", err) + } + if err := second.storeRootMetadata(); err != nil { + t.Fatalf("store second root: %v", err) + } + + root, err := first.bucket.Lookup(t.Context(), first.rootID) + if err != nil { + t.Fatalf("lookup merged root: %v", err) + } + if len(root.VirtualKey) != 2 || !slices.Contains(root.VirtualKey, "variant-a") || !slices.Contains(root.VirtualKey, "variant-b") { + t.Fatalf("merged variants = %#v", root.VirtualKey) + } +} + +func TestAppendVariantKeyEnforcesLimitAfterDeduplication(t *testing.T) { + keys, err := appendVariantKey([]string{"same", "same"}, "same", 1) + if err != nil || len(keys) != 1 { + t.Fatalf("deduplicate existing key: keys=%#v error=%v", keys, err) + } + if _, err := appendVariantKey(keys, "new", 1); !errors.Is(err, ErrVarySizeLimited) { + t.Fatalf("limit error = %v, want %v", err, ErrVarySizeLimited) + } +} diff --git a/server/middleware/caching/internal.go b/server/middleware/caching/internal.go index 99b1668..d6833dd 100644 --- a/server/middleware/caching/internal.go +++ b/server/middleware/caching/internal.go @@ -40,6 +40,7 @@ type Caching struct { opt *cachingOption req *http.Request ctx context.Context + rootID *object.ID id *object.ID md *object.Metadata rootmd *object.Metadata @@ -110,6 +111,10 @@ func (c *Caching) hasNoCache() bool { } func (c *Caching) reset() { + c.rootID = nil + c.id = nil + c.md = nil + c.rootmd = nil c.cacheable = false c.hit = false c.prefetch = false diff --git a/server/middleware/caching/processor.go b/server/middleware/caching/processor.go index 6ddd94e..2f78070 100644 --- a/server/middleware/caching/processor.go +++ b/server/middleware/caching/processor.go @@ -97,6 +97,7 @@ func (pc *ProcessorChain) preCacheProcessor(proxyClient proxy.Proxy, store stora return caching, fmt.Errorf("failed new object-objectID from request err: %w", err) } caching.id = objectID + caching.rootID = objectID // Select storage bucket by object ID // hashring or diskhash @@ -134,13 +135,18 @@ func (pc *ProcessorChain) postCacheProcessor(caching *Caching, req *http.Request } if caching.cacheable { - // HEAD request need store metadata - if req.Method == http.MethodHead { - _ = caching.bucket.Store(caching.req.Context(), caching.md) + if caching.rootmd != nil { + if err := caching.storeRootMetadata(); err != nil { + caching.log.Errorf("failed to store Vary root metadata: %v", err) + caching.cacheable = false + } } - if caching.rootmd != nil { - _ = caching.bucket.Store(caching.req.Context(), caching.rootmd) + // HEAD request need store metadata + if caching.cacheable && req.Method == http.MethodHead { + if err := caching.bucket.Store(caching.req.Context(), caching.md); err != nil { + caching.log.Errorf("failed to store cache metadata: %v", err) + } } } From a255c28ea23273d03d8c15453f996dea95c1331e Mon Sep 17 00:00:00 2001 From: Sendya <18x@loacg.com> Date: Wed, 19 Aug 2026 23:04:02 +0800 Subject: [PATCH 2/2] refactor: implement Vary-aware collapsed forwarding in ObjectFlightGroup using request header fingerprinting --- server/middleware/caching/caching.go | 8 +- .../caching/collapsed_forwarding_test.go | 138 ++++++++++++++++++ server/middleware/caching/object_flight.go | 123 +++++++++++++--- 3 files changed, 246 insertions(+), 23 deletions(-) diff --git a/server/middleware/caching/caching.go b/server/middleware/caching/caching.go index 9a7b636..68b9c4a 100644 --- a/server/middleware/caching/caching.go +++ b/server/middleware/caching/caching.go @@ -172,8 +172,12 @@ func Middleware(c *configv1.Middleware) (middleware.Middleware, func(), error) { // concurrent requests for the same cache object share one // origin fetch (Squid-style collapsed_forwarding). if opts.CollapsedRequest { - flightKey := caching.id.HashStr() + ":" + xhttp.HeaderFingerprint(req.Header) - flightResp, _, flightErr := objectFlight.Do(flightKey, opts.CollapsedRequestWaitTimeout.AsDuration(), func() (*http.Response, error) { + // VaryProcessor selects a representation-specific ID before a + // known-variant miss reaches this point. For a cold miss, + // ObjectFlightGroup checks the origin's Vary fields before sharing + // the response with requests carrying different headers. + flightKey := objectFlightRequestKey(caching.id.HashStr(), req) + flightResp, _, flightErr := objectFlight.DoRequest(flightKey, req.Header, opts.CollapsedRequestWaitTimeout.AsDuration(), func() (*http.Response, error) { r, e := caching.doProxy(req, false) if e != nil { return nil, e diff --git a/server/middleware/caching/collapsed_forwarding_test.go b/server/middleware/caching/collapsed_forwarding_test.go index c8f8768..a673695 100644 --- a/server/middleware/caching/collapsed_forwarding_test.go +++ b/server/middleware/caching/collapsed_forwarding_test.go @@ -411,6 +411,144 @@ func TestObjectFlight_BasicCollapse(t *testing.T) { } } +func TestObjectFlight_RequestMetadataDoesNotSplitFlight(t *testing.T) { + g := &ObjectFlightGroup{} + var callCount atomic.Int32 + + const callers = 5 + var wg sync.WaitGroup + start := make(chan struct{}) + bodies := make([]string, callers) + + for i := 0; i < callers; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + <-start + + header := http.Header{"X-Request-Idx": {string(rune('0' + idx))}} + resp, _, err := g.DoRequest("cache-key-metadata", header, 50*time.Millisecond, func() (*http.Response, error) { + callCount.Add(1) + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("shared-body")), + }, nil + }) + if err != nil { + t.Errorf("caller %d: unexpected error: %v", idx, err) + return + } + body, readErr := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if readErr != nil { + t.Errorf("caller %d: read error: %v", idx, readErr) + return + } + bodies[idx] = string(body) + }(i) + } + + close(start) + wg.Wait() + + if callCount.Load() != 1 { + t.Fatalf("request metadata split one object flight into %d calls", callCount.Load()) + } + for i, body := range bodies { + if body != "shared-body" { + t.Errorf("caller %d: body = %q, want shared-body", i, body) + } + } +} + +func TestObjectFlightRequestKey(t *testing.T) { + newRequest := func(method string, header http.Header) *http.Request { + req, err := http.NewRequest(method, "http://example.com/object", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header = header + return req + } + + base := newRequest(http.MethodGet, http.Header{"X-Request-Idx": {"1"}}) + metadataOnly := newRequest(http.MethodGet, http.Header{"X-Request-Idx": {"2"}}) + if objectFlightRequestKey("object", base) != objectFlightRequestKey("object", metadataOnly) { + t.Fatal("request-local metadata changed the object-flight key") + } + + representationOnly := newRequest(http.MethodGet, http.Header{"Accept-Encoding": {"br"}}) + if objectFlightRequestKey("object", base) != objectFlightRequestKey("object", representationOnly) { + t.Fatal("Vary-managed representation fields changed the initial object-flight key") + } + + for name, req := range map[string]*http.Request{ + "method": newRequest(http.MethodHead, nil), + "range": newRequest(http.MethodGet, http.Header{"Range": {"bytes=0-99"}}), + "authorization": newRequest(http.MethodGet, http.Header{"Authorization": {"Bearer token"}}), + "upstream": newRequest(http.MethodGet, http.Header{"i-x-ups-addr": {"127.0.0.1:9000"}}), + } { + if objectFlightRequestKey("object", base) == objectFlightRequestKey("object", req) { + t.Errorf("%s did not change the object-flight key", name) + } + } +} + +func TestObjectFlight_VaryRepresentationsUseSeparateFlights(t *testing.T) { + g := &ObjectFlightGroup{} + var callCount atomic.Int32 + + encodings := []string{"gzip", "br", "gzip", "br"} + var wg sync.WaitGroup + start := make(chan struct{}) + bodies := make([]string, len(encodings)) + + for i, encoding := range encodings { + wg.Add(1) + go func(idx int, encoding string) { + defer wg.Done() + <-start + + header := http.Header{ + "Accept-Encoding": {encoding}, + "X-Request-Idx": {string(rune('0' + idx))}, + } + resp, _, err := g.DoRequest("cache-key-vary", header, 50*time.Millisecond, func() (*http.Response, error) { + callCount.Add(1) + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Vary": {"Accept-Encoding"}}, + Body: io.NopCloser(strings.NewReader(encoding)), + }, nil + }) + if err != nil { + t.Errorf("caller %d: unexpected error: %v", idx, err) + return + } + body, readErr := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if readErr != nil { + t.Errorf("caller %d: read error: %v", idx, readErr) + return + } + bodies[idx] = string(body) + }(i, encoding) + } + + close(start) + wg.Wait() + + if callCount.Load() != 2 { + t.Fatalf("Vary representations used %d origin calls, want 2", callCount.Load()) + } + for i, body := range bodies { + if body != encodings[i] { + t.Errorf("caller %d: body = %q, want %q", i, body, encodings[i]) + } + } +} + func TestObjectFlight_ErrorPropagation(t *testing.T) { g := &ObjectFlightGroup{} var callCount atomic.Int32 diff --git a/server/middleware/caching/object_flight.go b/server/middleware/caching/object_flight.go index c6a0e77..c2af347 100644 --- a/server/middleware/caching/object_flight.go +++ b/server/middleware/caching/object_flight.go @@ -4,10 +4,30 @@ import ( "fmt" "io" "net/http" + "strings" "sync" "time" + + "github.com/omalloc/tavern/internal/protocol" + xhttp "github.com/omalloc/tavern/pkg/x/http" + "github.com/omalloc/tavern/pkg/x/http/varycontrol" ) +var objectFlightControlHeaders = map[string]struct{}{ + "range": {}, + "if-range": {}, + "if-match": {}, + "if-none-match": {}, + "if-modified-since": {}, + "if-unmodified-since": {}, + "authorization": {}, + "proxy-authorization": {}, + "cookie": {}, + "cache-control": {}, + "pragma": {}, + protocol.InternalUpstreamAddr: {}, +} + // objectFlightCall represents an in-flight full-object origin fetch. // // Unlike the previous WaitGroup-only approach, this uses io.Pipe + @@ -16,11 +36,17 @@ import ( // SavepartAsyncReader → disk writes) while simultaneously providing data // to all waiting callers — no cache re-lookup is needed. type objectFlightCall struct { - resp *http.Response - pipes []*io.PipeWriter - mu sync.Mutex // protects pipes during registration and snapshot - wg sync.WaitGroup // signals that resp headers / err are ready - err error + resp *http.Response + leaderHeader http.Header + waiters []*objectFlightWaiter + wg sync.WaitGroup // signals that resp headers / err are ready + err error +} + +type objectFlightWaiter struct { + header http.Header + writer *io.PipeWriter + retryKey string } // ObjectFlightGroup collapses concurrent full-MISS requests for the same @@ -36,6 +62,17 @@ type ObjectFlightGroup struct { m map[string]*objectFlightCall } +func objectFlightRequestKey(base string, req *http.Request) string { + selected := make(http.Header, len(objectFlightControlHeaders)) + for name, values := range req.Header { + if _, ok := objectFlightControlHeaders[strings.ToLower(name)]; ok { + selected[name] = values + } + } + + return base + "\x00method:" + req.Method + "\x00control:" + xhttp.HeaderFingerprint(selected) +} + // Do executes fn once per key and fans out the response body to all // concurrent callers. All callers receive the same response headers // (cloned) and a shared body stream. @@ -49,7 +86,20 @@ type ObjectFlightGroup struct { // shared — true if this caller joined an existing flight // err — error from fn or from body copy func (g *ObjectFlightGroup) Do(key string, waiter time.Duration, fn func() (*http.Response, error)) (*http.Response, bool, error) { + return g.do(key, nil, waiter, fn) +} + +// DoRequest is like Do, but also uses the response's Vary fields to avoid +// sharing a representation with a request whose selected header values differ. +// Requests rejected from the initial flight are collapsed again under a +// representation-specific key. +func (g *ObjectFlightGroup) DoRequest(key string, header http.Header, waiter time.Duration, fn func() (*http.Response, error)) (*http.Response, bool, error) { + return g.do(key, header, waiter, fn) +} + +func (g *ObjectFlightGroup) do(key string, header http.Header, waiter time.Duration, fn func() (*http.Response, error)) (*http.Response, bool, error) { pr, pw := io.Pipe() + w := &objectFlightWaiter{header: header.Clone(), writer: pw} g.mu.Lock() if g.m == nil { @@ -57,9 +107,7 @@ func (g *ObjectFlightGroup) Do(key string, waiter time.Duration, fn func() (*htt } if c, ok := g.m[key]; ok { // Waiter: register a pipe writer and wait for headers. - c.mu.Lock() - c.pipes = append(c.pipes, pw) - c.mu.Unlock() + c.waiters = append(c.waiters, w) g.mu.Unlock() c.wg.Wait() @@ -67,6 +115,11 @@ func (g *ObjectFlightGroup) Do(key string, waiter time.Duration, fn func() (*htt _ = pw.CloseWithError(c.err) return nil, true, c.err } + if w.retryKey != "" { + _ = pw.Close() + _ = pr.Close() + return g.do(w.retryKey, header, waiter, fn) + } resp := cloneResponse(c.resp) resp.Body = pr @@ -74,7 +127,10 @@ func (g *ObjectFlightGroup) Do(key string, waiter time.Duration, fn func() (*htt } // Leader: create the flight and execute fn. - c := &objectFlightCall{pipes: []*io.PipeWriter{pw}} + c := &objectFlightCall{ + leaderHeader: header.Clone(), + waiters: []*objectFlightWaiter{w}, + } c.wg.Add(1) g.m[key] = c g.mu.Unlock() @@ -101,23 +157,23 @@ func (g *ObjectFlightGroup) Do(key string, waiter time.Duration, fn func() (*htt g.mu.Unlock() c.wg.Done() - // Snapshot pipes under c.mu to avoid racing with waiter registrations. - c.mu.Lock() - for _, p := range c.pipes { - _ = p.CloseWithError(err) + for _, waiter := range c.waiters { + _ = waiter.writer.CloseWithError(err) } - c.mu.Unlock() return nil, false, err } c.resp = resp - c.wg.Done() // release waiters — headers are now available - - // Snapshot pipes under c.mu to avoid racing with waiter registrations. - c.mu.Lock() - pipes := make([]*io.PipeWriter, len(c.pipes)) - copy(pipes, c.pipes) - c.mu.Unlock() + pipes := make([]*io.PipeWriter, 0, len(c.waiters)) + for i, waiter := range c.waiters { + retryKey, compatible := objectFlightVaryMatch(key, c.leaderHeader, waiter.header, resp, i) + if compatible { + pipes = append(pipes, waiter.writer) + continue + } + waiter.retryKey = retryKey + } + c.wg.Done() // release waiters — headers and Vary matching are ready g.mu.Unlock() // Fan out the response body to all pipes (including the leader's). @@ -149,6 +205,31 @@ func (g *ObjectFlightGroup) Do(key string, waiter time.Duration, fn func() (*htt return leaderResp, false, nil } +func objectFlightVaryMatch(key string, leader, waiter http.Header, resp *http.Response, position int) (string, bool) { + if resp == nil { + return "", true + } + + varyKey := varycontrol.Clean(resp.Header.Values("Vary")...) + if len(varyKey) == 0 { + return "", true + } + + if varyKey.HasWildcard() { + if position == 0 { + return "", true + } + return fmt.Sprintf("%s\x00vary:*:%d", key, position), false + } + + leaderVaryData := varyKey.VaryData(leader) + waiterVaryData := varyKey.VaryData(waiter) + if leaderVaryData == waiterVaryData { + return "", true + } + return key + "\x00vary:" + waiterVaryData, false +} + // cloneResponse returns a shallow copy of resp with a cloned Header map. // Body is left nil — the caller sets it to a pipe reader. func cloneResponse(resp *http.Response) *http.Response {