Skip to content

Add async client-side caching support - #4220

Open
lh0156 wants to merge 2 commits into
redis:masterfrom
lh0156:agent/async-client-caching-3916
Open

Add async client-side caching support#4220
lh0156 wants to merge 2 commits into
redis:masterfrom
lh0156:agent/async-client-caching-3916

Conversation

@lh0156

@lh0156 lh0156 commented Jul 26, 2026

Copy link
Copy Markdown

Fixes #3916

What changed

The synchronous client already supports RESP3 client-side caching, but the async standalone client did not expose cache or cache_config and its async connection pool could not wrap connections for tracking and invalidation handling.

This change:

  • adds cache and cache_config to redis.asyncio.Redis;
  • adds async connection-pool cache construction and an async cache proxy;
  • enables CLIENT TRACKING and asynchronous invalidation processing;
  • preserves RESP3-only and Redis 7.4+ validation;
  • exposes get_cache() for parity with the synchronous client;
  • documents async standalone usage.

Verification

  • RED test reproduced the current TypeError for Redis(cache_config=...).
  • tests/test_asyncio/test_connection.py: 69 passed, 4 skipped.
  • async cache proxy unit coverage includes hit/miss, invalidation, pool wiring, RESP2 rejection, and Redis version validation.
  • ruff check passed.
  • compileall passed.
  • git diff --check passed.
  • The broader async connection-pool suite had 14 failures caused by the local Redis 7.2.4 instance disabling DEBUG commands; the changed connection tests had no failures.
  • Sphinx documentation build was not runnable because sphinx-build is not installed locally.

Note

Medium Risk
New async connection-layer caching affects all command I/O on pooled connections (tracking, invalidation, locking, and in-flight deduplication); mistakes could cause stale reads or connection desync, though parity with the existing sync proxy and broad unit/integration tests reduce risk.

Overview
Adds client-side caching (CSC) to the async standalone Redis client, matching behavior already available on the sync client.

redis.asyncio.Redis now accepts cache and cache_config, enforces RESP3-only configuration, and exposes get_cache(). The async ConnectionPool builds the shared cache (custom implementation, factory, or CacheConfig), registers CSC observability callbacks, and wraps each connection in a new AsyncCacheProxyConnection that enables CLIENT TRACKING, serves cache hits/misses, coordinates in-flight fills via completion_event on CacheEntry, and processes invalidation push messages asynchronously.

Command path: _send_command_parse_response forwards **options (including keys for cacheable commands) to send_command. Connections store handshake_metadata from HELLO for Redis 7.4+ validation on connect.

Docs in resp3_features.rst document async CSC usage and clarify supported client types (async + sync cluster/sentinel, not async cluster). Tests cover pool wiring, RESP2 rejection, proxy hit/miss/invalidation/in-flight behavior, and an integration round-trip on Redis ≥ 7.4.

Reviewed by Cursor Bugbot for commit 468e0ca. Bugbot is set up for automated code reviews on this repo. 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: e77bfb8c07

ℹ️ 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".

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

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1907 to +1908
if is_cacheable and kwargs.get("keys") is None:
raise ValueError("Cannot create cache key.")

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 Forward cache key options on async commands

With the normal async client path, redis/asyncio/client.py::_send_command_parse_response still calls conn.send_command(*args) and drops **options, so high-level cacheable commands such as await r.get("k") never pass the keys=[...] metadata added by the command mixins. Because GET is cacheable by default, this new check sees no keys and raises ValueError("Cannot create cache key.") before any request is sent, making Redis(cache_config=...) fail for ordinary reads; the sync client forwards **options to send_command, so the async path needs the same behavior.

AGENTS.md reference: AGENTS.md:L145-L148

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed in 00fd0ac. Async _send_command_parse_response now forwards command options, including cache keys, to the connection. Added a direct forwarding regression test; the asyncio connection/cache suites pass (106 passed, 41 skipped).

Comment thread redis/asyncio/connection.py Outdated
Comment on lines +1935 to +1936
if self._cache.get(self._current_command_cache_key) is not None:
return

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 Wait for in-flight fills before using cache entry

With concurrent cache misses for the same key on different pooled connections, the first request leaves an IN_PROGRESS entry. This branch treats any surviving entry as usable and returns without sending the second command; if the first connection already has its reply, the drain above can even consume that response as a pending invalidation. The second caller then awaits read_response() on a connection that never sent a command, and the first caller may have lost its reply, so ordinary concurrent cached reads can time out or leave the entry stuck in progress.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed in 00fd0ac. IN_PROGRESS cache entries now expose a completion event. Concurrent async cache misses wait for the original fill, then reuse its value; failed fills delete and signal the entry so waiters retry instead of hanging. Added success and failure concurrency tests.

Comment thread redis/asyncio/connection.py Outdated
if data[1] is None:
self._cache.flush()
else:
self._cache.delete_by_redis_keys(data[1])

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 Record async cache invalidation evictions

When an async invalidation removes cached entries, this branch discards the delete_by_redis_keys() result and never records a client-side-cache eviction, while the sync proxy records invalidation evictions after deleting keys. Async clients will report hits, misses, network savings, and cache size, but invalidation-driven evictions are missing from metrics, making cache observability inaccurate for production async users.

