diff --git a/backend/src/infrastructure/cache/backends/redis.py b/backend/src/infrastructure/cache/backends/redis.py index b0e454cb..1edb6e02 100644 --- a/backend/src/infrastructure/cache/backends/redis.py +++ b/backend/src/infrastructure/cache/backends/redis.py @@ -106,19 +106,19 @@ async def delete(self, key: str) -> None: async def delete_pattern(self, pattern: str) -> None: """Delete all keys matching a pattern. + Pages the ``SCAN`` cursor to completion so every match is removed, not just + the first batch. Each batch is deleted as it is scanned. + Args: pattern: The pattern to match against keys. """ cursor = 0 - keys_to_delete = [] - - cursor_response, keys = await self.client.scan(cursor=cursor, match=pattern + "*", count=100) - - if keys: - keys_to_delete.extend(keys) - - if keys_to_delete: - await self.client.delete(*keys_to_delete) + while True: + cursor, keys = await self.client.scan(cursor=cursor, match=pattern + "*", count=100) + if keys: + await self.client.delete(*keys) + if cursor == 0: + break async def exists(self, key: str) -> bool: """Check if a key exists in the cache. diff --git a/backend/tests/unit/infrastructure/cache/backends/test_redis.py b/backend/tests/unit/infrastructure/cache/backends/test_redis.py index e20e539d..24d46eb3 100644 --- a/backend/tests/unit/infrastructure/cache/backends/test_redis.py +++ b/backend/tests/unit/infrastructure/cache/backends/test_redis.py @@ -73,3 +73,21 @@ async def test_delete_pattern(redis_backend, mock_redis): mock_redis.scan.assert_called_once_with(cursor=0, match="test_*", count=100) mock_redis.delete.assert_called_once_with(b"key1", b"key2", b"key3") + + +@pytest.mark.asyncio +async def test_delete_pattern_pages_cursor(redis_backend, mock_redis): + """delete_pattern pages the SCAN cursor to completion, not just the first batch. + + Regression: the old implementation ran a single SCAN and silently left matches + beyond the first window undeleted. + """ + mock_redis.scan.side_effect = [(5, [b"a1", b"a2"]), (0, [b"a3"])] + + await redis_backend.delete_pattern("a") + + assert mock_redis.scan.await_count == 2 + # the second scan resumes from the cursor the first one returned + assert mock_redis.scan.await_args_list[1].kwargs["cursor"] == 5 + deleted = [k for call in mock_redis.delete.await_args_list for k in call.args] + assert deleted == [b"a1", b"a2", b"a3"]