Skip to content

fix(surrogate): skip indexing responses under the empty tag - #840

Open
rhurling wants to merge 1 commit into
darkweak:masterfrom
rhurling:fix/surrogate-empty-tag
Open

fix(surrogate): skip indexing responses under the empty tag#840
rhurling wants to merge 1 commit into
darkweak:masterfrom
rhurling:fix/surrogate-empty-tag

Conversation

@rhurling

@rhurling rhurling commented Jul 25, 2026

Copy link
Copy Markdown

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.

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 still runs once, with key == "", and calls storeTag("", cacheKey).

Every such response therefore appends to the same SURROGATE_ entry, and storeTag read-modify-writes that entire value while holding the shared mutex:

currentValue := string(s.Storage.Get(surrogatePrefix + tag))   // read whole value
if !containsCacheKey(currentValue, cacheKey) {                 // scan it
    currentValue += souinStorageSeparator + cacheKey           // copy it
    _ = s.Storage.Set(surrogatePrefix+tag, []byte(currentValue), s.duration)  // write it back
}

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:

  • cache held 203,725 objects; the concatenated key list was ≈ 30.8 MB, rewritten on every store
  • effective ceiling ≈ 3–4 stores/sec
  • goroutines climbed linearly 276 → 62,349, all with this stack, and never plateaued:
souin/pkg/middleware.(*SouinBaseHandler).Store
 └─ surrogate/providers.(*baseStorage).Store
     └─ surrogate/providers.(*baseStorage).storeTag
         └─ sync.(*Mutex).Lock
  • heap 155 MiB → 1.19 GiB, ending in a cgroup OOM kill; before that the process stopped accepting new connections while still appearing healthy
  • it is contention collapse rather than deadlock: with the load removed, goroutines drained back to normal on their own

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 MB SURROGATE_ value saturating Redis). Details and measurements in caddyserver/cache-handler#127 (comment).

Change

Skip the write when the tag is empty.

  • Responses that do carry surrogate keys are unaffected.
  • storeTag(uri, cacheKey) is still called unconditionally, so purge-by-URI keeps working.
  • Guarding inside storeTag rather than in Store keeps it to one place and holds the invariant for every caller.

Test

TestBaseStorage_Store_NoSurrogateKeyHeaderDoesNotIndexUnderEmptyTag stores 50 responses with no surrogate-key header and asserts SURROGATE_ 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 empty surrogate tag must stay empty, "cache_key_0,cache_key_1,cache_key_2,...,cache_key_49" given.

The test sets bs.duration explicitly 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/... and gofmt are all clean.

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_.
@netlify

netlify Bot commented Jul 25, 2026

Copy link
Copy Markdown

Deploy Preview for teal-sprinkles-4c7f14 canceled.

Name Link
🔨 Latest commit 0376125
🔍 Latest deploy log https://app.netlify.com/projects/teal-sprinkles-4c7f14/deploys/6a64df37f968320008ebb2e3

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant