fix: set_client() client ownership, plus paginate and typing fixes for #653 - #661
fix: set_client() client ownership, plus paginate and typing fixes for #653#661nkanu17 wants to merge 8 commits into
Conversation
There was a problem hiding this comment.
🟡 Not ready to approve
AsyncSearchIndex._swap_client() updates _redis_client under the async lock but flips _owns_redis_client outside it, which can expose an inconsistent state to concurrent coroutines (risking an incorrect close of a caller-provided client).
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Pull request overview
This PR fixes a client-lifecycle bug in the deprecated set_client() APIs for SearchIndex / AsyncSearchIndex, aligning their ownership semantics with constructor-injected clients so caller-provided Redis clients are no longer closed by disconnect() or GC finalizers. It also ensures previously index-owned clients are released before swapping in a caller’s client, and adds targeted unit/integration coverage for these ownership cases.
Changes:
- Fix sync
SearchIndex.set_client()to release any previously-owned client and mark the new caller-provided client as unowned (no finalizer / no close). - Refactor async client swapping through
_swap_client(client, owns=...)soconnect()keeps ownership whileset_client()does not. - Add new unit + integration tests for ownership semantics and update an existing async integration test expectation to match corrected behavior.
File summaries
| File | Description |
|---|---|
redisvl/index/index.py |
Fixes deprecated client swap ownership semantics; introduces _swap_client(..., owns=...) for async and corrects sync set_client() to avoid closing caller clients. |
tests/unit/test_index_client_ownership.py |
New unit tests covering ownership flips, GC behavior, and releasing previously owned clients (sync + async). |
tests/integration/test_index_client_ownership_integration.py |
New live-Redis integration tests asserting caller clients remain usable after GC/disconnect while connect()-created clients are closed. |
tests/integration/test_async_search_index.py |
Updates set_client() test to assert corrected “unowned client remains open and present after disconnect” behavior. |
Review details
- Files reviewed: 4/4 changed files
- Comments generated: 1
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
| async with self._lock: | ||
| self._redis_client = redis_client | ||
| self._register_client_finalizer(redis_client) | ||
| self._redis_client = validated_client | ||
| self._owns_redis_client = owns |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 3ed6684. Configure here.
…ry race) Redis 8.8 defaults the RediSearch worker pool to a nonzero background executor, exposing a race where a document expiring (TTL/HPEXPIRE) mid FT.SEARCH is returned as a matched id with a nil field array. redis-py collapses this to a Document with only id/payload, so RedisVL previously raised KeyError in the vector-normalize branch or leaked a partial record into CacheHit/ChatMessage/RouteMatch. process_results now skips such documents, detected only on zero-false positive signals (vector/range query missing vector_distance; JSON full-object unpack missing json), leaving legitimate id-only and INDEXMISSING results untouched. process_aggregate_results and the hybrid/SQL paths drop entirely-empty rows, and the semantic cache, message history, and router guard the paths the core rule does not cover. query() now returns a SearchResults list subclass exposing dropped_count and complete so callers can detect a race-shortened result set; it behaves exactly like a list otherwise. Skips are logged at WARNING level. Adds deterministic unit tests and a docs section. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
test_simple compared two independent FT.SEARCH result sets positionally. john and mary share the query vector (vector_distance == 0.0), so their relative order can differ between the two calls, making the test flaky (observed on Python 3.12 / redis-py 6.x / redis:8.4). Compare the result sets keyed by the unique `user` field instead. Applied to the sync and async twins. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The CI mitigation pinned --search-workers 0 via the REDIS_ARGS env var, but the official redis image does not honor REDIS_ARGS (it is a Redis Stack entrypoint convention). Verified empirically: with REDIS_ARGS set, redis:8.4 reports search-workers 0 (its default anyway) while redis:8.8.0 reports 12 -- the env var is ignored. So the pin was a silent no-op and the background-worker race stayed live on redis:latest, which is why the mitigation never resolved the flaky redis:latest runs (e.g. test_simple, test_filter_combinations). Pass the flags on the server command line instead, which the official image does honor. Confirmed redis:8.8.0 then reports search-workers 0 and the integration suite passes against redis:latest. REDIS_SEARCH_WORKERS still overrides (and now actually takes effect) for reproducing the race locally. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
process_aggregate_results popped __score and then dropped any row that became
an empty dict, so a legitimate scoring-only aggregation row (only __score) was
silently dropped and miscounted as a race-related missing-payload row. Judge
emptiness before stripping __score: drop only rows that came back with no
fields at all; keep a score-only row as {} (matching prior behavior).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An index created with redis_url owns the client it lazily creates, so _owns_redis_client stays True. The deprecated set_client() never reset that flag, so once a caller swapped in their own client the index still believed it owned it. Before 0.25.0 this was latent because the client finalizer never fired; now that it does, the index closes the caller's client when it is garbage collected, and disconnect() closes it too. __init__(redis_client=...) already treats such a client as not owned. set_client() now marks the client as not owned and releases the client the index had created for itself first. The sync path previously abandoned that client without closing it at all, since the overwrite also detached its finalizer. The deprecated async connect() needs the opposite treatment: it creates its own client and then delegated to set_client(), so a plain ownership flip would have left a client the index created with nobody to close it. Both now route through an internal _swap_client() helper that takes ownership as a parameter, so connect() keeps ownership and set_client() does not. One existing test asserted that .client is None after disconnect() following set_client(). That outcome was only reachable through the bug: for a client the index does not own, disconnect() has always left the client in place rather than closing or clearing it, which is also what happens for constructor-injected clients. The test now asserts the corrected semantics, including that the caller's client still answers PING afterwards. Fixes #660
Matched documents whose field payload came back missing are dropped from results, so a page can now arrive empty even though the server reported matches for it. Both paginate() implementations treated any empty page as the end of the result set, which silently discarded every remaining page: exactly the kind of quiet incompleteness the dropping is meant to avoid. Pagination now ends only when a page is empty AND nothing was dropped. A page emptied entirely by drops is skipped rather than yielded, since callers expect yielded pages to be non-empty, and the drop is already logged and counted upstream. The offset still advances in that case, so a run of fully-dropped pages terminates at the first page with no matches instead of looping. Also annotates the query paths as SearchResults instead of list[dict[str, Any]]. Without this the completeness API is invisible to type checkers, and the documented usage fails: mypy reported 'list[dict[str, Any]] has no attribute complete' and the same for dropped_count. SearchResults subclasses list, so this is a typing and discoverability change only, with no runtime effect.
results.complete reports that nothing was dropped client-side. It is not a guarantee that the result set reflects recent writes: on Redis 8.8+ the same worker-pool change means a freshly written document may not be indexed yet, so the server never matches it, nothing is dropped, and a short result set comes back with complete still True. Measured on Redis 8.10.0 with 8 concurrent writers querying immediately after loading, the index caught up within roughly 1 to 3 ms. Also documents that paginate() no longer stops at a page emptied by drops.
3ed6684 to
5fab869
Compare
There was a problem hiding this comment.
🟢 Ready to approve
The changes align with the stated ownership/pagination semantics and are backed by substantial new unit and integration test coverage.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Review details
Suppressed comments (1)
redisvl/index/index.py:975
set_client()unconditionally callsdisconnect(). When the index does not own its current client (e.g., constructed withredis_client=),disconnect()logs an INFO message ("Index does not own client, not disconnecting"), which becomes noisy/misleading during a client swap. Consider only disconnecting when the index currently owns its client so swaps of caller-owned clients stay silent.
# Release the client this index created for itself, if any, before
# taking on the caller's client.
self.disconnect()
self.__redis_client = redis_client
- Files reviewed: 6/6 changed files
- Comments generated: 0 new
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
… atomically Addresses two bot review findings on this PR. Bugbot: the sync connect() never set _owns_redis_client, unlike the async path which now routes through _swap_client(owns=True). Since set_client() now clears ownership, calling sync connect() afterwards left ownership False for a client the index had just created: no finalizer was registered and disconnect() returned early, so nothing ever closed it. The same applied to an index constructed with redis_client=... and later reconnected. connect() now takes ownership back, which is the whole point of routing the async path through owns=True. Copilot: the async _swap_client() assigned _redis_client under the lock but flipped _owns_redis_client just outside it, leaving a window where another coroutine could observe the new client while ownership still described the old one, and close a caller-provided client on that basis. Both fields, and the finalizer registration, now happen together inside the lock. The sync paths do the same under their threading lock. Also stops set_client() from calling disconnect() when the current client is not owned: it would do nothing there beyond logging that it is not disconnecting, which is noise during a swap. The SQL schema cache is still invalidated explicitly, so nothing is skipped. Adds three regression tests covering connect() taking ownership from an unowned state, for both classes.
There was a problem hiding this comment.
🟡 Not ready to approve
There are remaining correctness/contract issues in redisvl/index/index.py (client leak on repeated connect() and CountQuery return-type mismatches) that should be resolved before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Review details
Suppressed comments (5)
redisvl/index/index.py:1888
- SearchIndex._query() and SearchIndex.query() return an int for CountQuery (see CountQuery docs and process_results()), but are annotated as returning SearchResults and the docstring says "List[Result]". This is an API contract/typing mismatch for users and type checkers; update the return type and docstring to include the CountQuery case.
def query(
self, query: BaseQuery | AggregationQuery | HybridQuery | SQLQuery
) -> SearchResults:
"""Execute a query on the index.
redisvl/index/index.py:1863
- batch_query() can accept CountQuery because it takes Sequence[BaseQuery], and process_results() will return an int for those entries. The return type should reflect that it may contain either SearchResults or int values.
def batch_query(
self, queries: Sequence[BaseQuery], batch_size: int = 10
) -> list[SearchResults]:
"""Execute a batch of queries and process results."""
redisvl/index/index.py:960
- connect() replaces the current client without closing a previously owned client. Because _register_client_finalizer() detaches the prior finalizer, the old owned client can be leaked (open sockets) if connect() is called when an owned client already exists (e.g., after lazy creation from redis_url or a previous connect()). Capture and close the old client after swapping to avoid leaking resources.
self.invalidate_sql_schema_cache()
client = RedisConnectionFactory.get_redis_connection(
redis_url=redis_url, **kwargs
)
with self._lock:
self.__redis_client = client
# This index created the client, so it owns and must close it. The
# index may have been holding a caller-provided client until now
# (constructor injection or set_client), in which case ownership
# has to be taken back or nothing would ever close this one.
self._owns_redis_client = True
self._register_client_finalizer(client)
redisvl/index/index.py:345
- process_results() returns an integer for CountQuery (results.total), but its return annotation is SearchResults. This makes the internal contract inconsistent and forces incorrect typing through _query()/query(). Update the annotation to a union that matches runtime behavior.
This issue also appears in the following locations of the same file:
- line 1860
- line 1885
def process_results(
results: "Result", query: BaseQuery, schema: IndexSchema
) -> SearchResults:
docs/concepts/queries.md:396
- Spelling inconsistency in this doc: earlier sections use "behavior", but this note uses British spelling "behaviour". For consistency within the document, use "behavior" here as well.
Measured on Redis 8.10.0 with 8 concurrent writers querying immediately after
loading, the index caught up within about 1 to 3 ms. If you need read-your-writes
behaviour for search, either start Redis with `--search-workers 0` or re-query
rather than relying on `complete`.
- Files reviewed: 6/6 changed files
- Comments generated: 0 new
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

Fixes #660
Three commits:
fix: do not close a caller-provided client passed to set_client()(the original subject of this PR, set_client() keeps client ownership, so the index closes a caller-provided client #660)fix: keep paginating past a page emptied by dropped documents(fixes a defect in fix: tolerate matched docs with missing field payload (Redis 8.8 expiry race) #653)docs: note that complete does not cover writes that are not yet indexed(fills a gap in fix: tolerate matched docs with missing field payload (Redis 8.8 expiry race) #653's docs)1. set_client() closed a client it did not own (#660)
__init__(redis_client=...)already treats a caller-provided client as not owned and never closes it. The deprecatedset_client()did not follow that rule: an index created withredis_urlowns the client it lazily creates, so_owns_redis_clientstaysTrue, andset_client()never reset it. The index went on believing it owned a client it had never created.Latent before 0.25.0 because the finalizer never fired (#657). Now that it works, the index closes the caller's client on garbage collection, and
disconnect()closes it too. Verified on 0.25.0 before this change, both classes:Severity is low: redis-py clients recover from
close()andaclose()by reconnecting, so the effect is unexpected connection churn rather than a broken client, andset_client()is deprecated. I confirmed the recovery directly,ping()returnsTrueafter both.The trap was the deprecated async
connect(), which creates its own client and then delegated toset_client(). A plain ownership flip would have left a client the index created with nobody to close it. Both now route through_swap_client(client, *, owns: bool), soconnect()keeps ownership andset_client()gives it up. Also fixed in passing: syncset_client()abandoned the index's own client without closing it.Ownership is now consistent across all entry points:
__init__(redis_client=...)__init__(redis_url=...)then lazy creationconnect()(deprecated)set_client()(deprecated)One existing test,
test_search_index_set_client, asserted.client is Noneafterdisconnect(). That was only reachable through the bug, sincedisconnect()has always left non-owned clients in place, as it does for constructor-injected clients. It now asserts the corrected semantics, including that the caller's client still answersPING. That last assertion matters here because the test passes a sync client, which_validate_clientconverts into an async wrapper sharing the caller's pool; closing that wrapper would tear down connections the caller still needs.2. paginate() silently truncated after a fully-dropped page (defect in #653)
Both
paginate()implementations ended on any empty page:With #653, an empty page is ambiguous: either the result set is exhausted, or every match on that page was dropped for a missing field payload. Treating the second case as the end discards every remaining page, which is the same class of silent incompleteness #653 exists to prevent.
Reproduced on #653 before this fix by stubbing
_querywith one fully-dropped page followed by two healthy ones:doc2anddoc3were never delivered. Pagination now ends only when a page is empty and nothing was dropped. A page emptied entirely by drops is skipped rather than yielded, since callers expect yielded pages to be non-empty and the drop is already logged and counted upstream. The offset still advances, so a run of fully-dropped pages terminates at the first page with no matches instead of looping.Needs every document on one page to be dropped, so it is not common, but
page_size=1makes it trivial and the short-TTL workloads #653 calls out are where it would show up.Also in this commit: the query paths are annotated
SearchResultsinstead oflist[dict[str, Any]]. Without that, the completeness API #653 adds is invisible to type checkers and its own documented usage fails. mypy on the exact snippet fromdocs/concepts/queries.md, before:and after,
Success: no issues found.SearchResultssubclasseslist, so this is purely a typing and discoverability change with no runtime effect.3.
completedoes not mean the result set is complete (docs gap in #653)results.completereports that nothing was dropped client-side. It is not a guarantee about recent writes. The same Redis 8.8+ worker-pool change also means a freshly written document may not be indexed yet: the server never matches it, nothing is dropped, and a short result set comes back withcompletestillTrue.Measured on
redis:latest(8.10.0), 8 concurrent threads each loading 3 documents and querying immediately, 480 iterations:search-workers 14(image default)search-workers 0In every shortfall all 3 hashes were already in the keyspace and the index caught up within 0.8 to 2.6 ms, so this is indexing lag, not expiry, and no TTLs are involved. The docs now say so, and point at
--search-workers 0or re-querying rather than relying oncomplete. Tracked separately in #662.Testing
Test-driven throughout; every test below was confirmed failing first.
tests/unit/test_index_client_ownership.py, 10 tests. 8 failed before the ownership fix; the 2 that passed were theconnect()guards that had to keep passing.tests/integration/test_index_client_ownership_integration.py, 7 tests against real Redis: a caller's client still answersPINGafter collection and afterdisconnect()for both classes; the index's own client has its pool sockets torn down when replaced; a client created byconnect()is closed exactly once on collection; plus the constructor-injection baseline.tests/unit/test_paginate_dropped_pages.py, 5 tests. 3 failed before the paginate fix. Covers a fully-dropped page mid-run, termination at a genuinely empty page, and a run where every page is dropped.Full suite on this stack: 2032 passed, 143 skipped, 2 xfailed, no failures.
make format, mypy, and pre-commit all clean.Note
I had opened #659 for the
REDIS_ARGScompose fix before spotting #653, which already contained it. #659 is now closed as a duplicate: the functional change there was byte-identical and the comment in #653 is the better one.Note
Medium Risk
Changes connection lifecycle for deprecated client APIs (wrong close could break shared pools) and pagination behavior under document-drop races; scope is bounded by tests but affects core index query paths.
Overview
Fixes Redis client ownership when using deprecated
set_client()/connect(): caller-injected clients are no longer closed on GC ordisconnect(), matching__init__(redis_client=...), while index-created clients are still owned and released when swapped. Sync and async paths update_owns_redis_clientunder a lock (async via_swap_client).Pagination no longer stops when a page is empty because every match was dropped for missing field payloads; it only ends on a truly empty page (
dropped_count == 0), skips non-yieldable empty dropped pages, and still advances the offset.Query processing APIs are typed to return
SearchResultssocomplete/dropped_countare visible to type checkers. Docs clarify thatresults.completereflects client-side drops only, not Redis 8.8+ indexing lag after recent writes.Reviewed by Cursor Bugbot for commit 15fd17a. Bugbot is set up for automated code reviews on this repo. Configure here.