From 1d3b694011178e09242a19310900bc7faa23d1db Mon Sep 17 00:00:00 2001 From: Shubham Kaudewar Date: Mon, 8 Sep 2025 23:58:51 +0530 Subject: [PATCH 1/5] Support bytes key for client-side caching --- redis/cache.py | 4 ++-- tests/test_cache.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/redis/cache.py b/redis/cache.py index cb29ffe785..5e4929522f 100644 --- a/redis/cache.py +++ b/redis/cache.py @@ -204,8 +204,8 @@ def delete_by_redis_keys(self, redis_keys: List[bytes]) -> List[bool]: keys_to_delete = [] for redis_key in redis_keys: - if isinstance(redis_key, bytes): - redis_key = redis_key.decode() + if isinstance(redis_key, str): + redis_key = redis_key.encode("utf-8") for cache_key in self._cache: if redis_key in cache_key.redis_keys: keys_to_delete.append(cache_key) diff --git a/tests/test_cache.py b/tests/test_cache.py index 1f3193c49d..93b5870bb6 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -1038,6 +1038,40 @@ def test_delete_by_redis_keys_removes_associated_entries(self, mock_connection): assert len(cache.collection) == 1 assert cache.get(cache_key4).cache_value == b"bar3" + def test_delete_by_redis_keys_with_non_utf8_bytes_key(self, mock_connection): + """cache fails to invalidate entries when redis_keys contain non-UTF-8 bytes.""" + cache = DefaultCache(CacheConfig(max_size=5)) + + # Valid UTF-8 key works + utf8_key = b"foo" + utf8_cache_key = CacheKey(command="GET", redis_keys=(utf8_key,)) + assert cache.set( + CacheEntry( + cache_key=utf8_cache_key, + cache_value=b"bar", + status=CacheEntryStatus.VALID, + connection_ref=mock_connection, + ) + ) + + # Non-UTF-8 bytes key + bad_key = b"f\xffoo" + bad_cache_key = CacheKey(command="GET", redis_keys=(bad_key,)) + assert cache.set( + CacheEntry( + cache_key=bad_cache_key, + cache_value=b"bar2", + status=CacheEntryStatus.VALID, + connection_ref=mock_connection, + ) + ) + + # Delete both keys: utf8 should succeed, non-utf8 exposes bug + results = cache.delete_by_redis_keys([utf8_key, bad_key]) + + assert results[0] is True + assert results[1] is True, "Cache did not remove entry for non-UTF8 bytes key" + def test_flush(self, mock_connection): cache = DefaultCache(CacheConfig(max_size=5)) From 23a0cfab4182d65961626cbcebb4752e673b7046 Mon Sep 17 00:00:00 2001 From: Shubham Kaudewar Date: Tue, 9 Sep 2025 00:30:40 +0530 Subject: [PATCH 2/5] Prepare both versions for lookup --- redis/cache.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/redis/cache.py b/redis/cache.py index 5e4929522f..aa94ba2f3b 100644 --- a/redis/cache.py +++ b/redis/cache.py @@ -199,15 +199,23 @@ def delete_by_cache_keys(self, cache_keys: List[CacheKey]) -> List[bool]: return response - def delete_by_redis_keys(self, redis_keys: List[bytes]) -> List[bool]: + def delete_by_redis_keys(self, redis_keys: Union[List[bytes], List[str]]) -> List[bool]: response = [] keys_to_delete = [] for redis_key in redis_keys: + # Prepare both versions for lookup + candidates = [redis_key] if isinstance(redis_key, str): - redis_key = redis_key.encode("utf-8") + candidates.append(redis_key.encode("utf-8")) + elif isinstance(redis_key, bytes): + try: + candidates.append(redis_key.decode("utf-8")) + except UnicodeDecodeError: + pass # Non-UTF-8 bytes, skip str version + for cache_key in self._cache: - if redis_key in cache_key.redis_keys: + if any(candidate in cache_key.redis_keys for candidate in candidates): keys_to_delete.append(cache_key) response.append(True) From 5b5f824485f09a761f107620beb6b3d55c4708a1 Mon Sep 17 00:00:00 2001 From: Shubham Kaudewar Date: Tue, 16 Sep 2025 23:32:26 +0530 Subject: [PATCH 3/5] linter fix --- redis/cache.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/redis/cache.py b/redis/cache.py index aa94ba2f3b..041818e700 100644 --- a/redis/cache.py +++ b/redis/cache.py @@ -199,7 +199,9 @@ def delete_by_cache_keys(self, cache_keys: List[CacheKey]) -> List[bool]: return response - def delete_by_redis_keys(self, redis_keys: Union[List[bytes], List[str]]) -> List[bool]: + def delete_by_redis_keys( + self, redis_keys: Union[List[bytes], List[str]] + ) -> List[bool]: response = [] keys_to_delete = [] From 4ae25bf23e48e58314040459460dfd1ef2e45187 Mon Sep 17 00:00:00 2001 From: Shubham Kaudewar Date: Tue, 16 Sep 2025 23:43:45 +0530 Subject: [PATCH 4/5] cache_key check for only one matched against multiple candidates fix --- redis/cache.py | 7 +++++-- tests/test_cache.py | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/redis/cache.py b/redis/cache.py index 041818e700..1295dc4e69 100644 --- a/redis/cache.py +++ b/redis/cache.py @@ -216,10 +216,13 @@ def delete_by_redis_keys( except UnicodeDecodeError: pass # Non-UTF-8 bytes, skip str version - for cache_key in self._cache: + matched = False + for cache_key in list(self._cache): if any(candidate in cache_key.redis_keys for candidate in candidates): keys_to_delete.append(cache_key) - response.append(True) + matched = True + + response.append(matched) for key in keys_to_delete: self._cache.pop(key) diff --git a/tests/test_cache.py b/tests/test_cache.py index 93b5870bb6..55c1ed43dd 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -1034,7 +1034,7 @@ def test_delete_by_redis_keys_removes_associated_entries(self, mock_connection): ) ) - assert cache.delete_by_redis_keys([b"foo", b"foo1"]) == [True, True, True] + assert cache.delete_by_redis_keys([b"foo", b"foo1"]) == [True, True] assert len(cache.collection) == 1 assert cache.get(cache_key4).cache_value == b"bar3" From b33b2f313fa8aafc2a9b139ec1955c2540eb7633 Mon Sep 17 00:00:00 2001 From: Shubham Kaudewar Date: Tue, 16 Sep 2025 23:52:19 +0530 Subject: [PATCH 5/5] revert back correct changes --- redis/cache.py | 7 ++----- tests/test_cache.py | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/redis/cache.py b/redis/cache.py index 1295dc4e69..041818e700 100644 --- a/redis/cache.py +++ b/redis/cache.py @@ -216,13 +216,10 @@ def delete_by_redis_keys( except UnicodeDecodeError: pass # Non-UTF-8 bytes, skip str version - matched = False - for cache_key in list(self._cache): + for cache_key in self._cache: if any(candidate in cache_key.redis_keys for candidate in candidates): keys_to_delete.append(cache_key) - matched = True - - response.append(matched) + response.append(True) for key in keys_to_delete: self._cache.pop(key) diff --git a/tests/test_cache.py b/tests/test_cache.py index 55c1ed43dd..93b5870bb6 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -1034,7 +1034,7 @@ def test_delete_by_redis_keys_removes_associated_entries(self, mock_connection): ) ) - assert cache.delete_by_redis_keys([b"foo", b"foo1"]) == [True, True] + assert cache.delete_by_redis_keys([b"foo", b"foo1"]) == [True, True, True] assert len(cache.collection) == 1 assert cache.get(cache_key4).cache_value == b"bar3"