Skip to content

fix(client): drop evicted entries from the client-side cache key index - #3538

Open
maxymlyskov wants to merge 2 commits into
redis:masterfrom
maxymlyskov:fix/drop-evicted-entries-from-key-index
Open

maxymlyskov wants to merge 2 commits into
redis:masterfrom
maxymlyskov:fix/drop-evicted-entries-from-key-index

Conversation

@maxymlyskov

@maxymlyskov maxymlyskov commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Description

BasicClientSideCache keeps a key to cacheKeys index (#keyToCacheKeySetMap) next to its entries, and invalidate(key) uses it to find what to drop. delete() and deleteOldest() remove the entry but leave its cacheKey in that index. So an entry evicted by maxEntries, expired by ttl or dropped after a failed load stays indexed until the server invalidates that exact key or the cache is cleared. A read-mostly workload over many distinct keys grows the index past maxEntries, and server invalidations don't keep it small: the default tracking-table-max-keys is 1000000.

With redis:8 (8.10.2), a RESP3 client with BasicClientSideCache({ maxEntries: 100 }) calling get on 50000, then 200000 more distinct keys:

master:
after reading 50000 more distinct keys (maxEntries 100): entries 100 | key index 50000 | heapUsed 47.2 MB
after reading 200000 more distinct keys (maxEntries 100): entries 100 | key index 250000 | heapUsed 138.7 MB
this branch:
after reading 50000 more distinct keys (maxEntries 100): entries 100 | key index 100 | heapUsed 21.0 MB
after reading 200000 more distinct keys (maxEntries 100): entries 100 | key index 100 | heapUsed 33.2 MB

invalidate(key) has a smaller version of the same leak: it clears that key only, so a multi-key entry (MGET a b) invalidated through a stays listed under b.

The cache now remembers each entry's keys (#cacheKeyToKeysMap, filled in set(), cleared in clear()), so an eviction doesn't have to scan every key. #deleteFromKeyIndex(cacheKey) removes the cacheKey from each of its keys' sets and drops a key once no entry uses it; delete(), deleteOldest(), invalidate(key) and a set() that overwrites an entry call it.

Four tests in a new describe('key index') use cache.set(...) and keySetEntries(): maxEntries: 2 with 10 keys leaves only the 2 cached keys indexed, deleting entries keeps another entry on the same key, invalidating a of an a b entry leaves b with only its own entry, and an a b entry overwritten with keys a stays cached when b is invalidated. All four fail on master.

npm run build && npm test passes apart from RedisSentinel lifecycle events destroy() from an end listener tears down a reentrant reopen, which also fails on a clean master (2 of 10 runs alone). Lint is clean.

#3495 also changes cache.ts (invalidate(), clear() and a copy of the index), so the two will conflict textually. This fix doesn't touch the lines it adds.


Checklist

  • Does npm test pass with this change (including linting)?
  • Is the new or changed code fully tested?
  • Is a documentation update included (if this change modifies existing APIs, or introduces new ones)?

Note

Medium Risk
Changes core client-side cache bookkeeping used on every cached read and server invalidation; behavior is narrowed to fix leaks but touches hot paths in BasicClientSideCache.

Overview
Fixes a memory leak in BasicClientSideCache where the Redis-key → cache-key index (#keyToCacheKeySetMap) kept growing after entries were removed. Eviction (maxEntries), delete(), deleteOldest(), TTL expiry, failed loads, and partial invalidation used to drop the entry but leave stale index entries—so read-heavy workloads over many keys could balloon heap use even when maxEntries stayed small.

The cache now tracks each entry’s Redis keys in #cacheKeyToKeysMap and centralizes cleanup in #deleteFromKeyIndex, invoked whenever an entry leaves the cache (including on set() overwrite and when invalidating multi-key entries like MGET). clear() also resets the new map.

Adds a key index test block with four regressions: eviction under maxEntries, delete with shared keys, multi-key invalidation, and overwrite changing an entry’s key set.

Reviewed by Cursor Bugbot for commit 3267def. Bugbot is set up for automated code reviews on this repo. Configure here.

BasicClientSideCache keeps a key -> cacheKeys index next to its entries.
delete() and deleteOldest() removed the entry but left its cacheKey in
that index, so entries evicted by maxEntries, expired by ttl or dropped
after a failed load stayed indexed until the server invalidated the key.
A read-mostly workload over many keys grew the index past maxEntries.
invalidate(key) also left a multi-key entry (MGET a b) indexed under its
other keys.

Remember each entry's keys and remove its cacheKey from them whenever the
entry goes, dropping a key once no entry uses it.
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review 🔄 Running since 2026-10-23T22:48:43.238365Z 489e4b0 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit 489e4b0. Configure here.

Comment thread packages/client/lib/client/cache.ts
… key index

Cursor Bugbot pointed out that set() replaced an existing cacheKey's keys
in #cacheKeyToKeysMap without removing the old ones from
#keyToCacheKeySetMap. An entry set with keys a and b and then with key a
stayed listed under b, so invalidating b dropped the live entry.

handleCache() builds the cacheKey from every argument, so it always comes
with the same keys; this shows only when set() is called directly.

This branch has not been deployed

No deployments
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