Skip to content

Add async client-side caching support - #4220

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

Add async client-side caching support#4220
lh0156 wants to merge 5 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
Touches the hot async command path and connection pooling with cache invalidation and concurrency; mistakes could cause stale reads or pool deadlocks, though behavior is heavily tested and gated on RESP3 and Redis 7.4+.

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

Redis now accepts cache / cache_config (RESP3-only, Redis 7.4+), exposes get_cache(), and forwards command keys (and other options) through to connections so cache keys can be built. The async connection pool constructs the shared cache, wraps connections in a new AsyncCacheProxyConnection (CLIENT TRACKING, invalidation push handling, hit/miss/eviction metrics), and uses the pool lock when CSC is enabled so in-flight fills and connection availability stay consistent.

Shared cache types gain completion_event on CacheEntry so concurrent async callers can wait on in-progress fills; CacheProxy signals that event on LRU eviction. Sync pool wiring avoids double-wrapping when a factory already returns a CacheProxy.

Docs in resp3_features.rst document async CSC usage; tests cover proxy behavior, RESP2 rejection, and integration round-trips.

Reviewed by Cursor Bugbot for commit b944df4. 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.

Comment thread redis/asyncio/connection.py

@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 thread redis/asyncio/connection.py Outdated
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 👍 / 👎.

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 db91b4b. Cache entries now retain enough pool context to verify that the owner connection is either the current connection or idle in the pool before draining it. A busy foreign connection is not read from; its entry is discarded and the command is refetched. Added a regression test proving the borrowed connection is not drained.

Comment thread redis/asyncio/connection.py Outdated
)
)

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 👍 / 👎.

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 db91b4b. If the underlying async send exits with cancellation or another BaseException after an IN_PROGRESS entry is installed, the entry is deleted and its completion event is signaled. Added coverage that a cancelled fill can be retried immediately.

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 👍 / 👎.

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 db91b4b. Scalar str/bytes cache keys are normalized to a one-item tuple before CacheKey creation, so commands such as ZREVRANGE do not split a key into characters or byte integers. Added a regression test.

Comment thread redis/asyncio/client.py Outdated
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 👍 / 👎.

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 db91b4b. Cache/cache_config wiring now happens after the Unix-socket/TCP branch, so Redis(unix_socket_path=..., protocol=3, cache_config=...) creates a cache-enabled pool. Added a constructor regression test.

Comment thread redis/asyncio/connection.py Outdated
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 👍 / 👎.

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 db91b4b. Shared invalidation draining now stops when timeout=0 returns None as well as when it raises TimeoutError, preventing a busy loop on partially buffered frames. Added a regression test.

@lh0156

lh0156 commented Aug 2, 2026

Copy link
Copy Markdown
Author

Follow-up fixes are in db91b4b. This revision addresses the post-rebase cache review feedback: packed-command key reset, safe idle-owner invalidation draining, cancelled fill cleanup, scalar cache-key normalization, Unix-socket cache configuration, and zero-timeout invalidation reads. Focused async connection/cache tests pass (112 passed, 41 skipped); ruff check and format validation pass.

@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: db91b4b165

ℹ️ 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 +1984 to +1985
async with self._cache_lock:
self._cache.set(cache_entry)

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 Wake waiters when evicting in-flight cache fills

When max_size is smaller than the number of concurrent cache misses, this set() can evict an existing IN_PROGRESS entry via the LRU policy without setting its completion_event. A coroutine that already saw that entry and is awaiting completion_event.wait() will never resume; the original filler also won't signal later because read_response() finds the entry missing. This can hang ordinary concurrent cached reads with small caches, e.g. two tasks waiting on GET k1 while another miss for k2 evicts the k1 placeholder.

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 6d81ecf. CacheProxy.set now captures the LRU entry selected for eviction and signals its completion_event after eviction, so async waiters cannot remain blocked on an evicted fill. Added test_async_cache_proxy_signals_evicted_in_progress_fill. The focused async connection and cache suites pass: 115 passed, 41 skipped.

Comment on lines +2091 to +2097
async with self._pool_lock:
is_current_connection = cached_entry.connection_ref is self._conn
is_available_connection = (
self._pool is None
or is_current_connection
or self._pool._is_connection_available(cached_entry.connection_ref)
)

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 Guard cached-entry drains for blocking pools

This lock only protects the normal ConnectionPool; BlockingConnectionPool.get_connection() skips _lock outside maintenance mode, so a cached hit can decide the cached entry's connection is idle and then await _drain_pending_invalidations() while another task borrows that same connection from the blocking pool. In that context a concurrent command reply can be consumed as a push invalidation, recreating the stream desynchronization this availability check is trying to prevent.

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 6d81ecf. BlockingConnectionPool._maybe_pool_lock now always serializes pool mutations with the cache owner availability check, including the normal get path. Added test_blocking_pool_serializes_cache_owner_checks to cover the previously unprotected interleaving.

Comment thread redis/asyncio/connection.py Outdated
Comment on lines +1958 to +1960
async with self._cache_lock:
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.

P2 Badge Retry when the replacement fill is still in progress

After a failed or invalidated fill wakes waiters, another coroutine can install a new IN_PROGRESS entry for the same cache key before this waiter reacquires the lock. This branch treats any non-missing entry as reusable and returns without sending the command; the following read_response() then records a miss and waits on a connection that never sent a request. Check that the entry is valid, or loop back to wait for the replacement fill instead of returning on a fresh in-progress placeholder.

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 6d81ecf. After an in-progress fill completes, send_command now re-reads the cache and returns only when the current entry is VALID. If the entry was replaced by another in-progress fill, it loops and waits for that replacement instead of returning with no response pending. Added test_async_cache_proxy_waits_for_replacement_in_progress_fill.

