From 4fbc650cdb230d56bc1ebe3b048a88c4cf07d678 Mon Sep 17 00:00:00 2001 From: Joshua Nishanth Tarun A Date: Fri, 15 May 2026 01:56:28 +0530 Subject: [PATCH] fix(index): use UNLINK instead of DEL in SearchIndex.drop_keys DEL reclaims memory on the main thread, so a single drop_keys call over a large key set stalls Redis proportionally to the freed keyspace. For SemanticCache use cases where scope-targeted invalidation routinely sweeps 10K to 1M+ keys (for example, a policy version rollover in a multi-tenant deployment), this is a customer-visible latency event on every invalidation. UNLINK has the same return semantics as DEL and is available on Redis 4+, so it is a strict superset for this use case. The only observable difference is that reclaimed memory is reported lazily by MEMORY USAGE, which is the point. Applies to both SearchIndex.drop_keys and AsyncSearchIndex.drop_keys. SemanticCache.drop() flows through this path via the keys= argument. Refs #600 --- redisvl/index/index.py | 18 +++++-- tests/unit/test_drop_keys_unlink.py | 82 +++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 tests/unit/test_drop_keys_unlink.py diff --git a/redisvl/index/index.py b/redisvl/index/index.py index 4d7a871b..ac5e3982 100644 --- a/redisvl/index/index.py +++ b/redisvl/index/index.py @@ -826,6 +826,11 @@ def invalidate_sql_schema_cache(self) -> None: def drop_keys(self, keys: str | list[str]) -> int: """Remove a specific entry or entries from the index by it's key ID. + Uses ``UNLINK`` rather than ``DEL`` so memory reclamation runs on a + background thread. This avoids blocking the main thread when a large + number of keys are dropped at once (for example, scope-targeted + ``SemanticCache`` invalidation). The returned count is unchanged. + Args: keys (Union[str, List[str]]): The document ID or IDs to remove from the index. @@ -833,9 +838,9 @@ def drop_keys(self, keys: str | list[str]) -> int: int: Count of records deleted from Redis. """ if isinstance(keys, list): - return self._redis_client.delete(*keys) # type: ignore + return self._redis_client.unlink(*keys) # type: ignore else: - return self._redis_client.delete(keys) # type: ignore + return self._redis_client.unlink(keys) # type: ignore def drop_documents(self, ids: str | list[str]) -> int: """Remove documents from the index by their document IDs. @@ -1779,6 +1784,11 @@ def invalidate_sql_schema_cache(self) -> None: async def drop_keys(self, keys: str | list[str]) -> int: """Remove a specific entry or entries from the index by it's key ID. + Uses ``UNLINK`` rather than ``DEL`` so memory reclamation runs on a + background thread. This avoids blocking the main thread when a large + number of keys are dropped at once (for example, scope-targeted + ``SemanticCache`` invalidation). The returned count is unchanged. + Args: keys (Union[str, List[str]]): The document ID or IDs to remove from the index. @@ -1787,9 +1797,9 @@ async def drop_keys(self, keys: str | list[str]) -> int: """ client = await self._get_client() if isinstance(keys, list): - return await client.delete(*keys) + return await client.unlink(*keys) else: - return await client.delete(keys) + return await client.unlink(keys) async def drop_documents(self, ids: str | list[str]) -> int: """Remove documents from the index by their document IDs. diff --git a/tests/unit/test_drop_keys_unlink.py b/tests/unit/test_drop_keys_unlink.py new file mode 100644 index 00000000..cc84e14b --- /dev/null +++ b/tests/unit/test_drop_keys_unlink.py @@ -0,0 +1,82 @@ +"""Unit tests for SearchIndex/AsyncSearchIndex.drop_keys using UNLINK (issue #600).""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from redisvl.index import AsyncSearchIndex, SearchIndex +from redisvl.schema import IndexSchema + + +def _schema() -> IndexSchema: + return IndexSchema.from_dict( + { + "index": {"name": "drop_keys_test", "prefix": "drop_keys_test"}, + "fields": [{"name": "id", "type": "tag"}], + } + ) + + +class TestDropKeysUsesUnlink: + """SearchIndex.drop_keys should issue UNLINK, not DEL. + + UNLINK reclaims memory on a background thread; DEL reclaims on the main + thread and stalls the server when dropping a large key set (for example, + scope-targeted SemanticCache invalidation). + """ + + def test_single_key_calls_unlink(self): + client = MagicMock() + client.unlink.return_value = 1 + client.delete.return_value = 1 + index = SearchIndex(schema=_schema(), redis_client=client) + + result = index.drop_keys("drop_keys_test:1") + + assert result == 1 + client.unlink.assert_called_once_with("drop_keys_test:1") + client.delete.assert_not_called() + + def test_list_of_keys_calls_unlink(self): + client = MagicMock() + client.unlink.return_value = 3 + client.delete.return_value = 3 + index = SearchIndex(schema=_schema(), redis_client=client) + + keys = ["drop_keys_test:1", "drop_keys_test:2", "drop_keys_test:3"] + result = index.drop_keys(keys) + + assert result == 3 + client.unlink.assert_called_once_with(*keys) + client.delete.assert_not_called() + + +class TestAsyncDropKeysUsesUnlink: + """AsyncSearchIndex.drop_keys should issue UNLINK, not DEL.""" + + @pytest.mark.asyncio + async def test_single_key_calls_unlink(self): + client = MagicMock() + client.unlink = AsyncMock(return_value=1) + client.delete = AsyncMock(return_value=1) + index = AsyncSearchIndex(schema=_schema(), redis_client=client) + + result = await index.drop_keys("drop_keys_test:1") + + assert result == 1 + client.unlink.assert_awaited_once_with("drop_keys_test:1") + client.delete.assert_not_awaited() + + @pytest.mark.asyncio + async def test_list_of_keys_calls_unlink(self): + client = MagicMock() + client.unlink = AsyncMock(return_value=3) + client.delete = AsyncMock(return_value=3) + index = AsyncSearchIndex(schema=_schema(), redis_client=client) + + keys = ["drop_keys_test:1", "drop_keys_test:2", "drop_keys_test:3"] + result = await index.drop_keys(keys) + + assert result == 3 + client.unlink.assert_awaited_once_with(*keys) + client.delete.assert_not_awaited()