AGENTS.md reference: AGENTS.md:L145-L148

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed in 00fd0ac. Async invalidation now records CSCReason.INVALIDATION with the number of removed entries, matching the sync proxy behavior. Added a metric regression test.

@vladvildanov

Copy link
Copy Markdown
Collaborator

Please make sure resolve Codex comments first

@lh0156
lh0156 force-pushed the agent/async-client-caching-3916 branch from 00fd0ac to 468e0ca Compare July 31, 2026 02:17
@lh0156

lh0156 commented Jul 31, 2026

Copy link
Copy Markdown
Author

Rebased onto upstream master 807da5e to resolve the merge conflict. The review fixes are unchanged; the final fix commit is 468e0ca (previously 00fd0ac). Focused async cache/connection tests and ruff check pass after the rebase.

@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 default effort and found 1 potential issue.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit 468e0ca. Configure here.

await self._conn.disconnect(*args, **kwargs)

async def send_packed_command(self, command, check_health: bool = True) -> None:
await self._conn.send_packed_command(command, check_health=check_health)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Missing cache key reset in send_packed_command

High Severity

AsyncCacheProxyConnection.send_packed_command does not reset self._current_command_cache_key to None before forwarding the packed command. The sync CacheProxyConnection.send_packed_command explicitly sets self._current_command_cache_key = None (with a comment explaining pre-packed commands are not individually cacheable). Without this reset, a subsequent read_response call could cache the packed command's response under a stale cache key from an earlier send_command, corrupting the cache with wrong data.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 468e0ca. 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: 468e0caf96

ℹ️ 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".

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

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1958 to +1962
async with self._pool_lock:
while await cached_entry.connection_ref.can_read():
try:
await cached_entry.connection_ref.read_response(
push_request=True,

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 draining responses from another borrowed connection

In the current code this drain still runs for a VALID cache entry by reading from cached_entry.connection_ref, which can be a different raw connection that the pool has since handed to another coroutine; the pool lock is not held while borrowed connections wait for their own replies. If that coroutine's reply is buffered when a second connection serves this cache hit, this read_response(push_request=True, ...) consumes and discards the normal command reply as if it were an invalidation, leaving the real owner to time out or see a corrupted stream. Only drain the entry's connection when you can prove it is idle/currently owned, or process invalidations on the borrowed connection instead.

Useful? React with 👍 / 👎.

)
)

await self._conn.send_command(*args, **kwargs)

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 Clear in-progress entries when async sends are cancelled

After setting the shared cache entry to IN_PROGRESS, this await can exit via asyncio.CancelledError while the underlying connection disconnects in its BaseException handler; because the proxy only deletes/signals the entry in read_response(), no completion event is set for this key. A later request for the same cache key then waits forever at the in-flight fill path instead of retrying, which is easy to hit when callers wrap Redis operations in asyncio.wait_for or cancel tasks.

Useful? React with 👍 / 👎.

Comment on lines +1938 to +1941
self._current_command_cache_key = CacheKey(
command=args[0],
redis_keys=tuple(kwargs["keys"]),
redis_args=args,

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 Normalize scalar cache keys before tuple conversion

When a cacheable command passes a single key as a scalar, this conversion splits it into characters/integers instead of storing the Redis key; for example zrevrange() sets options["keys"] = name, so name="myzset" becomes ('m', 'y', ...) (or byte integers). Redis invalidations arrive for b"myzset", which will not match that tuple, so async caching can keep returning stale ZREVRANGE results after the sorted set is modified. Normalize scalar str/bytes keys to a one-item tuple before building CacheKey.

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

Useful? React with 👍 / 👎.

Comment thread redis/asyncio/client.py
Comment on lines +467 to +473
if (cache_config or cache) and check_protocol_version(protocol, 3):
kwargs.update(
{
"cache": cache,
"cache_config": cache_config,
}
)

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 Forward cache options for Unix-socket clients

Because this cache wiring is nested only under the TCP branch, Redis(unix_socket_path=..., protocol=3, cache_config=...) silently constructs a pool with cache is None even though the user explicitly requested client-side caching and the RESP3 validation below still succeeds. Direct async ConnectionPool(path=..., connection_class=UnixDomainSocketConnection, cache_config=...) does create the proxy, so the high-level async constructor should pass the cache/cache_config kwargs for Unix sockets as well or reject the combination instead of disabling caching.

Useful? React with 👍 / 👎.

Comment on lines +2064 to +2067
await self._conn.read_response(
push_request=True,
timeout=0,
disconnect_on_error=False,

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 Break when zero-timeout invalidation reads time out

When a pending invalidation frame is only partially buffered, async read_response(timeout=0) returns None on the user-requested timeout rather than raising TimeoutError, but this loop only breaks on the exception. Because can_read() remains true while the partial bytes stay buffered, a cached async client can spin here and starve the event loop before the rest of the frame arrives; treat a None result like no complete push was available.

AGENTS.md reference: AGENTS.md:L145-L148

Useful? React with 👍 / 👎.

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.

Async redis-py client does not support client-side caching / key tracking

2 participants