Skip to content

Pass zrank/zrevrank keys so they are cacheable (fixes ValueError under CSC) - #4249

Open
uttam12331 wants to merge 2 commits into
redis:masterfrom
uttam12331:fix-zrank-zrevrank-cache-keys
Open

uttam12331 wants to merge 2 commits into
redis:masterfrom
uttam12331:fix-zrank-zrevrank-cache-keys

Conversation

@uttam12331

@uttam12331 uttam12331 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Summary

ZRANK and ZREVRANK are in the client-side cache allow list (redis/cache.py), but neither sets options["keys"]. When client-side caching is enabled, building the cache key raises:

ValueError: Cannot create cache key.

(from redis/connection.py, if kwargs.get("keys") is None: raise ValueError("Cannot create cache key.")), so these two commands can't be used with caching at all.

This was pointed out by @petyaslavova in #4244:

ZRANK and ZREVRANK are in the cache allow list but pass no keys at all, so they currently will raise ValueError("Cannot create cache key.") when client-side caching is enabled.

Fix

Set options["keys"] = [name] in both zrank and zrevrank, matching the range commands:

         options = {"withscore": withscore, "score_cast_func": score_cast_func}
+        options["keys"] = [name]

         return self.execute_command(*pieces, **options)

Tests

Added test_zrank_zrevrank_are_cacheable (in tests/test_cache.py), which caches zrank/zrevrank (no ValueError), asserts the entries are stored under redis_keys=("myzset",), and that mutating the sorted set from a second client invalidates them.


Note

Low Risk
Read-path caching fixes with targeted key metadata; behavior unchanged when CSC is off, covered by new integration tests.

Overview
Fixes client-side caching for several allow-listed read commands that previously omitted options["keys"], which triggered ValueError: Cannot create cache key. when CSC was enabled.

zrank / zrevrank and xpending_range now set options["keys"] = [name] (stream/sorted-set key) so cache keys can be built and invalidations track the right Redis key. sort_ro now forwards _command="SORT_RO" into sort() so the wire command matches the CSC allow list instead of reusing SORT.

Adds cache tests for all three areas: rank commands with invalidation on zadd, xpending_range population, and sort_ro with invalidation on list mutation.

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

@eeshsaxena eeshsaxena left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The fix is right. Every other command in DefaultCache.DEFAULT_ALLOW_LIST reaches execute_command with keys=[...], either directly or through a helper (_zrange, _zaggregate, _geosearchgeneric, the JSON and TS command builders), and Connection.send_command raises ValueError("Cannot create cache key.") for any allow-listed command that arrives without keys. ZRANK and ZREVRANK were the two that slipped through, so this closes the gap.

I went through the whole allow-list looking for siblings with the same problem. Everything else is covered, so the crash side of this is complete.

One related thing turned up that is pre-existing and out of scope here, but may be worth its own issue: SORT_RO, GEORADIUS_RO, and GEORADIUSBYMEMBER_RO are in the allow-list, but nothing in the client ever sends those command names. sort_ro() delegates to sort() which sends SORT, and georadius() / georadiusbymember() send GEORADIUS / GEORADIUSBYMEMBER. Those base names are correctly kept out of the allow-list because they can write with STORE. The net effect is that read-only geo and sort queries are never actually cached, even though the _RO entries look like they were added to cache them. That is a silent functional gap rather than a crash, so it does not block this change.

The added test covering both commands under single-connection and pooled clients looks good.

@petyaslavova petyaslavova left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Hey @uttam12331, thank you for your contribution! Overall, it looks good!

Reviewing your change, I noticed that there are a few other gaps like these ones. Would you please add similar handling for the following:

  • xpending_range() sends XPENDING with parse_detail=True and no keys, so it still raises ValueError("Cannot create cache key.") under CSC. It needs keys=[name].
  • SORT_RO is on the allow list, but sort_ro() delegates to sort(), which sends SORT. Please make it send SORT_RO with keys=[key].
  • GEORADIUS_RO and GEORADIUSBYMEMBER_RO are on the allow list, but _georadiusgeneric() always sends the base GEORADIUS / GEORADIUSBYMEMBER names. Please send the _RO variants with keys=[name] when neither store nor store_dist is given.

