Skip to content

perf(surrogate): store tags via native sets when the storer supports it - #851

Merged
darkweak merged 6 commits into
darkweak:masterfrom
mohammed90:feat/set-based-surrogate-tags
Sep 16, 2026
Merged

darkweak merged 6 commits into
darkweak:masterfrom
mohammed90:feat/set-based-surrogate-tags

Conversation

@mohammed90

@mohammed90 mohammed90 commented Aug 20, 2026 •

Copy link
Copy Markdown
Contributor

The go.mod replaces are temporary

Problem

Follow-up to # (OOM fixes). storeTag performs a
read-modify-write of the entire SURROGATE_<tag> value on every stored
response
, twice per tag, under a global mutex: GET (~720 KB observed in the
production allocs profile) → string append → SET with no expiration. Cost per
stored response grows linearly with tag size, and tag size grows forever —
quadratic work over time, and the last unbounded growth path in the profile.

Changes

  • storeTag uses the storer's core.SetStorer when implemented (go-redis):
    one deduplicating set-add, no read-back, no giant string values.
  • purgeTag reads members via GetSet.
  • List streams tags via WalkSets — native sets are invisible to MapKeys,
    which only reads string values, so the surrogate-keys API endpoint keeps
    working.
  • Storers without SetStorer keep the existing string behavior unchanged.
  • go-redis surrogate tags get a one-year rolling lifetime instead of an
    infinite one, so abandoned tags eventually expire.
  • Bumps the storages replace to the commit providing SetStorer
    (darkweak/storages#).

Testing

  • go test ./pkg/surrogate/... ./pkg/api/... ./pkg/middleware/... — green
    (surrogate provider tests cover the string fallback; set semantics are
    covered by integration tests in the storages PR).

Rollout

  • Deploy together with the storages bump; both replaces (core, and
    go-redis in the xcaddy build) must point at the same storages commit.
  • Mixed replicas during rollout are safe: old replicas read legacy strings
    until a tag is migrated; after migration they cannot append to that tag
    (missed purge member at worst — the affected keys still expire via their own
    TTLs).

Expected impact

  • Store path: O(tag size) reads/allocations per response → O(1) set-add.
  • SURROGATE_* value sizes bounded by unique membership; dead tags expire
    within a year of last touch instead of never.

EvictMapping fetched the entire mapping index via MapKeys before
processing it. With the go-redis storage this materialized hundreds of
MB per run (325 MB live in a production heap profile) on every eviction
interval, which is the primary driver of the OOM crashes.

Process mappings one entry at a time through the storer's
MappingWalker when available, falling back to MapKeys for storers that
don't implement it. Peak memory for an eviction run drops from
O(index size) to O(batch).

Temporarily replace darkweak/storages/core with the fork commit that
provides MappingWalker, bounded mapping key TTLs and 64 KB lz4 blocks,
so canary builds can validate the fix. The replace also moves off the
perf/core/reduce-overhead pseudo-version, which pools lz4/bufio readers
that escape through http.Response.Body and can serve truncated
responses under concurrency. Swap the replace for a tagged upstream
release once darkweak/storages merges the changes.
@netlify

netlify Bot commented Aug 20, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for teal-sprinkles-4c7f14 canceled.

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

Production goroutine profiles showed one eviction goroutine per
configured cache handler for the same storer: eviction registration
runs in the handler constructor, which executes once per handler and
again on config reloads. All of them walked the same mapping index
concurrently, multiplying its memory cost.

Register at most one eviction goroutine per storer identity for the
process lifetime. Extend the eviction lock expiry on every ownership
check and keep extending it while a walk runs, so walks longer than
the lock TTL don't let other replicas join mid-walk. Previously the
in-process holder comparison also short-circuited all local goroutines
to "owned", making the lock a no-op inside one process.

Drop mapping values larger than 1 MB during eviction without decoding
them: unmarshalling a pathological value materializes it in memory,
and the response keys it references expire through their own TTLs.
storeTag reread the whole SURROGATE_ value on every stored response,
appended one cache key and rewrote it under a global mutex. With tag
values growing without bound (~720 KB single reads observed in a
production allocs profile), every stored response paid O(value size)
in reads, allocations and writes.

Use the storer's SetStorer implementation when available: additions
become one deduplicating set operation with no read-back, purges read
members directly and listings stream sets instead of going through
MapKeys, which cannot see set values. Storers without SetStorer keep
the previous string behavior.

Give go-redis surrogate tags a one-year rolling lifetime instead of an
infinite one so abandoned tags eventually expire, and bump the storages
replace to the fork commit providing SetStorer.
@mohammed90
mohammed90 force-pushed the feat/set-based-surrogate-tags branch from 8c22112 to 7d032fe Compare August 29, 2026 22:49
@mohammed90

Copy link
Copy Markdown
Contributor Author

Further relevant fixes and adjustment to the design flaw have a dedicated fix in
darkweak/storages#63.

Per-handler registration — eviction goroutines are now deduplicated
process-wide by storer identity (Name()-Uuid() via sync.Map.LoadOrStore),
so multiple cache blocks / config reloads can't spawn concurrent walkers for
the same storer. This removes the 7-walkers-per-process pattern from the
goroutine dumps.

Lock was a no-op in-process / too short for long walks — in-process
exclusion now comes from the registration dedup (a single walker exists per
storer), and the cross-replica lock is extended on every ownership check plus
kept alive at TTL/2 for the duration of a walk, so walks longer than the 2-min
TTL no longer let other replicas join mid-walk. The interval timer is also
reset only after a walk completes: a walk longer than the interval no longer
triggers an immediate back-to-back walk. Known limitation, unchanged from
before: acquisition is get-then-set with a verify read, not an atomic SET NX —
the Storer interface doesn't expose CAS. Two replicas can, rarely, both pass
the verify; the consequence is one redundant walk, not corruption.

No oversized-value guard — EvictMapping now deletes mapping values over
1 MB without decoding them (the 119 MB values in your dataset are dropped, not
materialized). Regression test included.

The root cause — whole-blob mappings that grow per variant — is fixed
separately in darkweak/storages#63 (one hash field per variant,
no read-modify-write, lazy migration, oversized blobs dropped undecoded).
With it, hash mappings are self-pruning and this eviction walk converges to a
no-op for Redis; the walk remains for the other storers and legacy data.

Signed-off-by: Mohammed Al Sahaf <msaa1990@gmail.com>
@mohammed90

Copy link
Copy Markdown
Contributor Author

This includes #828

@mohammed90

Copy link
Copy Markdown
Contributor Author

@darkweak, I don't know how to manage the plugins system. Is souin in the plugin upgraded after a tag, so CI fails now but succeeds later or what?

@darkweak
darkweak merged commit 2ff248e into darkweak:master Sep 16, 2026
20 of 21 checks passed
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.

2 participants