From c7d1bbb3e1b32beb00484699791dc036d30d262a Mon Sep 17 00:00:00 2001 From: Vishal Bala Date: Thu, 3 Sep 2026 09:49:05 +0200 Subject: [PATCH 1/8] fix(cache): build async cache clients without a deprecation warning BaseCache._get_async_redis_client called get_async_redis_connection, which warns unconditionally, so anyone who built a cache from a redis_url and awaited a cache method saw a DeprecationWarning for an API they never called. A suite-wide filter in pyproject.toml hid it. Point the method at _get_aredis_connection, the async form already used everywhere else, and drop the filter. Cache clients now also report their library name via CLIENT SETINFO like every other RedisVL client, and a connection failure surfaces when the client is created rather than at the first command. The regression guard lives in tests/unit/test_connection_normalization.py and lands with the next commit, which is the first to touch that file. --- pyproject.toml | 3 --- redisvl/extensions/cache/base.py | 2 +- tests/unit/test_error_handling.py | 6 +++--- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 450df15c6..afdaf3b2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -169,9 +169,6 @@ exclude = ''' [tool.pytest.ini_options] log_cli = true asyncio_mode = "auto" -filterwarnings = [ - "ignore:get_async_redis_connection will become async in the next major release:DeprecationWarning", -] [tool.mypy] warn_unused_configs = true diff --git a/redisvl/extensions/cache/base.py b/redisvl/extensions/cache/base.py index 8ff3566db..6af540aa3 100644 --- a/redisvl/extensions/cache/base.py +++ b/redisvl/extensions/cache/base.py @@ -139,7 +139,7 @@ async def _get_async_redis_client(self) -> AsyncRedisClient: url = cast(str | None, self.redis_kwargs["redis_url"]) kwargs = cast(dict[str, Any], self.redis_kwargs["connection_kwargs"]) self._async_redis_client = ( - RedisConnectionFactory.get_async_redis_connection( + await RedisConnectionFactory._get_aredis_connection( redis_url=url, **kwargs ) ) diff --git a/tests/unit/test_error_handling.py b/tests/unit/test_error_handling.py index 4dbc47d7c..c6ce31377 100644 --- a/tests/unit/test_error_handling.py +++ b/tests/unit/test_error_handling.py @@ -11,7 +11,7 @@ import asyncio from collections.abc import Mapping -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest import redis.exceptions @@ -204,11 +204,11 @@ async def test_connection_kwargs_valid_dict(self): "redisvl.extensions.cache.base.RedisConnectionFactory" ) as mock_factory: mock_client = Mock() - mock_factory.get_async_redis_connection.return_value = mock_client + mock_factory._get_aredis_connection = AsyncMock(return_value=mock_client) result = await cache._get_async_redis_client() assert result == mock_client - mock_factory.get_async_redis_connection.assert_called_once() + mock_factory._get_aredis_connection.assert_awaited_once() class TestRouterConfigErrorHandling: From 13c00479dd4b6be596ed81c11895b5b44da07bea Mon Sep 17 00:00:00 2001 From: Vishal Bala Date: Thu, 3 Sep 2026 09:49:25 +0200 Subject: [PATCH 2/8] feat(index): add owns_client for explicit client ownership handover An index closed only a client it created itself, and callers who needed to override that wrote to the private _owns_redis_client keyword or poked the attribute afterwards. The MCP server did the latter, which never worked as intended: _register_client_finalizer gates on the flag, so a post-construction flip lands after registration has already declined and no finalizer is ever created. owns_client states ownership once, at construction, before the finalizer is registered. It replaces the private keyword rather than sitting alongside it, so there is one spelling and no precedence question. An explicit value also wins over the ownership from_existing would otherwise assume for a client it created, which is why the assignment there uses setdefault. Also documents the accessor asymmetry between the two classes: _redis_client lazily creates on SearchIndex but is a plain nullable attribute on AsyncSearchIndex, whose lazy getter is _get_client. --- redisvl/extensions/router/semantic.py | 2 +- redisvl/index/index.py | 47 +++++++++++++--- redisvl/mcp/server.py | 8 +-- redisvl/redis/connection.py | 2 +- tests/unit/test_connection_normalization.py | 61 ++++++++++++++++++++- tests/unit/test_index_gc_finalizer.py | 54 ++++++++++++++++++ 6 files changed, 159 insertions(+), 15 deletions(-) diff --git a/redisvl/extensions/router/semantic.py b/redisvl/extensions/router/semantic.py index 16472615b..4d9111e19 100644 --- a/redisvl/extensions/router/semantic.py +++ b/redisvl/extensions/router/semantic.py @@ -192,7 +192,7 @@ def from_existing( **factory_kwargs, ) index_kwargs["_client_validated"] = True - index_kwargs["_owns_redis_client"] = True + index_kwargs["owns_client"] = True if lib_name is not None: index_kwargs["lib_name"] = lib_name created_redis_client = True diff --git a/redisvl/index/index.py b/redisvl/index/index.py index 5a34c03f0..4e03f489f 100644 --- a/redisvl/index/index.py +++ b/redisvl/index/index.py @@ -816,11 +816,11 @@ def __init__( redis_url: str | None = None, connection_kwargs: dict[str, Any] | None = None, validate_on_load: bool = False, + owns_client: bool | None = None, **kwargs, ): - """Initialize the RedisVL search index with a schema, Redis client - (or URL string with other connection args), connection_args, and other - kwargs. + """Initialize the RedisVL search index with a schema and either a Redis + client or a URL string with other connection kwargs. Args: schema (IndexSchema): Index schema object. @@ -832,6 +832,12 @@ def __init__( args. validate_on_load (bool, optional): Whether to validate data against schema when loading. Defaults to False. + owns_client (Optional[bool]): Whether the index should close the + Redis client when it is disconnected or garbage collected. By + default the index owns only a client it created itself from + `redis_url`, and never closes a client passed as + `redis_client`. Pass True to hand over a client you created, + or False to keep one the index would otherwise own. """ if "connection_args" in kwargs: connection_kwargs = kwargs.pop("connection_args") @@ -851,7 +857,13 @@ def __init__( self._sql_executors: dict[str, Any] = {} self._validated_client = kwargs.pop("_client_validated", False) - self._owns_redis_client = kwargs.pop("_owns_redis_client", redis_client is None) + # An index owns the client it created itself, so a caller-supplied + # client is not closed unless the caller hands ownership over with + # owns_client=True. Assigned before the finalizer is registered, + # because _register_client_finalizer reads this flag. + self._owns_redis_client = ( + redis_client is None if owns_client is None else owns_client + ) self._client_finalizer = None # Close the owned client when this index is garbage collected. When # the client is created lazily, registration happens at creation time @@ -923,7 +935,9 @@ def from_existing( schema_dict = convert_index_info_to_schema(index_info) schema = IndexSchema.from_dict(schema_dict) if created_redis_client: - init_kwargs["_owns_redis_client"] = True + # The index created this client, so it owns it unless the caller + # explicitly said otherwise. + init_kwargs.setdefault("owns_client", True) return cls( schema, redis_client=redis_client, @@ -2131,6 +2145,7 @@ def __init__( redis_client: AsyncRedisClient | None = None, connection_kwargs: dict[str, Any] | None = None, validate_on_load: bool = False, + owns_client: bool | None = None, **kwargs, ): """Initialize the RedisVL async search index with a schema. @@ -2145,6 +2160,12 @@ def __init__( args. validate_on_load (bool, optional): Whether to validate data against schema when loading. Defaults to False. + owns_client (Optional[bool]): Whether the index should close the + Redis client when it is disconnected or garbage collected. By + default the index owns only a client it created itself from + `redis_url`, and never closes a client passed as + `redis_client`. Pass True to hand over a client you created, + or False to keep one the index would otherwise own. """ if "redis_kwargs" in kwargs: connection_kwargs = kwargs.pop("redis_kwargs") @@ -2157,7 +2178,11 @@ def __init__( self._validate_on_load = validate_on_load self._lib_name: str | None = kwargs.pop("lib_name", None) - # Store connection parameters + # Store connection parameters. Note the asymmetry with SearchIndex: + # there, _redis_client is a property that lazily creates the client, + # whereas here it is a plain attribute that stays None until + # _get_client() creates one. Read it through _get_client(), never + # directly, unless you specifically want the un-created state. self._redis_client = redis_client self._redis_url = redis_url self._connection_kwargs = connection_kwargs or {} @@ -2165,7 +2190,11 @@ def __init__( self._sql_executors: dict[str, Any] = {} self._validated_client = kwargs.pop("_client_validated", False) - self._owns_redis_client = kwargs.pop("_owns_redis_client", redis_client is None) + # See the note on SearchIndex.__init__: ownership follows from who + # created the client, and owns_client=True hands it to the index. + self._owns_redis_client = ( + redis_client is None if owns_client is None else owns_client + ) self._client_finalizer = None # Close the owned client when this index is garbage collected. When # the client is created lazily, registration happens at creation time @@ -2231,7 +2260,9 @@ async def from_existing( schema_dict = convert_index_info_to_schema(index_info) schema = IndexSchema.from_dict(schema_dict) if created_redis_client: - init_kwargs["_owns_redis_client"] = True + # The index created this client, so it owns it unless the caller + # explicitly said otherwise. + init_kwargs.setdefault("owns_client", True) return cls( schema, redis_client=redis_client, diff --git a/redisvl/mcp/server.py b/redisvl/mcp/server.py index 554ccc18e..53b894661 100644 --- a/redisvl/mcp/server.py +++ b/redisvl/mcp/server.py @@ -643,11 +643,11 @@ async def _load_effective_schema( @staticmethod def _make_index(schema: IndexSchema, client: Any) -> AsyncSearchIndex: """Bind an inspected schema and Redis client into an async index.""" - index = AsyncSearchIndex(schema=schema, redis_client=client) # The server acquired this client explicitly during startup, so hand - # ownership to the index for a single shutdown path. - index._owns_redis_client = True - return index + # ownership to the index for a single shutdown path. Passing + # owns_client at construction also registers the GC finalizer, which a + # post-construction flag flip would be too late for. + return AsyncSearchIndex(schema=schema, redis_client=client, owns_client=True) async def _initialize_vectorizer( self, binding: MCPIndexBindingConfig, schema: IndexSchema, timeout: int diff --git a/redisvl/redis/connection.py b/redisvl/redis/connection.py index 53edec80c..eca8a2f78 100644 --- a/redisvl/redis/connection.py +++ b/redisvl/redis/connection.py @@ -35,7 +35,7 @@ def _split_from_existing_kwargs( init_kwargs: dict[str, Any] = {} connection_kwargs: dict[str, Any] = {} - for key in ("validate_on_load", "lib_name"): + for key in ("validate_on_load", "lib_name", "owns_client"): if key in kwargs: init_kwargs[key] = kwargs.pop(key) diff --git a/tests/unit/test_connection_normalization.py b/tests/unit/test_connection_normalization.py index 98622eb1c..f5915ddd9 100644 --- a/tests/unit/test_connection_normalization.py +++ b/tests/unit/test_connection_normalization.py @@ -7,6 +7,7 @@ from redisvl.extensions.router.semantic import SemanticRouter from redisvl.index import AsyncSearchIndex, SearchIndex from redisvl.query.sql import SQLQuery +from redisvl.utils.utils import assert_no_warnings def _schema_dict(name: str = "idx") -> dict: @@ -105,6 +106,41 @@ def test_search_index_from_existing_owns_factory_created_client(): created_client.close.assert_called_once_with() +def test_search_index_from_existing_honours_explicit_owns_client(): + """``owns_client`` is an init kwarg, not a connection kwarg. + + Without the allow-list entry in ``_split_from_existing_kwargs`` it would + fall through to ``connection_kwargs`` and redis-py would reject it. An + explicit value also wins over the ownership ``from_existing`` would + otherwise assume for a client it created itself. + """ + created_client = MagicMock() + + with ( + patch( + "redisvl.index.index.RedisConnectionFactory.get_redis_connection", + return_value=created_client, + ) as mock_get_connection, + patch.object(SearchIndex, "_info", return_value={}), + patch( + "redisvl.index.index.convert_index_info_to_schema", + return_value=_schema_dict("search-index"), + ), + ): + index = SearchIndex.from_existing( + "search-index", + redis_url="redis://localhost:6380", + owns_client=False, + ) + + mock_get_connection.assert_called_once_with(redis_url="redis://localhost:6380") + assert index._owns_redis_client is False + + index.disconnect() + + created_client.close.assert_not_called() + + @pytest.mark.asyncio async def test_async_search_index_from_existing_prefers_provided_client(): """Use the provided async Redis client instead of constructing a new one.""" @@ -278,7 +314,7 @@ def test_semantic_router_from_existing_rebuilds_from_redis_url(): assert mock_from_dict.call_args.kwargs["_index_kwargs"] == { "_internal_flag": True, "_client_validated": True, - "_owns_redis_client": True, + "owns_client": True, } assert result is loaded_router @@ -300,6 +336,29 @@ def test_base_cache_sync_client_creation_uses_connection_factory(): assert client is mock_client +@pytest.mark.asyncio +async def test_base_cache_async_client_creation_emits_no_warning(): + """Creating a cache's async client must not warn. + + ``_get_async_redis_client`` used to call ``get_async_redis_connection``, + which warns unconditionally, so cache users saw a DeprecationWarning for + an API they never called. A suite-wide filter in pyproject.toml hid it. + This is the guard that replaced that filter. + """ + cache = EmbeddingsCache(redis_url="redis://localhost:6379") + mock_client = MagicMock() + + with patch( + "redisvl.extensions.cache.base.RedisConnectionFactory._get_aredis_connection", + new=AsyncMock(return_value=mock_client), + ) as mock_get_connection: + with assert_no_warnings(): + client = await cache._get_async_redis_client() + + mock_get_connection.assert_awaited_once_with(redis_url="redis://localhost:6379") + assert client is mock_client + + def test_sql_query_uses_connection_factory_for_redis_url(): """Build SQL query helper connections through the shared connection factory.""" fake_sql_redis_module = _fake_sql_redis_module() diff --git a/tests/unit/test_index_gc_finalizer.py b/tests/unit/test_index_gc_finalizer.py index 7a36b5b49..37bb2ee3c 100644 --- a/tests/unit/test_index_gc_finalizer.py +++ b/tests/unit/test_index_gc_finalizer.py @@ -223,3 +223,57 @@ def test_async_injected_client_not_closed_on_collection(self): collect() fake_client.aclose.assert_not_awaited() + + +class TestOwnsClientHandover: + """``owns_client`` overrides who closes the client. + + By default an index closes only a client it created itself. These tests + cover the two explicit overrides, which are the only way to hand a + pre-built client over (or to keep one the index would otherwise own) now + that ``set_client()`` is gone. + """ + + def test_sync_injected_client_closed_when_ownership_handed_over(self): + schema = IndexSchema.from_dict(SCHEMA_DICT) + fake_client = mock.MagicMock(name="handed_over_sync_client") + index = SearchIndex(schema, redis_client=fake_client, owns_client=True) + + del index + collect() + + fake_client.close.assert_called_once() + + def test_async_injected_client_closed_when_ownership_handed_over(self): + schema = IndexSchema.from_dict(SCHEMA_DICT) + fake_client = mock.MagicMock(name="handed_over_async_client") + fake_client.aclose = mock.AsyncMock() + index = AsyncSearchIndex(schema, redis_client=fake_client, owns_client=True) + + del index + collect() + + fake_client.aclose.assert_awaited_once() + + def test_sync_lazily_created_client_kept_when_ownership_declined(self): + """``owns_client=False`` keeps a client the index would have owned. + + Only covered on the sync class: the flag is read by shared + ``__init__`` code, and the per-flavour close paths are covered above. + """ + schema = IndexSchema.from_dict(SCHEMA_DICT) + fake_client = mock.MagicMock(name="lazily_created_sync_client") + + with mock.patch( + "redisvl.index.index.RedisConnectionFactory.get_redis_connection", + return_value=fake_client, + ): + index = SearchIndex( + schema, redis_url="redis://fake:6379", owns_client=False + ) + assert index._redis_client is fake_client + + del index + collect() + + fake_client.close.assert_not_called() From 8877e4a62fd9db3108a9724a6618ed3c4d520382 Mon Sep 17 00:00:00 2001 From: Vishal Bala Date: Thu, 3 Sep 2026 09:49:37 +0200 Subject: [PATCH 3/8] fix(migration): read the lazily created client, not the raw property The four migration modules read index.client, which is None until the client is lazily created, and each handled that differently: one raised, two recorded an error, one dereferenced unguarded, and the planner degraded to an empty key sample that then made the key-sample check vacuously true. All four build their index through from_existing, which always yields a client, so none of this was reachable in practice. Reading through _redis_client and _get_client removes the disagreement and the dead guards with it. The test doubles are renamed to match the real accessors so they still stand in for an index. --- redisvl/migration/async_planner.py | 4 +-- redisvl/migration/async_validation.py | 34 +++++++--------------- redisvl/migration/planner.py | 20 ++++++++----- redisvl/migration/validation.py | 12 ++------ tests/unit/test_async_migration_planner.py | 4 +-- tests/unit/test_migration_planner.py | 9 +++--- 6 files changed, 33 insertions(+), 50 deletions(-) diff --git a/redisvl/migration/async_planner.py b/redisvl/migration/async_planner.py index 6c75efda2..99972208e 100644 --- a/redisvl/migration/async_planner.py +++ b/redisvl/migration/async_planner.py @@ -235,9 +235,7 @@ async def snapshot_source( prefixes = index.schema.index.prefix prefix_list = prefixes if isinstance(prefixes, list) else [prefixes] - client = index.client - if client is None: - raise ValueError("Failed to get Redis client from index") + client = await index._get_client() return SourceSnapshot( index_name=index_name, diff --git a/redisvl/migration/async_validation.py b/redisvl/migration/async_validation.py index ce742a3d0..2575daaf6 100644 --- a/redisvl/migration/async_validation.py +++ b/redisvl/migration/async_validation.py @@ -74,13 +74,10 @@ async def validate( validation.doc_count_match = source_total == target_total key_sample = plan.source.keyspace.key_sample - client = target_index.client if not key_sample: validation.key_sample_exists = True - elif client is None: - validation.key_sample_exists = False - validation.errors.append("Failed to get Redis client for key sample check") else: + client = await target_index._get_client() # Handle prefix change: transform key_sample to use new prefix. # Must match the executor's RENAME logic exactly: # new_key = new_prefix + key[len(old_prefix):] @@ -140,9 +137,7 @@ async def validate( async def _count_index_keys(self, index: AsyncSearchIndex) -> int: """Count keys matching the target index prefixes with SCAN.""" - client = index.client - if client is None: - raise ValueError("Redis client is required to count index keys") + client = await index._get_client() prefixes = index.schema.index.prefix prefix_list = prefixes if isinstance(prefixes, list) else [prefixes] @@ -181,25 +176,16 @@ async def _run_query_checks( ) ) - client = target_index.client + client = await target_index._get_client() for key in query_checks.get("keys_exist", []): - if client is None: - results.append( - QueryCheckResult( - name=f"key:{key}", - passed=False, - details="Failed to get Redis client", - ) - ) - else: - exists = bool(await client.exists(key)) - results.append( - QueryCheckResult( - name=f"key:{key}", - passed=exists, - details="Key exists" if exists else "Key not found", - ) + exists = bool(await client.exists(key)) + results.append( + QueryCheckResult( + name=f"key:{key}", + passed=exists, + details="Key exists" if exists else "Key not found", ) + ) return results diff --git a/redisvl/migration/planner.py b/redisvl/migration/planner.py index 4c09fe04c..cd6f711f8 100644 --- a/redisvl/migration/planner.py +++ b/redisvl/migration/planner.py @@ -2,7 +2,7 @@ from copy import deepcopy from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple, cast import yaml @@ -18,6 +18,7 @@ ) from redisvl.redis.connection import supports_svs from redisvl.schema.schema import IndexSchema +from redisvl.types import SyncRedisClient class MigrationPlanner: @@ -226,7 +227,7 @@ def snapshot_source( prefixes=prefix_list, key_separator=index.schema.index.key_separator, key_sample=self._sample_keys( - client=index.client, + client=index._redis_client, prefixes=prefix_list, key_separator=index.schema.index.key_separator, ), @@ -647,10 +648,10 @@ def write_plan(self, plan: MigrationPlan, plan_out: str) -> None: yaml.safe_dump(plan.model_dump(exclude_none=True), f, sort_keys=False) def _sample_keys( - self, *, client: Any, prefixes: List[str], key_separator: str + self, *, client: SyncRedisClient, prefixes: List[str], key_separator: str ) -> List[str]: key_sample: List[str] = [] - if client is None or self.key_sample_limit <= 0: + if self.key_sample_limit <= 0: return key_sample for prefix in prefixes: @@ -666,10 +667,13 @@ def _sample_keys( match_pattern = f"{prefix}*" cursor = 0 while True: - cursor, keys = client.scan( - cursor=cursor, - match=match_pattern, - count=max(self.key_sample_limit, 1000), + cursor, keys = cast( + tuple[int, list[Any]], + client.scan( + cursor=cursor, + match=match_pattern, + count=max(self.key_sample_limit, 1000), + ), ) for key in keys: decoded_key = key.decode() if isinstance(key, bytes) else str(key) diff --git a/redisvl/migration/validation.py b/redisvl/migration/validation.py index f8735a443..6735d6479 100644 --- a/redisvl/migration/validation.py +++ b/redisvl/migration/validation.py @@ -12,7 +12,6 @@ QueryCheckResult, ) from redisvl.migration.utils import build_scan_match_patterns, load_yaml, schemas_equal -from redisvl.types import SyncRedisClient class MigrationValidator: @@ -89,7 +88,7 @@ def validate( # Check keys one at a time to avoid Redis Cluster cross-slot # errors from multi-key EXISTS commands. existing_count = sum( - target_index.client.exists(key) for key in keys_to_check + target_index._redis_client.exists(key) for key in keys_to_check ) validation.key_sample_exists = existing_count == len(keys_to_check) @@ -128,10 +127,7 @@ def validate( def _count_index_keys(self, index: SearchIndex) -> int: """Count keys matching the target index prefixes with SCAN.""" - raw_client = index.client - if raw_client is None: - raise ValueError("Redis client is required to count index keys") - client = cast(SyncRedisClient, raw_client) + client = index._redis_client prefixes = index.schema.index.prefix prefix_list = prefixes if isinstance(prefixes, list) else [prefixes] @@ -173,10 +169,8 @@ def _run_query_checks( ) ) + client = target_index._redis_client for key in query_checks.get("keys_exist", []): - client = target_index.client - if client is None: - raise ValueError("Redis client not connected") exists = bool(client.exists(key)) results.append( QueryCheckResult( diff --git a/tests/unit/test_async_migration_planner.py b/tests/unit/test_async_migration_planner.py index 93ce3d49d..c4483d974 100644 --- a/tests/unit/test_async_migration_planner.py +++ b/tests/unit/test_async_migration_planner.py @@ -35,8 +35,8 @@ def __init__(self, schema, stats, keys): self._stats = stats self._client = AsyncDummyClient(keys) - @property - def client(self): + async def _get_client(self): + """Mirrors AsyncSearchIndex._get_client, the lazy async getter.""" return self._client async def info(self): diff --git a/tests/unit/test_migration_planner.py b/tests/unit/test_migration_planner.py index b07f9df93..d8f5df0cc 100644 --- a/tests/unit/test_migration_planner.py +++ b/tests/unit/test_migration_planner.py @@ -36,7 +36,8 @@ def __init__(self, schema, stats, keys): self._client = DummyClient(keys) @property - def client(self): + def _redis_client(self): + """Mirrors SearchIndex._redis_client, the lazily creating property.""" return self._client def info(self): @@ -1207,7 +1208,7 @@ def test_exists_called_per_key(self, monkeypatch): mock_client.exists.return_value = 1 # Each key exists mock_index = MagicMock() - mock_index.client = mock_client + mock_index._redis_client = mock_client mock_index.info.return_value = {"num_docs": 3, "hash_indexing_failures": 0} mock_index.schema.to_dict.return_value = plan.merged_target_schema mock_index.search.return_value = MagicMock(total=3) @@ -1242,7 +1243,7 @@ def test_multi_prefix_keys_translated(self, monkeypatch): mock_client.exists.return_value = 1 mock_index = MagicMock() - mock_index.client = mock_client + mock_index._redis_client = mock_client mock_index.info.return_value = {"num_docs": 3, "hash_indexing_failures": 0} mock_index.schema.to_dict.return_value = plan.merged_target_schema mock_index.search.return_value = MagicMock(total=3) @@ -1287,7 +1288,7 @@ def test_expected_source_count_uses_scanned_target_keys(self, monkeypatch): } mock_index = MagicMock() - mock_index.client = DummyClient( + mock_index._redis_client = DummyClient( [b"target:1", b"target:2", b"target:3", b"target:4", b"target:5"] ) mock_index.info.return_value = {"num_docs": 5, "hash_indexing_failures": 0} From 59348340310c9be9be49a09e2490280e1ce460f1 Mon Sep 17 00:00:00 2001 From: Vishal Bala Date: Thu, 3 Sep 2026 09:49:48 +0200 Subject: [PATCH 4/8] docs(utils): stop promising removal in the next major release The deprecation decorators told users each deprecated argument, function and class would be removed "in the next major release". Every release so far has been 0.x and breaking changes ship on minor bumps per project convention, so that promise has never been accurate and each removal would otherwise need a release note explaining the mismatch. Say "in a future release" instead. Warning text only; no behaviour change. --- redisvl/utils/utils.py | 8 +++++--- tests/unit/test_url_deprecation.py | 2 +- tests/unit/test_utils.py | 16 +++++++--------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/redisvl/utils/utils.py b/redisvl/utils/utils.py index 85f74397c..bd53af0ae 100644 --- a/redisvl/utils/utils.py +++ b/redisvl/utils/utils.py @@ -93,7 +93,9 @@ class MyClass: def test_method(cls, old_arg=None, new_arg=None): pass """ - message = f"Argument {argument} is deprecated and will be removed in the next major release." + message = ( + f"Argument {argument} is deprecated and will be removed in a future release." + ) if replacement: message += f" Use {replacement} instead." @@ -157,7 +159,7 @@ def decorator(func): fn_name = name or func.__name__ warning_message = ( f"Function {fn_name} is deprecated and will be " - "removed in the next major release. " + "removed in a future release. " ) if replacement: warning_message += replacement @@ -194,7 +196,7 @@ def decorator(cls): class_name = name or cls.__name__ warning_message = ( f"Class {class_name} is deprecated and will be " - "removed in the next major release. " + "removed in a future release. " ) if replacement: warning_message += replacement diff --git a/tests/unit/test_url_deprecation.py b/tests/unit/test_url_deprecation.py index d70ff4cb8..7b01cefa5 100644 --- a/tests/unit/test_url_deprecation.py +++ b/tests/unit/test_url_deprecation.py @@ -26,7 +26,7 @@ async def test__get_aredis_connection_deprecates_url_kwarg_only(): assert any( str(w.message) == ( - "Argument url is deprecated and will be removed in the next major release. " + "Argument url is deprecated and will be removed in a future release. " "Use redis_url instead." ) for w in record diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index d7c1e2f0e..ef0a17670 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -215,7 +215,7 @@ def test_func(old_arg=None, new_arg=None, neutral_arg=None): assert len(record) == 1 assert str(record[0].message) == ( - "Argument old_arg is deprecated and will be removed in the next major release. Use new_arg instead." + "Argument old_arg is deprecated and will be removed in a future release. Use new_arg instead." ) # Test that passing the deprecated argument as a positional argument also triggers the warning. @@ -224,7 +224,7 @@ def test_func(old_arg=None, new_arg=None, neutral_arg=None): assert len(record) == 1 assert str(record[0].message) == ( - "Argument old_arg is deprecated and will be removed in the next major release. Use new_arg instead." + "Argument old_arg is deprecated and will be removed in a future release. Use new_arg instead." ) with assert_no_warnings(): @@ -242,8 +242,7 @@ def test_func(old_arg=None, neutral_arg=None): assert len(record) == 1 assert str(record[0].message) == ( - "Argument old_arg is deprecated and will be removed" - " in the next major release." + "Argument old_arg is deprecated and will be removed" " in a future release." ) # As a positional arg @@ -252,8 +251,7 @@ def test_func(old_arg=None, neutral_arg=None): assert len(record) == 1 assert str(record[0].message) == ( - "Argument old_arg is deprecated and will be removed" - " in the next major release." + "Argument old_arg is deprecated and will be removed" " in a future release." ) with assert_no_warnings(): @@ -547,7 +545,7 @@ def __init__(self, value): assert len(record) == 1 assert str(record[0].message) == ( - "Class OldClass is deprecated and will be removed in the next major release. " + "Class OldClass is deprecated and will be removed in a future release. " "Use NewClass instead." ) assert obj.value == 42 @@ -563,7 +561,7 @@ def __init__(self, value): assert len(record) == 1 assert str(record[0].message) == ( - "Class OldClass is deprecated and will be removed in the next major release. " + "Class OldClass is deprecated and will be removed in a future release. " ) assert obj.value == 42 @@ -577,7 +575,7 @@ class OldClass: assert len(record) == 1 assert str(record[0].message) == ( - "Class CustomOldClass is deprecated and will be removed in the next major release. " + "Class CustomOldClass is deprecated and will be removed in a future release. " "Use NewClass instead." ) From 4558ffea5fd617f58ea10a5367ed471a2629e349 Mon Sep 17 00:00:00 2001 From: Vishal Bala Date: Thu, 3 Sep 2026 10:31:26 +0200 Subject: [PATCH 5/8] fix(cache): serialise lazy creation of the async cache client Moving this method onto _get_aredis_connection introduced an await between its "is the client None" check and the assignment, because the async factory issues a CLIENT SETINFO round trip. BaseCache has no lock, so two concurrent callers each built a client and the first was left unreachable: adisconnect only closes the client currently on the instance, so the orphan's connection pool was never released. Wrap the lazy path in a double-checked lock, the same shape AsyncSearchIndex._get_client already uses over the same factory. The regression test fails without the lock and passes with it. --- redisvl/extensions/cache/base.py | 43 +++++++++++++-------- tests/unit/test_connection_normalization.py | 30 ++++++++++++++ 2 files changed, 57 insertions(+), 16 deletions(-) diff --git a/redisvl/extensions/cache/base.py b/redisvl/extensions/cache/base.py index 6af540aa3..212faec2b 100644 --- a/redisvl/extensions/cache/base.py +++ b/redisvl/extensions/cache/base.py @@ -4,6 +4,7 @@ specific cache types such as LLM caches and embedding caches. """ +import asyncio from collections.abc import Mapping from typing import Any, cast @@ -59,7 +60,13 @@ def __init__( # Initialize Redis clients self._async_redis_client = async_redis_client self._redis_client = redis_client + # Guards lazy async client creation, which suspends on an await and so + # cannot rely on a bare check-then-set. Mirrors AsyncSearchIndex._lock. + self._async_client_lock = asyncio.Lock() + # Caches never close a caller-supplied client and register no GC + # finalizer, so the index's owns_client handover has no cache + # equivalent by design. if redis_client or async_redis_client: self._owns_redis_client = False else: @@ -128,22 +135,26 @@ async def _get_async_redis_client(self) -> AsyncRedisClient: Returns: AsyncRedisClient: An async Redis client instance. """ - if not hasattr(self, "_async_redis_client") or self._async_redis_client is None: - client = self.redis_kwargs.get("redis_client") - - if client and isinstance(client, (Redis, RedisCluster)): - self._async_redis_client = RedisConnectionFactory.sync_to_async_redis( - client - ) - else: - url = cast(str | None, self.redis_kwargs["redis_url"]) - kwargs = cast(dict[str, Any], self.redis_kwargs["connection_kwargs"]) - self._async_redis_client = ( - await RedisConnectionFactory._get_aredis_connection( - redis_url=url, **kwargs - ) - ) - return self._async_redis_client + client = getattr(self, "_async_redis_client", None) + if client is None: + async with self._async_client_lock: + # Double-check: another task may have created the client while + # this one waited on the lock or on the factory's round trip. + client = getattr(self, "_async_redis_client", None) + if client is None: + provided = self.redis_kwargs.get("redis_client") + if provided and isinstance(provided, (Redis, RedisCluster)): + client = RedisConnectionFactory.sync_to_async_redis(provided) + else: + url = cast(str | None, self.redis_kwargs["redis_url"]) + kwargs = cast( + dict[str, Any], self.redis_kwargs["connection_kwargs"] + ) + client = await RedisConnectionFactory._get_aredis_connection( + redis_url=url, **kwargs + ) + self._async_redis_client = client + return client def expire(self, key: str, ttl: int | None = None) -> None: """Set or refresh the expiration time for a key in the cache. diff --git a/tests/unit/test_connection_normalization.py b/tests/unit/test_connection_normalization.py index f5915ddd9..28560f79a 100644 --- a/tests/unit/test_connection_normalization.py +++ b/tests/unit/test_connection_normalization.py @@ -1,3 +1,4 @@ +import asyncio from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch @@ -359,6 +360,35 @@ async def test_base_cache_async_client_creation_emits_no_warning(): assert client is mock_client +@pytest.mark.asyncio +async def test_base_cache_async_client_creation_is_serialised(): + """Concurrent callers must share one client, not orphan a connection pool. + + Building the client awaits a CLIENT SETINFO round trip, so a bare + check-then-set would let two tasks each create one and leave the first + unreachable and never closed. + """ + cache = EmbeddingsCache(redis_url="redis://localhost:6379") + created = [] + + async def factory(*args, **kwargs): + await asyncio.sleep(0) # the suspension the real factory introduces + client = MagicMock(name=f"client{len(created)}") + created.append(client) + return client + + with patch( + "redisvl.extensions.cache.base.RedisConnectionFactory._get_aredis_connection", + new=factory, + ): + first, second = await asyncio.gather( + cache._get_async_redis_client(), cache._get_async_redis_client() + ) + + assert len(created) == 1 + assert first is second + + def test_sql_query_uses_connection_factory_for_redis_url(): """Build SQL query helper connections through the shared connection factory.""" fake_sql_redis_module = _fake_sql_redis_module() From c2d54c90c36a75f98164e419c41446e2e354eb75 Mon Sep 17 00:00:00 2001 From: Vishal Bala Date: Thu, 3 Sep 2026 10:31:46 +0200 Subject: [PATCH 6/8] fix(router): let an explicit owns_client survive from_existing SemanticRouter.from_existing merges {**init_kwargs, **index_kwargs} with index_kwargs second, and set owns_client there unconditionally on the branch where it creates the client. A caller's owns_client=False was therefore discarded in silence, while SearchIndex.from_existing honoured the same argument via setdefault. Same keyword, same verb, opposite answer. Claim ownership only when the caller has not already answered, so all three public from_existing entry points agree. --- redisvl/extensions/router/semantic.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/redisvl/extensions/router/semantic.py b/redisvl/extensions/router/semantic.py index 4d9111e19..f3490d7b4 100644 --- a/redisvl/extensions/router/semantic.py +++ b/redisvl/extensions/router/semantic.py @@ -192,7 +192,11 @@ def from_existing( **factory_kwargs, ) index_kwargs["_client_validated"] = True - index_kwargs["owns_client"] = True + # index_kwargs wins the merge below, so only claim ownership when + # the caller has not already answered. Matches the setdefault in + # SearchIndex.from_existing, where an explicit value also wins. + if "owns_client" not in init_kwargs: + index_kwargs["owns_client"] = True if lib_name is not None: index_kwargs["lib_name"] = lib_name created_redis_client = True From e42acf5671db1de4384898eb58a9e2eb4183c55b Mon Sep 17 00:00:00 2001 From: Vishal Bala Date: Thu, 3 Sep 2026 10:31:48 +0200 Subject: [PATCH 7/8] docs(index): document ownership and correct what the code claims Review of the owns_client work turned up several statements that were wrong or missing rather than merely terse. The owns_client entry said the index owns a client it created "from redis_url", but get_redis_connection falls back to REDIS_URL, so an index built with no connection arguments at all still creates and owns one. It also left the caller's obligation unstated: declining ownership of a client the index created means closing it yourself. disconnect was documented as "Disconnect from the Redis database" on the base and sync classes and not at all on the async one, which now misleads: it is a no-op for an unowned client, and with owns_client public that is a state callers choose. Its log line claimed the index did not own the client even when the index had created it. A test docstring asserted set_client() was already gone. It is not, until the next branch removes it, and pointing readers away from it hides the ownership footgun owns_client exists to fix. Also: coerce owns_client with bool(), since the finalizer gate tests truthiness while disconnect tested "is False", so a falsy non-bool made the two paths disagree; reject the retired private _owns_redis_client keyword loudly, because underscore-prefixed keywords are forwarded verbatim and it would otherwise be dropped in silence; hoist a lazily created client out of a per-key loop; drop the last dead client-is-None guard in the migration package; and stop one more warning promising removal in the next major release. --- redisvl/index/index.py | 99 ++++++++++++++++++--------- redisvl/mcp/server.py | 4 +- redisvl/migration/async_executor.py | 2 - redisvl/migration/validation.py | 5 +- redisvl/redis/connection.py | 2 +- tests/unit/test_index_gc_finalizer.py | 7 +- tests/unit/test_utils.py | 4 +- 7 files changed, 77 insertions(+), 46 deletions(-) diff --git a/redisvl/index/index.py b/redisvl/index/index.py index 4e03f489f..8eddcfffc 100644 --- a/redisvl/index/index.py +++ b/redisvl/index/index.py @@ -748,7 +748,12 @@ def from_dict(cls, schema_dict: dict[str, Any], **kwargs): return cls(schema=schema, **kwargs) def disconnect(self): - """Disconnect from the Redis database.""" + """Close the Redis client if this index owns it. + + Always invalidates the cached SQL schema. When the index does not own + the client (see ``owns_client``), the client is left open and the + index remains usable. + """ raise NotImplementedError("This method should be implemented by subclasses.") def key(self, id: str) -> str: @@ -832,12 +837,12 @@ def __init__( args. validate_on_load (bool, optional): Whether to validate data against schema when loading. Defaults to False. - owns_client (Optional[bool]): Whether the index should close the - Redis client when it is disconnected or garbage collected. By - default the index owns only a client it created itself from - `redis_url`, and never closes a client passed as - `redis_client`. Pass True to hand over a client you created, - or False to keep one the index would otherwise own. + owns_client (Optional[bool], optional): Whether the index closes + the Redis client when the index is disconnected or garbage + collected. Defaults to None, meaning the index owns a client + only if it created one itself. Pass True to hand over a client + you created, or False to keep one the index would otherwise + close, in which case closing it becomes your responsibility. """ if "connection_args" in kwargs: connection_kwargs = kwargs.pop("connection_args") @@ -857,12 +862,17 @@ def __init__( self._sql_executors: dict[str, Any] = {} self._validated_client = kwargs.pop("_client_validated", False) - # An index owns the client it created itself, so a caller-supplied - # client is not closed unless the caller hands ownership over with - # owns_client=True. Assigned before the finalizer is registered, - # because _register_client_finalizer reads this flag. + if "_owns_redis_client" in kwargs: + # Underscore-prefixed kwargs are forwarded verbatim by + # _split_from_existing_kwargs, so this would otherwise be dropped + # in silence and leak the connection it used to control. + raise TypeError( + "_owns_redis_client is no longer accepted; use owns_client instead" + ) + # Must be assigned before _register_client_finalizer, which gates on + # this flag. self._owns_redis_client = ( - redis_client is None if owns_client is None else owns_client + redis_client is None if owns_client is None else bool(owns_client) ) self._client_finalizer = None # Close the owned client when this index is garbage collected. When @@ -873,10 +883,15 @@ def __init__( _finalizer_close_client = staticmethod(_close_owned_sync_client) def disconnect(self): - """Disconnect from the Redis database.""" + """Close the Redis client if this index owns it. + + Always invalidates the cached SQL schema. When the index does not own + the client (see ``owns_client``), the client is left open and the + index remains usable. + """ self.invalidate_sql_schema_cache() - if self._owns_redis_client is False: - logger.info("Index does not own client, not disconnecting") + if not self._owns_redis_client: + logger.info("Index does not own its client; leaving it open") return self._detach_client_finalizer() if self.__redis_client: @@ -900,6 +915,9 @@ def from_existing( instantiated redis client. redis_url (Optional[str]): The URL of the Redis server to connect to. + owns_client (Optional[bool], optional): Whether the index closes + the client. Defaults to True when this method created the + client from `redis_url`, and False when you supplied one. Raises: ValueError: If redis_url or redis_client is not provided. @@ -935,8 +953,6 @@ def from_existing( schema_dict = convert_index_info_to_schema(index_info) schema = IndexSchema.from_dict(schema_dict) if created_redis_client: - # The index created this client, so it owns it unless the caller - # explicitly said otherwise. init_kwargs.setdefault("owns_client", True) return cls( schema, @@ -2160,12 +2176,12 @@ def __init__( args. validate_on_load (bool, optional): Whether to validate data against schema when loading. Defaults to False. - owns_client (Optional[bool]): Whether the index should close the - Redis client when it is disconnected or garbage collected. By - default the index owns only a client it created itself from - `redis_url`, and never closes a client passed as - `redis_client`. Pass True to hand over a client you created, - or False to keep one the index would otherwise own. + owns_client (Optional[bool], optional): Whether the index closes + the Redis client when the index is disconnected or garbage + collected. Defaults to None, meaning the index owns a client + only if it created one itself. Pass True to hand over a client + you created, or False to keep one the index would otherwise + close, in which case closing it becomes your responsibility. """ if "redis_kwargs" in kwargs: connection_kwargs = kwargs.pop("redis_kwargs") @@ -2181,8 +2197,8 @@ def __init__( # Store connection parameters. Note the asymmetry with SearchIndex: # there, _redis_client is a property that lazily creates the client, # whereas here it is a plain attribute that stays None until - # _get_client() creates one. Read it through _get_client(), never - # directly, unless you specifically want the un-created state. + # _get_client() creates one. Read it through _get_client(), not + # directly. self._redis_client = redis_client self._redis_url = redis_url self._connection_kwargs = connection_kwargs or {} @@ -2190,10 +2206,17 @@ def __init__( self._sql_executors: dict[str, Any] = {} self._validated_client = kwargs.pop("_client_validated", False) - # See the note on SearchIndex.__init__: ownership follows from who - # created the client, and owns_client=True hands it to the index. + if "_owns_redis_client" in kwargs: + # Underscore-prefixed kwargs are forwarded verbatim by + # _split_from_existing_kwargs, so this would otherwise be dropped + # in silence and leak the connection it used to control. + raise TypeError( + "_owns_redis_client is no longer accepted; use owns_client instead" + ) + # Must be assigned before _register_client_finalizer, which gates on + # this flag. self._owns_redis_client = ( - redis_client is None if owns_client is None else owns_client + redis_client is None if owns_client is None else bool(owns_client) ) self._client_finalizer = None # Close the owned client when this index is garbage collected. When @@ -2220,6 +2243,9 @@ async def from_existing( instantiated redis client. redis_url (Optional[str]): The URL of the Redis server to connect to. + owns_client (Optional[bool], optional): Whether the index closes + the client. Defaults to True when this method created the + client from `redis_url`, and False when you supplied one. """ if not redis_url and not redis_client: raise ValueError( @@ -2260,8 +2286,6 @@ async def from_existing( schema_dict = convert_index_info_to_schema(index_info) schema = IndexSchema.from_dict(schema_dict) if created_redis_client: - # The index created this client, so it owns it unless the caller - # explicitly said otherwise. init_kwargs.setdefault("owns_client", True) return cls( schema, @@ -3347,8 +3371,14 @@ async def info(self, name: str | None = None) -> dict[str, Any]: return await self._info(index_name, client) async def disconnect(self): + """Close the Redis client if this index owns it. + + Always invalidates the cached SQL schema. When the index does not own + the client (see ``owns_client``), the client is left open and the + index remains usable. + """ self.invalidate_sql_schema_cache() - if self._owns_redis_client is False: + if not self._owns_redis_client: return self._detach_client_finalizer() if self._redis_client is not None: @@ -3356,7 +3386,12 @@ async def disconnect(self): self._redis_client = None def disconnect_sync(self): - if self._redis_client is None or self._owns_redis_client is False: + """Close an owned Redis client from synchronous code. + + For callers outside an event loop, such as ``__del__`` or a shutdown + hook. Honours ``owns_client`` exactly as :meth:`disconnect` does. + """ + if self._redis_client is None or not self._owns_redis_client: return sync_wrapper(self.disconnect)() diff --git a/redisvl/mcp/server.py b/redisvl/mcp/server.py index 53b894661..14097b1f0 100644 --- a/redisvl/mcp/server.py +++ b/redisvl/mcp/server.py @@ -644,9 +644,7 @@ async def _load_effective_schema( def _make_index(schema: IndexSchema, client: Any) -> AsyncSearchIndex: """Bind an inspected schema and Redis client into an async index.""" # The server acquired this client explicitly during startup, so hand - # ownership to the index for a single shutdown path. Passing - # owns_client at construction also registers the GC finalizer, which a - # post-construction flag flip would be too late for. + # ownership to the index for a single shutdown path. return AsyncSearchIndex(schema=schema, redis_client=client, owns_client=True) async def _initialize_vectorizer( diff --git a/redisvl/migration/async_executor.py b/redisvl/migration/async_executor.py index 149ae0e9d..87310c61f 100644 --- a/redisvl/migration/async_executor.py +++ b/redisvl/migration/async_executor.py @@ -867,8 +867,6 @@ def _notify(step: str, detail: Optional[str] = None) -> None: try: client = await source_index._get_client() - if client is None: - raise ValueError("Failed to get Redis client from source index") aof_enabled = await self._detect_aof_enabled(client) disk_estimate = estimate_disk_space(plan, aof_enabled=aof_enabled) if disk_estimate.has_quantization: diff --git a/redisvl/migration/validation.py b/redisvl/migration/validation.py index 6735d6479..fcf14ed8f 100644 --- a/redisvl/migration/validation.py +++ b/redisvl/migration/validation.py @@ -87,9 +87,8 @@ def validate( keys_to_check.append(translated) # Check keys one at a time to avoid Redis Cluster cross-slot # errors from multi-key EXISTS commands. - existing_count = sum( - target_index._redis_client.exists(key) for key in keys_to_check - ) + client = target_index._redis_client + existing_count = sum(client.exists(key) for key in keys_to_check) validation.key_sample_exists = existing_count == len(keys_to_check) # Run automatic functional checks (always). diff --git a/redisvl/redis/connection.py b/redisvl/redis/connection.py index eca8a2f78..d4e44ea8d 100644 --- a/redisvl/redis/connection.py +++ b/redisvl/redis/connection.py @@ -726,7 +726,7 @@ def get_async_redis_connection( variable is not set. """ warn( - "get_async_redis_connection will become async in the next major release.", + "get_async_redis_connection will become async in a future release.", DeprecationWarning, ) _deprecated_url = kwargs.pop("url", None) diff --git a/tests/unit/test_index_gc_finalizer.py b/tests/unit/test_index_gc_finalizer.py index 37bb2ee3c..edc0aed05 100644 --- a/tests/unit/test_index_gc_finalizer.py +++ b/tests/unit/test_index_gc_finalizer.py @@ -229,9 +229,10 @@ class TestOwnsClientHandover: """``owns_client`` overrides who closes the client. By default an index closes only a client it created itself. These tests - cover the two explicit overrides, which are the only way to hand a - pre-built client over (or to keep one the index would otherwise own) now - that ``set_client()`` is gone. + cover the two explicit overrides at construction, which is where + ownership should be stated: the deprecated ``set_client()`` inherits + whatever ownership the index already had, so it can hand the index a + caller's client and then close it. """ def test_sync_injected_client_closed_when_ownership_handed_over(self): diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index ef0a17670..e4e2893fd 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -242,7 +242,7 @@ def test_func(old_arg=None, neutral_arg=None): assert len(record) == 1 assert str(record[0].message) == ( - "Argument old_arg is deprecated and will be removed" " in a future release." + "Argument old_arg is deprecated and will be removed in a future release." ) # As a positional arg @@ -251,7 +251,7 @@ def test_func(old_arg=None, neutral_arg=None): assert len(record) == 1 assert str(record[0].message) == ( - "Argument old_arg is deprecated and will be removed" " in a future release." + "Argument old_arg is deprecated and will be removed in a future release." ) with assert_no_warnings(): From 29c37b1cc4228183a09da3c3ad582954ca20e6ce Mon Sep 17 00:00:00 2001 From: Vishal Bala Date: Thu, 10 Sep 2026 13:09:11 +0200 Subject: [PATCH 8/8] refactor(migration): drop a cast import left dead by the merge Main replaced the hand-rolled SCAN loop in the validator with scan_iter, which removed the only cast() in the file. `make lint` runs format and mypy but not pylint, so nothing in the standard checks would flag it. --- redisvl/migration/validation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redisvl/migration/validation.py b/redisvl/migration/validation.py index c33253817..83a7598c8 100644 --- a/redisvl/migration/validation.py +++ b/redisvl/migration/validation.py @@ -1,7 +1,7 @@ from __future__ import annotations import time -from typing import Any, Dict, List, Optional, cast +from typing import Any, Dict, List, Optional from redis.commands.search.query import Query