Skip to content

fix(devshard): bound the gateway chat response cache#1409

Closed
ouicate wants to merge 1 commit into
gonka-ai:mainfrom
ouicate:fix/devshard-chat-cache-bound
Closed

fix(devshard): bound the gateway chat response cache#1409
ouicate wants to merge 1 commit into
gonka-ai:mainfrom
ouicate:fix/devshard-chat-cache-bound

Conversation

@ouicate

@ouicate ouicate commented Jul 6, 2026

Copy link
Copy Markdown

Problem

The gateway keeps a single process-wide chatResponseCache (devshard/cmd/devshardctl/response_cache.go) that dedups chat completions, keyed by a SHA-256 of model + request body with a 1h TTL. Expired entries are only ever reclaimed lazily inside Get — i.e. when the same key is looked up again.

Real chat traffic has near-unique request bodies (different messages/ids), so a given key is essentially never queried a second time. Its expired entry is therefore never revisited and never deleted, and there is no size cap. The map grows by one full cached response body per distinct request and never shrinks: an unbounded memory leak that scales with total traffic × uptime, independent of how many entries are actually live.

Fix

Cap the cache at defaultChatResponseCacheMaxEntries (2048) and evict when a new key would exceed it. Eviction runs under the existing lock in two phases:

  1. Reclaim every expired entry (the common case for a unique-body workload — usually sufficient on its own).
  2. Only if still at capacity with live entries, drop the oldest half in a single pass (ordered by ExpiresAt, which is insertion time + constant TTL, i.e. insertion order).

Dropping half amortizes the O(n) sweep across ~maxEntries/2 inserts instead of paying it on every Set once hot, and always keeps the newest entries so a just-cached response survives an immediate retry. Overwriting an existing key
skips eviction (the count doesn't grow). This matches the bounded-map idiom already used by the rate limiter and gossip dedup.

Tests

response_cache_test.go: set/get round-trip with body-copy isolation, expired entry dropped on access, hard bound under 500 unique inserts (never exceeds max, newest survives), expired-reclaimed-before-live eviction priority, and
overwrite-does-not-evict.

The gateway's process-wide chatResponseCache dedups chat completions keyed
by a hash of model + request body, with a 1h TTL. Expired entries are only
reclaimed lazily in Get -- i.e. when the same key is looked up again. Real
chat traffic has near-unique bodies, so a key is essentially never queried
twice: the expired entry is never revisited and never deleted, and there was
no size cap. The map grew by one full response body per distinct request and
never shrank -- an unbounded memory leak that scales with traffic x uptime.

Cap the cache at defaultChatResponseCacheMaxEntries (2048) and evict when a
new key would exceed it. Eviction runs under the existing lock in two phases:
reclaim all expired entries first (usually enough for a unique-body workload),
then, only if still at capacity, drop the oldest half in one pass. Batching
the drop amortizes the O(n) sweep and always keeps the newest entries so a
just-cached response survives an immediate retry. This matches the bounded-map
idiom already used by the rate limiter and gossip dedup.
@qdanik

qdanik commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

@ouicate similar fix was provided in PR #1402.

The byte cap in #1402 targets actual RSS more directly and is already reviewed, so I'd suggest consolidating on #1402 and closing this as superseded.

@ouicate ouicate closed this Jul 6, 2026
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