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()