fix: keep paginate going past a page whose matches were all dropped - #701
Open
vishal-bala wants to merge 5 commits into
Open
fix: keep paginate going past a page whose matches were all dropped#701vishal-bala wants to merge 5 commits into
paginate going past a page whose matches were all dropped#701vishal-bala wants to merge 5 commits into
Conversation
`SearchIndex.paginate` and `AsyncSearchIndex.paginate` terminated on `if not results: break`, treating an empty page as an exhausted result set. That is unsound: `process_results` deliberately drops matched documents whose field payload came back missing (the Redis 8.8+ background-WORKERS TTL/expiry race), so a page can be empty while the server still has matches to report. When every document on one page was dropped, iteration stopped early and silently discarded every remaining page. Termination now asks whether the server reported any match for the page, using the `dropped_count` that `SearchResults` already carries, rather than whether any document could be materialized. A page with matches but no materialized documents is skipped and the offset still advances, so iteration always makes progress and cannot wedge. Fully-dropped pages are not yielded, so every yielded batch stays non-empty as before. Regression tests drive the termination logic with canned pages (the live 8.8 race is not reproducible on demand); 3 of them fail before this change.
Five review passes (code, security, system design, documentation, testing) over b3e808f. Three real defects and a set of gaps: **`_page_had_matches` used `len()` where the old code used truthiness.** `process_results` returns a bare `int` for a `CountQuery`, so `paginate` on one went from an infinite loop (total > 0, the old code yielded the same integer forever) to `TypeError: object of type 'int' has no len()` — and for total == 0 that was a strict regression, since the old code exited cleanly. Switched to truthiness so `int`/`None`/`list` never raise, and added an explicit `CountQuery` guard with a message pointing at `index.query()`, which fixes the pre-existing infinite loop rather than trading it for an obscure `TypeError`. **A fully-dropped page took its `dropped_count` out of the stream.** A page with 1 kept and 9 dropped was yielded reporting `dropped_count=9`; a page with 0 kept and 10 dropped was invisible, leaving every batch the caller saw reporting `complete is True` while matched documents went missing. Same event, opposite observability, decided by whether one document happened to survive. A skipped page's count is now folded into the next yielded batch (`_fold_carried_drops`), which keeps the signal reachable without breaking the non-empty-batch guarantee. Trailing dropped pages have no later batch to ride on and remain warning-only; that residual gap is documented at the helper and in the concepts guide. **The helper docstring justified its `getattr` with a path that does not exist.** No query path returns a plain `list` — every one returns `SearchResults`. Reworded to say what the fallback actually guards: an override or test double. Tests re-seated on the `index.search` seam, so canned `FT.SEARCH` replies run the real `_query` -> `process_results` -> `SearchResults` chain and the `dropped_count` the fix depends on is produced by production code instead of hand-constructed. Pages are keyed by paging offset, so a failure to advance shows up as repeated documents or a wedge rather than a silently different page, and the request count is bounded so a regression fails instead of hanging the suite. Added the cases reviewers found missing: partially-dropped page (yielded, `SearchResults`, count intact), carry-forward, trailing dropped page, zero matches, `page_size` validation, `CountQuery` rejection, async mirrors, and direct contract tests for both helpers. Verified by reintroducing each of the three regressions in turn. Docs: the `paginate` notes were changelog-voice and named internal machinery that appears nowhere in the published docs; rewritten at caller altitude to state the actionable facts. `SearchResults` was a public export with no API-reference entry even though this fix's correctness rests on it — added. Pagination's guarantee added to the concepts guide, whose existing text promised `results.complete` detects short paginated pages. Deferred deliberately: short-page termination (`len + dropped < page_size`), a public `SearchResults.total`, and a separately-filed pre-existing unbounded loop in `clear()`/`drop_by_filter()` where a non-empty batch that deletes nothing re-queries offset 0 forever.
A Redis-docs review of the paginate change surfaced a false positive in `_has_missing_field_payload` that this branch made materially worse. FT.SEARCH with NOCONTENT "returns the document ids and not the content" for every healthy match (`RETURN 0` behaves the same way), so no field payload is expected and its absence carries no information. redis-py's `Query.no_content()` sets only `_no_content` and leaves `_return_fields` untouched, so both detection branches fire on every document: `unpack_json` stays true because `_return_fields` is empty, and `vector_distance` stays in `_return_fields` while the server sends ids only. Verified against production code: a 3-match NOCONTENT reply came back as zero documents with `dropped_count=3`, on both the JSON-unpack and vector paths. Before this branch that surfaced as one empty page and iteration stopped. After it, `_page_had_matches` is true on every page, so `paginate` walked the entire result set yielding nothing and logging a warning per page — and deep offsets can now reach the server's `search-max-search-results` cap and error instead of ending. `index.query()` was already silently returning `[]` for these queries. Short-circuits the predicate on `_no_content`, and guards the vector-normalize branch on the distance actually being present — without that, skipping the drop lets `VectorQuery(normalize_vector_distance=True).no_content()` reach `doc_dict[DISTANCE_ID]` and raise `KeyError`. Race detection and normalization of healthy documents are both unchanged (verified). Docs corrected against the FT.SEARCH reference: - The stable-pagination note asked only for a `sort_by` clause. The docs require `SORTBY` on a *unique* field — "If you use the LIMIT option without sorting, the results returned are non-deterministic, which means that subsequent queries may return duplicated or missing values." Also notes the `search-max-search-results` ceiling (1,000,000 default, 10,000 on some managed tiers), which bounds deep pagination. - The concepts bullet added in a80dd01 claimed `paginate()` "still reaches the end of the result set". True of offsets, not of documents: without a unique sort key pages can repeat or miss rows regardless of expiry. Reworded and qualified. - Widened the race framing in the text this branch introduced: the trigger is a key that expires *or is updated* mid-query, not TTL expiry alone, and such a key is still counted in the server's total. Not changed: the "Redis 8.8+ background-WORKERS" attribution in the seven docstring sites inherited from 0cfabe2. The review argued `search-workers` defaults to 0, but cited the Redis Software REST API object reference rather than the OSS configuration page, and this repo pins `--search-workers 0` in tests/docker-compose.yml precisely because 8.8 changed that default and broke `redis:latest` CI. Left alone pending evidence from the OSS docs.
A mutation-testing audit found the suite I expanded in a80dd01 was mostly redundant: many tests had kill-sets that were strict subsets of others, and six parametrized rows killed no mutant at all. Worse, it missed three real defects. Collapsed the drop-shape tests into one page map that exercises every shape in a single pass -- leading fully-dropped page, a healthy page that inherits its drops, a second dropped page forcing the carry reset, a partially-dropped page combining its own drop with the carried ones, then the genuine end. The `dropped_count` sequence `[2, 3]` is what pins the accounting: losing the fold gives `[0, 1]`, losing the reset gives `[2, 5]`, assigning instead of adding gives `[2, 2]`. Two defects survived all 27 tests and are now caught (verified by mutation): - the carry counter never being reset, double-counting across two dropped pages; - the async path skipping a partially-dropped page instead of yielding it. Deleted as redundant or fictional: the empty-page, zero-match, trailing-dropped and every-page-dropped tests (kill-sets subsumed by the merged pair); the nine-row `_page_had_matches` table (three rows killed nothing; the `int`/`None` rows cover inputs the CountQuery guard makes unreachable); the three `_fold_carried_drops` unit tests (one is a provably equivalent mutant, `+= 0`); and both plain-list tolerance tests, which exercised a path their own docstring admitted does not exist in production. That last deletion also repairs a false claim in a80dd01's message. Those two tests overrode `_query` directly instead of the `index.search` seam, so they had no request bound -- under a never-terminate regression they hung indefinitely rather than failing. Every remaining fake bounds its request count, so the claim now holds for the whole file. Kept beyond the merged pair: both CountQuery guards (separate sync and async code) and `page_size` validation, which nothing else in the repo covers. Net: 27 -> 5 tests, 388 -> 191 lines, and 6 of 6 targeted mutants killed. The only coverage genuinely given up is on inputs production cannot produce.
The unit tests for the ``_no_content`` short-circuit drive ``process_results`` with a hand-built ``Result``. This adds the integration counterpart, which needs no race to trigger: under ``NOCONTENT`` a real server returns ids and no field data for every healthy match, which is exactly the shape the drop heuristic uses to detect an expiry-race victim. Covers the three shapes that regressed -- plain ``FilterQuery``, ``VectorQuery``, and ``VectorQuery(normalize_vector_distance=True)`` (which raised ``KeyError`` rather than returning ids) -- plus ``paginate`` over a NOCONTENT result set, tying the fix to the termination change on this branch. Verified to fail with the short-circuit removed.
vishal-bala
marked this pull request as ready for review
August 24, 2026 15:42
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two independent bugs in
redisvl/index/index.py, both of which silently return fewer documents than the server matched.1.
paginatestopped early on an unmaterializable page.SearchIndex.paginate/AsyncSearchIndex.paginateterminated onif not results: break, treating an empty page as an exhausted result set. Butprocess_resultsdeliberately drops matched documents whose field payload came back missing — a key that expires or is updated mid-query is returned by the server as a matched id with anilfield array, and is still counted in the server's total. So a page can be empty while matches remain, and the loop would exit and discard every remaining page.2.
NOCONTENTqueries returned nothing at all. This one is live onmaintoday and needs no race. UnderNOCONTENTthe server returns ids and no field data for every healthy match, which is byte-for-byte the shape the drop heuristic uses to detect a race victim — so it dropped every document. redis-py'sQuery.no_content()sets only_no_contentand leaves_return_fieldsuntouched, so neither detection predicate noticed.Measured against a real Redis 8.4, four documents indexed,
mainvs this branch:mainVectorQuery(...).no_content()VectorQuery(..., normalize_vector_distance=True).no_content()FilterQuery(...).no_content()(hash)Hash
FilterQueryescaped because it trips neither predicate. Vector and range queries (which always projectvector_distancewhenreturn_score=True, the default) and JSON full-object unpack both returned an empty list for everyNOCONTENTcall.What changed
Termination now asks whether the server reported any match, via
SearchResults.dropped_count, rather than whether the page yielded documents. The offset advances unconditionally, so a page that cannot be materialized can never wedge the loop._page_had_matches(results)— a page had matches when it either yielded documents or dropped some. Uses truthiness rather thanlen(), becauseprocess_resultsreturns a bareintfor aCountQuery._fold_carried_drops(results, carried)—paginatenever yields an empty batch, so a fully-dropped page'sdropped_countwould otherwise vanish and leave later batches reportingcomplete is True. Its count is folded into the next yielded batch instead.TypeErrorforCountQueryin bothpaginatemethods. It returns a match count, not documents; previously it yielded that integer forever._has_missing_field_payloadshort-circuits on_no_content, and the vector-normalize branch is guarded on the distance actually being present (otherwiseVectorQuery(normalize_vector_distance=True).no_content()raisedKeyError).SearchResultsis now in the API reference, anddocs/concepts/queries.mddocuments the pagination behaviour.SearchResultsremains a drop-inlist, so no caller needs to change.Known limitations
These are real and deliberate — this PR does not claim to make offset pagination correct in general, only to stop it truncating on a droppable page.
Trailing dropped pages are only logged. A result set whose final pages are entirely dropped has no subsequent batch to fold those drops into, so they are reported only by the
process_resultswarning and never reachSearchResults.dropped_count.paginateis offset-based, and that is inherently unstable. Redis documentsLIMITwithout sorting as non-deterministic — subsequent queries may return duplicated or missing values. Stable paging requiresSORTBYon a unique field; sorting on a non-unique field (age, say) is not sufficient. This is independent of the expiry race and is not fixed here. The durable fix is a cursor-backedpaginate(FT.AGGREGATE ... WITHCURSOR, which_iter_keys_by_filterin this same file already uses); it is explicitly not attempted in this PR.Deep pagination is still bounded server-side.
search-max-search-resultscaps the reachable offset — 1,000,000 by default (verified on Redis 8.4), but 10,000 on Redis Cloud Free & Fixed tiers. Past that the search errors rather than ending cleanly.Deliberately deferred. Short-page termination (stopping when
len(batch) + dropped_count < page_size, which would save one round trip per iteration) and a publicSearchResults.total. Both are additive and neither is needed for correctness.Verification
Run against Redis 8.4 via testcontainers, on Python 3.14, with
uv sync --all-extras.make test): 1900 passed, 257 skipped, 2 xfailed, 0 failed.make check-types(mypy over./redisvl): clean, 119 source files.make docs-build: succeeds; 11 warnings, all pre-existing and none from files touched here.pre-commit run --all-files: passes.make test-notebookson01_getting_started.ipynb(which demonstratespaginate()): 24 passed, 1 skipped, no external API calls.The regression tests were confirmed to actually fail against the pre-fix code, not merely to pass:
_page_had_matchestoreturn bool(results)fails 2 of the 5 paginate tests.carried_drops = 0reset fails the same 2, withdropped_countgoing[2, 5]instead of[2, 3]._no_contentshort-circuit fails bothNOCONTENTunit tests and the new integration test.tests/unit/test_paginate_dropped_page.pyserves cannedFT.SEARCHreplies keyed by paging offset through the real_query→process_results→SearchResultschain, so thedropped_countthe fix depends on is produced by production code. Every fake bounds its request count, so a termination regression fails loudly instead of hanging the suite. An audit cut this file from 27 tests to 5 that kill strictly more mutants.A note on the "Redis 8.8+" attribution
Seven docstrings in
redisvl/index/index.py(inherited from merged commit0cfabe2, not written on this branch) attribute the race to Redis 8.8+ changing the RediSearch worker pool default to a nonzero background executor. A reviewer disputed this, citing asearch-workersdefault of0. Checked empirically withCONFIG GET search-workers:redis_versionsearch-workersredis:8.40redis:8.8.012redis:latest12The docstrings are correct and the reviewer's number came from the Redis Software REST API object reference, which does not govern Redis Open Source. The
--search-workers 0pin and comment intests/docker-compose.ymlare also accurate. No change needed.Note
Medium Risk
Changes search result processing and pagination termination, which can alter which documents callers receive and how completeness is reported. Behavior is covered by unit and integration tests, but it sits on a core query path.
Overview
Stops
paginatefrom treating an empty page as end-of-results when matches were dropped (expiry/update race), and stopsNOCONTENTqueries from being dropped as if they were race victims.Sync and async
paginatenow advance while the server still reported matches (dropped_countor documents), skip empty batches, and fold skipped-page drops into the next yieldedSearchResults.CountQueryis rejected instead of looping forever._has_missing_field_payloadshort-circuits on_no_content, and distance normalization is skipped when no score is present, so id-only vector/JSON queries return ids instead of an empty list orKeyError. Docs note the remaining caveats: trailing fully-dropped pages only log, and offset paging still needs a uniquesort_by.Reviewed by Cursor Bugbot for commit 564f96b. Bugbot is set up for automated code reviews on this repo. Configure here.