The last two are silent gaps rather than crashes — read-only sort and geo queries are simply never cached today, even though the allow-list entries suggest they are.

Could you also rebase onto master? #4244 landed a sibling test in tests/test_cache.py at the same insertion point, so the branch conflicts there. A regression test per item, in the style of the one you already added, would round this out.

ZRANK and ZREVRANK are in the client-side cache allow list, but neither set
options["keys"], so building the cache key raised
ValueError("Cannot create cache key.") whenever caching was enabled.

Set options["keys"] = [name] for both, matching the range commands, and add
a regression test that caches zrank/zrevrank and checks invalidation.
@uttam12331
uttam12331 force-pushed the fix-zrank-zrevrank-cache-keys branch from 4d16982 to 8551ac9 Compare August 11, 2026 05:03
@uttam12331

Copy link
Copy Markdown
Contributor Author

Thanks @petyaslavova! Addressed all of it:

  • xpending_range() — now passes keys=[name] (was raising ValueError("Cannot create cache key.")).
  • sort_ro() — now sends SORT_RO with keys=[key] (via a private _command arg on sort(), reusing its piece builder). The write path (sort(..., store=...)) still sends SORT.
  • georadius() / georadiusbymember()_georadiusgeneric() now sends the _RO variant with keys=[name] when neither store nor store_dist is given; the STORE/STOREDIST write path is unchanged.
  • Rebased onto master — resolved the tests/test_cache.py conflict with Pass zrevrange key as a list in options["keys"] to match sibling range commands #4244 (kept both its test_zrevrange_cache_key_uses_whole_key and my zrank/zrevrank test).
  • A regression test per item added in tests/test_cache.py (xpending_range / sort_ro / georadius_ro), in the style of the existing one.

I verified the command construction directly (each now reaches execute_command with the right command name and keys, and the store paths are untouched). I don't have a RESP3 + client-side-caching server locally to run the test_cache.py integration tests, so I leaned on CI for the cache round-trips — happy to adjust any of them if something needs tweaking.

Comment thread redis/commands/core.py Outdated
# because they can write).
if not kwargs["store"] and not kwargs["store_dist"]:
command += "_RO"
kwargs["keys"] = [args[0]]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Geo _RO responses left unparsed

High Severity

Read-only georadius / georadiusbymember now send GEORADIUS_RO / GEORADIUSBYMEMBER_RO, but response_callbacks still only maps the non-_RO names to parse_geosearch_generic. With withdist, withcoord, or withhash, distances, hashes, and coordinates stay as raw server values instead of the typed structures callers expect. This affects all clients, including those not using caching.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8551ac9. Configure here.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8551ac9026

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread redis/commands/core.py Outdated
# works (the base GEORADIUS names are kept off the cache allow list
# because they can write).
if not kwargs["store"] and not kwargs["store_dist"]:
command += "_RO"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve GEO parsing when using _RO commands

When a read-only georadius() or georadiusbymember() call includes withdist, withcoord, or withhash, this rewrite changes the parser lookup key to GEORADIUS_RO/GEORADIUSBYMEMBER_RO, but the GEO response callbacks are registered only for the base command names. Those option-bearing calls now return the raw nested Redis reply instead of the established floats, ints, and coordinate tuples, so existing non-STORE callers see different response shapes even without client-side caching. Register the _RO aliases with the same parser or parse under the base command name.

AGENTS.md reference: AGENTS.md:L158-L162

Useful? React with 👍 / 👎.

Comment thread redis/commands/core.py
get=get,
desc=desc,
alpha=alpha,
_command="SORT_RO",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Avoid caching SORT_RO with untracked patterns

When client-side caching is enabled and sort_ro() is called with by or get patterns, this now makes the command cacheable while sort() still records only keys=[name]. The sorted reply can depend on external keys such as score:* or object keys; invalidations for those keys will not match this cache entry, so repeated sort_ro() calls can return stale order or values until the source collection itself changes. Either include all dependencies or bypass caching for BY/GET variants.