Comment thread redis/asyncio/connection.py Outdated
Comment thread redis/asyncio/connection.py Outdated
Comment thread redis/asyncio/connection.py Outdated

@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 b944df4. Configure here.

self._cache = cache
self._pool_lock = pool_lock
self._pool = pool
self._cache_lock = asyncio.Lock()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Per-proxy cache lock doesn't protect shared cache

Medium Severity

Each AsyncCacheProxyConnection creates its own _cache_lock = asyncio.Lock(), but all proxies from the same pool share the same _cache instance. This means two proxies can independently acquire their own locks and concurrently read/modify the shared cache at interleaving await points, leading to race conditions in cache state management (e.g., TOCTOU issues between checking and writing entries).

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b944df4. 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: b944df45f1

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

await self._conn.send_packed_command(command, check_health=check_health)

async def send_command(self, *args, **kwargs) -> None:
await self._process_pending_invalidations()

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 Pub/Sub messages before commands

When a cache-enabled Redis instance creates a PubSub, that connection is wrapped in AsyncCacheProxyConnection too. PubSub.execute_command intentionally avoids parsing pending responses because a subscribed connection can already have messages queued (redis/asyncio/client.py:1289-1291), but this unconditional drain runs before every SUBSCRIBE/UNSUBSCRIBE/PING and read_response(push_request=True) returns ordinary Pub/Sub pushes as well as invalidations. With a pending message, the drain consumes and discards it before PubSub.parse_response() can deliver it, so cache_config can silently lose Pub/Sub messages; restrict the drain to non-PubSub connections or preserve non-invalidation pushes.

Useful? React with 👍 / 👎.

Comment on lines +1938 to +1939
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.

P2 Badge Preserve XPENDING range under caching

When caching is enabled, xpending_range() sends the cache-allowlisted XPENDING command with parse_detail=True but no keys option (redis/commands/core.py:7799). This new guard raises ValueError before sending, so await r.xpending_range(...) stops working on a cache-enabled async client even though it is a read-only Redis command; add key metadata for that command path and keep the sync mirror aligned, or skip caching when metadata is unavailable.

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

Useful? React with 👍 / 👎.

keys_deleted = 0
else:
entries = tuple(self._cache.collection.values())
keys_deleted = len(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 Deduplicate multi-key invalidation deletes

When Redis sends one invalidation push containing both keys from a multi-key cache entry, for example an in-progress MGET k1 k2 invalidated by MSET k1 k2, DefaultCache.delete_by_redis_keys() queues the same CacheKey once per matched Redis key and raises KeyError on the second pop. Because this new async callback calls it before signaling completion events, that exception prevents waiters on the in-progress entry from being awakened, and the filler later finds the entry gone and also will not signal; dedupe the matched cache keys or signal waiters in a finally path.

Useful? React with 👍 / 👎.

cache_entry = self._cache.get(cache_key)
if cache_entry is not None:
cache_entry.status = CacheEntryStatus.VALID
cache_entry.cache_value = response

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 Cache a copy of first mutable responses

For cacheable commands whose response callback returns the raw mutable container, such as zrange(..., withscores=False), this stores the exact object that is returned to the first caller. If that caller mutates the list or dict it received, the cached value is mutated too, so later cache hits return caller-added data that was never in Redis; store a defensive copy here (and mirror the same fix in the sync cache path) before exposing the response.

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

Useful? React with 👍 / 👎.

Comment on lines +3230 to +3233
if self.cache is not None:
return AsyncCacheProxyConnection(
connection, self.cache, self._lock, pool=self
)

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 Don’t treat cache invalidations as dirty data

When maintenance notifications are disabled (for example Unix-socket clients force them off), these wrapped cache connections can legitimately have pending RESP3 invalidation pushes while idle in the pool. ensure_connection() below still treats any readable data as a dirty connection because the async pool lacks the sync pool's self.cache is None guard, so the next borrow disconnects and flushes the whole cache instead of letting send_command() drain the invalidation; skip that dirty-read reconnect for cache-enabled pools as the sync implementation does.

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

Useful? React with 👍 / 👎.

Comment on lines +2098 to +2099
if response is None:
return 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 Continue draining after handled invalidations

read_response(push_request=True) returns None not only for a zero-timeout partial read, but also after a handled invalidation because _on_invalidation_callback() has no return value. This means a connection with queued invalidations for k1 and then k2 stops after processing k1; a following cached GET k2 can still hit the stale entry because the second push remains buffered. Continue the loop after a handled invalidation and only stop on the timeout/no-complete-frame case, matching the sync drain behavior.

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

Useful? React with 👍 / 👎.

Comment on lines +1996 to +1997
async with self._cache_lock:
self._cache.set(cache_entry)

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 Recheck before installing miss placeholders

The cache lookup above and this placeholder insertion are separated by awaits and not protected by a shared cache-level critical section, so two concurrent misses for the same key on different connections can both send the command and the later placeholder can replace the earlier one. If the first response then marks the later placeholder valid, the second caller returns that cached value without reading its own already-sent reply, leaving the socket dirty for the next borrower; re-read the cache immediately before setting the IN_PROGRESS entry and wait/retry if another filler won the race.

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