Skip to content

perf(go-redis): bound memory usage of the mapping index (streaming eviction, mapping TTL, smaller lz4 blocks) - #59

Open
mohammed90 wants to merge 4 commits into
darkweak:mainfrom
mohammed90:main
Open

perf(go-redis): bound memory usage of the mapping index (streaming eviction, mapping TTL, smaller lz4 blocks)#59
mohammed90 wants to merge 4 commits into
darkweak:mainfrom
mohammed90:main

Conversation

@mohammed90

Copy link
Copy Markdown
Contributor

Problem

Caddy instances running Souin with the go-redis storage OOM under production
traffic. A heap/allocs profile from an affected instance (HeapAlloc 2.13 GB,
MaxRSS 2.22 GB, 20.9 GB allocated over ~3.5 min) shows three dominant causes,
all in the storage layer:

  1. MapKeys materializes the whole mapping index. The mapping-eviction job
    calls MapKeys(IDX_), which SCANs every mapping key and issues one MGET
    for all of them
    . In the profile this single call stack holds 325 MB +
    43 MB live
    (~83% of sampled live heap), and it reruns every eviction
    interval (default 1 minute).
  2. Mapping keys never expire. SetMultiLevel stores the mapping key with
    duration -1 (KeepTTL). The index grows by one entry per varied key,
    forever, so the spike in (1) grows without bound.
  3. lz4 4 MB default block size. Every SetMultiLevel compression and every
    MappingElection decompression cycles 4 MB pooled blocks even for tiny
    bodies — ~65% of all allocated bytes (~13.5 GB of 20.9 GB) in the profile.

Changes

  • core: new optional MappingWalker interface — storers can stream mapping
    entries in bounded batches; callers fall back to MapKeys when unimplemented.
    Also exposes Lz4WriterPool (writer pooling only; see the doc comment for why
    readers must never be pooled).
  • go-redis: implements WalkMappings (SCAN + MGET in batches of 100, early
    stop supported); MapKeys now delegates to it.
  • go-redis: mapping key TTL = max(existing TTL, duration + stale) instead
    of -1. Never shortens an expiration owned by a longer-lived entry; legacy
    unbounded keys become bounded on their next update.
  • go-redis: lz4 writer uses Block64Kb. Readers derive block size from the
    frame header, so previously stored entries remain readable.

Compatibility

  • No breaking interface changes: MappingWalker is optional, MapKeys behavior
    is preserved (same results, bounded batches internally).
  • Existing cache entries and mapping keys are read unchanged; unbounded mapping
    keys converge to bounded TTLs as they are updated or evicted.

Testing

  • go test ./go-redis/ ./core/ against a real Redis 8.x — all green.
  • New tests:
    • TestRedis_WalkMappings: 250 keys (crosses batch boundaries), prefix
      stripping, early stop after the first entry.
    • TestRedis_SetMultiLevel_MappingTTL: TTL is set within the entry lifetime,
      extended by longer-lived entries, not shortened by shorter-lived ones, and
      applied to legacy keys without expiration.

AI Disclosure

Copilot (Claude Fable 5) analyzed and developed the changes and the tests. I understand the reasoning and the code changes make sense. The changes aren't deployed yet due to how this repo couples with Souin repo.

Storer.MapKeys forces implementations to materialize every mapping key
and value in a single map. On large deployments the mapping index can
reach hundreds of MB, so any caller doing periodic maintenance pays that
allocation on every run.

MappingWalker is an optional interface that lets a storer stream
mapping entries in bounded batches instead. Callers fall back to
MapKeys when the storer doesn't implement it.

Also expose Lz4WriterPool so downstream storers can reuse lz4 writers.
Writers are safe to pool once Close has flushed the frame; readers must
never be pooled this way because they escape through http.Response.Body.

Signed-off-by: Mohammed Al Sahaf <msaa1990@gmail.com>
MapKeys collected every key from SCAN into one slice, then issued a
single MGET for all of them and copied every value into a map. With a
large mapping index this materializes the entire index in memory at
once (observed: 325 MB live from a single caller in a production heap
profile).

Implement MappingWalker with SCAN + MGET in batches of 100 keys and
rewire MapKeys through it, so even the compatibility path no longer
issues one unbounded MGET.
SetMultiLevel stored the mapping key with duration -1, which maps to
KeepTTL: mapping keys never expired and grew by one entry per varied
key forever. The index could only shrink via the eviction job, and its
size was unbounded between runs.

Give the mapping key a TTL of max(existing TTL, duration + stale) so it
always outlives the longest-lived entry it references, never shortens
an expiration owned by a longer-lived entry, and converts legacy
unbounded keys to bounded ones on their next update.

Also configure the lz4 writer with 64 KB blocks instead of the 4 MB
default. Every compression and decompression churned 4 MB pooled blocks
even for tiny payloads. Readers pick the block size up from the frame
header, so old entries remain readable and new entries are cheap on
both paths.
@mohammed90

Copy link
Copy Markdown
Contributor Author

@darkweak, if you have the time, can you take a look? It'd be great if we can resolve this at the earliest to improve the ecosystem for Caddy and Souin.

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