AGENTS.md reference: AGENTS.md:L158-L162

Useful? React with 👍 / 👎.

Comment thread redis/commands/core.py
Comment thread redis/commands/core.py
alpha: bool = False,
store: str | None = None,
groups: bool | None = False,
_command: str = "SORT",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep SORT_RO off the public sort signature

sort() is a public command method, so adding the _command keyword changes its runtime signature and sort_ro() now depends on that hidden keyword when it calls self.sort(...). Any subclass or wrapper that overrides sort() with the documented signature will now raise TypeError: ... unexpected keyword argument '_command' when sort_ro() is used; route both commands through a private helper instead of extending the public method signature.

AGENTS.md reference: AGENTS.md:L111-L113

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Hey @uttam12331, this one should be fixed.

Comment thread redis/commands/core.py Outdated
# works (the base GEORADIUS names are kept off the cache allow list
# because they can write).
if not kwargs["store"] and not kwargs["store_dist"]:
command += "_RO"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve GEO parsing when using _RO commands

When a read-only georadius() or georadiusbymember() call includes withdist, withcoord, or withhash, this rewrite changes the parser lookup key to GEORADIUS_RO/GEORADIUSBYMEMBER_RO, but the GEO response callbacks are registered only for the base command names. Those option-bearing calls now return the raw nested Redis reply instead of the established floats, ints, and coordinate tuples, so existing non-STORE callers see different response shapes even without client-side caching. Register the _RO aliases with the same parser or parse under the base command name.

Useful? React with 👍 / 👎.

Comment thread redis/commands/core.py
Comment thread redis/commands/core.py
Comment thread redis/commands/core.py
Comment thread redis/commands/core.py Outdated
# works (the base GEORADIUS names are kept off the cache allow list
# because they can write).
if not kwargs["store"] and not kwargs["store_dist"]:
command += "_RO"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve Redis 3.2 GEO command compatibility

On Redis 3.2.0 through 3.2.9, GEORADIUS and GEORADIUSBYMEMBER exist but the _RO variants do not; Redis documents GEORADIUS_RO and GEORADIUSBYMEMBER_RO as available since 3.2.10. Because this branch rewrites every non-STORE GEO radius call, those supported servers now get unknown command for the common read path; only switch to _RO when the server supports it or limit this to CSC-capable connections.

Useful? React with 👍 / 👎.

Following review, cover the other allow-listed commands that reached
execute_command without `keys`:

- xpending_range() sent XPENDING with no keys, so it raised
  ValueError("Cannot create cache key.") under CSC. Pass keys=[name].
- sort_ro() delegated to sort(), which sends SORT, so SORT_RO (on the allow
  list) was never cached. Send SORT_RO with keys=[key] via a private
  _command argument to sort(); the write path (sort(store=...)) still sends
  SORT. The SORT response callback is a no-op without `groups` (which sort_ro
  never uses), so response parsing is unaffected.

Add a regression test per command in tests/test_cache.py.

(Deferred: georadius/georadiusbymember _RO caching. The GEO response
callbacks are keyed on the base command names, so sending the _RO variants
would change response parsing for withdist/withcoord/withhash calls, and the
_RO commands only exist since Redis 3.2.10. That needs _RO parser aliases and
version gating and is better handled separately.)
@uttam12331
uttam12331 force-pushed the fix-zrank-zrevrank-cache-keys branch from 8551ac9 to f84b16f Compare August 11, 2026 06:35
@uttam12331

Copy link
Copy Markdown
Contributor Author

Good catch from Bugbot/Codex on the GEO change — I've addressed it.

The GEO response callbacks are registered on the base command names (GEORADIUS GEORADIUSBYMEMBER GEOSEARCH), so rewriting to GEORADIUS_RO would have changed response parsing for withdist/withcoord/withhash calls (raw reply instead of the parsed floats/coords), and the _RO variants only exist since Redis 3.2.10. Doing that safely needs _RO parser aliases plus version gating, so I've reverted the georadius part and think it's better as a separate follow-up (happy to do it).

