fix(surrogate): skip indexing responses under the empty tag - #840
Open
rhurling wants to merge 1 commit into
Open
fix(surrogate): skip indexing responses under the empty tag#840rhurling wants to merge 1 commit into
rhurling wants to merge 1 commit into
Conversation
A response carrying none of the surrogate-key headers still reaches storeTag with an empty tag. getSurrogateKey returns "" for such a response, and ParseHeaders is strings.Split, which returns [""] for an empty input rather than an empty slice, so Store's loop runs once with an empty key. Every such response is therefore indexed into the single SURROGATE_ entry. storeTag read-modify-writes that whole value while holding the shared mutex, so once the entry is large each store costs O(number of cached objects) and the cost grows with the cache. Workloads with high URL cardinality reach that point quickly because every request is a miss and pays the full cost. Skip the write when the tag is empty. Responses that do carry surrogate keys are unaffected, and the per-URI tag is still written, so purge-by-URI keeps working. The added test reproduces the accumulation: without this change 50 responses with no surrogate-key header leave all 50 cache keys joined in SURROGATE_.
✅ Deploy Preview for teal-sprinkles-4c7f14 canceled.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR and all of the following text is written by Claude, after debugging a production incident as mentioned.
Problem
A response that carries none of the surrogate-key headers (
Surrogate-Key,Cache-Groups,Edge-Cache-Tag,Cache-Tags) is still indexed — under the empty tag.getSurrogateKeyreturns""for such a response, andParseHeadersisstrings.Split, which returns[""]for an empty input rather than an empty slice. SoStore's loop still runs once, withkey == "", and callsstoreTag("", cacheKey).Every such response therefore appends to the same
SURROGATE_entry, andstoreTagread-modify-writes that entire value while holding the shared mutex:Once that entry is large, each store costs O(number of cached objects), and the cost grows with the cache — so it degrades over time rather than showing up immediately. Workloads with high URL cardinality hit it fastest, because every request is a miss and pays the full cost.
Evidence
I hit this in production (Caddy 2.11.4 / cache-handler v0.16.0 / souin v1.7.7 / simplefs). A crawler pulled ~84k unique image URLs over 110 minutes at ~12.8 req/s:
I believe this is the shared root cause behind #114 (goroutines wedged in
sync.Mutex.Lock), #118 (memory growth; its reproduction generates a unique URL per request) and #127 (a 50 MBSURROGATE_value saturating Redis). Details and measurements in caddyserver/cache-handler#127 (comment).Change
Skip the write when the tag is empty.
storeTag(uri, cacheKey)is still called unconditionally, so purge-by-URI keeps working.storeTagrather than inStorekeeps it to one place and holds the invariant for every caller.Test
TestBaseStorage_Store_NoSurrogateKeyHeaderDoesNotIndexUnderEmptyTagstores 50 responses with no surrogate-key header and assertsSURROGATE_stays empty while the per-URI tags are still written.Without the change it fails with all 50 keys accumulated in the one entry:
The test sets
bs.durationexplicitly because the mock storer otherwise maps to a zero TTL, under which nothing is retained.go build ./...,go vet ./pkg/surrogate/...,go test ./pkg/surrogate/...andgofmtare all clean.