Kept the safe fixes:

  • zrank / zrevrank and xpending_range — same command names, only keys added, so no parsing/compat change.
  • sort_ro — sends SORT_RO with keys; its response callback (sort_return_tuples) is a no-op unless groups is set, which sort_ro never passes, so parsing is unaffected.

Rebased onto master and updated the tests (dropped the georadius one). Let me know if you'd prefer I also take on the georadius _RO + parser-alias work here rather than in a follow-up.

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

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

Reviewed by Cursor Bugbot for commit f84b16f. Configure here.

Comment thread redis/commands/core.py
options = {"groups": len(get) if groups else None}
options["keys"] = [name]
return self.execute_command("SORT", *pieces, **options)
return self.execute_command(_command, *pieces, **options)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale sort_ro cache with BY/GET

Medium Severity

sort_ro now issues allow-listed SORT_RO and caches under keys=[name] only. With by or get, Redis tracking invalidates the external keys those options touch, but those keys are not in redis_keys, so delete_by_redis_keys leaves the entry in place and later hits can return stale results. Previously sort_ro sent non-allow-listed SORT, so this path was not cached.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f84b16f. Configure here.

@petyaslavova petyaslavova left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Hey @uttam12331, thanks for the updates you have added!

The zrank / zrevrank fix is good, and the sort_ro direction is right — sending SORT_RO is what the method documents. Two more changes before we can merge:

  1. Keep _command off the public sort() signature (Codex flagged this too) and route both commands through a private helper: _sort(self, command, name, ...) holding the current body, with sort() calling self._sort("SORT", ...) and sort_ro() calling self._sort("SORT_RO", ...). _zrange is the existing precedent for that shape, and the @overload declarations for sort() should stay as they are.

  2. Set options["keys"] = [name] only when both by and get are None. With BY score:* or GET pattern:* the reply also depends on keys the command never declares, and Redis tracking remembers only declared keys — a change to score:1 invalidates score:1, never the sorted key, so an entry stored under keys=[name] can never be evicted and later calls would return a stale ordering.

Comment thread redis/commands/core.py
alpha: bool = False,
store: str | None = None,
groups: bool | None = False,
_command: str = "SORT",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Hey @uttam12331, this one should be fixed.

Comment thread redis/commands/core.py

@Mukller Mukller 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.

Verified locally by capturing execute_command payloads on this branch vs installed 8.1.0 (no server needed):

command 8.1.0 (before) this branch
zrank(k, m) ('ZRANK', k, m) keys=None keys=[k]
zrank(k, m, withscore=True) keys=None keys=[k]
zrevrank(k, m) keys=None keys=[k]
sort_ro("mylist") sends SORT (!), keys set sends SORT_RO, keys=[mylist]
xpending_range(s, g, ...) keys=None keys=[s]

Two things worth calling out:

  1. The SORT_RO fix is a bigger deal than the title suggests: before this PR sort_ro() literally dispatched SORT, i.e. the read-only variant was never used — a user with read-only ACL (-SORT +SORT_RO) would get a permission error from a method documented as SORT_RO. Now correct.
  2. Declaring keys= on ZRANK/ZREVRANK/XPENDING is what makes these commands usable under client-side caching: without key declaration the invalidation tracking can't bind to the key (and in CSC setups this surfaces as the ValueError mentioned in the PR description).

Minor nit, non-blocking: _command is a private-ish kwarg on public sort(). It's prefixed correctly and pragmatic (avoids duplicating ~40 lines of option building); just noting it in case maintainers prefer an underscore-excluded docstring line so IDEs don't suggest it.

CI: 363 green / 3 failures scattered across unrelated matrix cells (8.0.6+py3.14 unified, 8.8 cluster, PyPy standalone) — that scatter pattern reads as infra flakiness rather than something caused by adding keys= options; worth a re-run if maintainers agree.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants