Skip to content

fix: keep paginate going past a page whose matches were all dropped - #701

Open
vishal-bala wants to merge 5 commits into
mainfrom
fix/paginate-dropped-page-truncation
Open

fix: keep paginate going past a page whose matches were all dropped#701
vishal-bala wants to merge 5 commits into
mainfrom
fix/paginate-dropped-page-truncation

Conversation

@vishal-bala

@vishal-bala vishal-bala commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Summary

Two independent bugs in redisvl/index/index.py, both of which silently return fewer documents than the server matched.

1. paginate stopped early on an unmaterializable page. SearchIndex.paginate / AsyncSearchIndex.paginate terminated on if not results: break, treating an empty page as an exhausted result set. But process_results deliberately 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 a nil field 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. NOCONTENT queries returned nothing at all. This one is live on main today and needs no race. Under NOCONTENT the 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's Query.no_content() sets only _no_content and leaves _return_fields untouched, so neither detection predicate noticed.

Measured against a real Redis 8.4, four documents indexed, main vs this branch:

query main this branch
VectorQuery(...).no_content() 0 of 4 4 of 4
VectorQuery(..., normalize_vector_distance=True).no_content() 0 of 4 4 of 4
FilterQuery(...).no_content() (hash) 4 of 4 4 of 4

Hash FilterQuery escaped because it trips neither predicate. Vector and range queries (which always project vector_distance when return_score=True, the default) and JSON full-object unpack both returned an empty list for every NOCONTENT call.

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 than len(), because process_results returns a bare int for a CountQuery.
  • _fold_carried_drops(results, carried)paginate never yields an empty batch, so a fully-dropped page's dropped_count would otherwise vanish and leave later batches reporting complete is True. Its count is folded into the next yielded batch instead.
  • An explicit TypeError for CountQuery in both paginate methods. It returns a match count, not documents; previously it yielded that integer forever.
  • _has_missing_field_payload short-circuits on _no_content, and the vector-normalize branch is guarded on the distance actually being present (otherwise VectorQuery(normalize_vector_distance=True).no_content() raised KeyError).
  • SearchResults is now in the API reference, and docs/concepts/queries.md documents the pagination behaviour.

SearchResults remains a drop-in list, 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_results warning and never reach SearchResults.dropped_count.

paginate is offset-based, and that is inherently unstable. Redis documents LIMIT without sorting as non-deterministic — subsequent queries may return duplicated or missing values. Stable paging requires SORTBY on 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-backed paginate (FT.AGGREGATE ... WITHCURSOR, which _iter_keys_by_filter in this same file already uses); it is explicitly not attempted in this PR.

Deep pagination is still bounded server-side. search-max-search-results caps 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 public SearchResults.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.

  • Full suite (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-notebooks on 01_getting_started.ipynb (which demonstrates paginate()): 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:

  • Reverting _page_had_matches to return bool(results) fails 2 of the 5 paginate tests.
  • Deleting the carried_drops = 0 reset fails the same 2, with dropped_count going [2, 5] instead of [2, 3].
  • Removing the _no_content short-circuit fails both NOCONTENT unit tests and the new integration test.

tests/unit/test_paginate_dropped_page.py serves canned FT.SEARCH replies keyed by paging offset through the real _queryprocess_resultsSearchResults chain, so the dropped_count the 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 commit 0cfabe2, 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 a search-workers default of 0. Checked empirically with CONFIG GET search-workers:

image redis_version search-workers
redis:8.4 8.4.6 0
redis:8.8.0 8.8.0 12
redis:latest 8.10.0 12

The 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 0 pin and comment in tests/docker-compose.yml are 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 paginate from treating an empty page as end-of-results when matches were dropped (expiry/update race), and stops NOCONTENT queries from being dropped as if they were race victims.

Sync and async paginate now advance while the server still reported matches (dropped_count or documents), skip empty batches, and fold skipped-page drops into the next yielded SearchResults. CountQuery is rejected instead of looping forever.

_has_missing_field_payload short-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 or KeyError. Docs note the remaining caveats: trailing fully-dropped pages only log, and offset paging still needs a unique sort_by.

Reviewed by Cursor Bugbot for commit 564f96b. Bugbot is set up for automated code reviews on this repo. Configure here.

vishal-bala and others added 5 commits August 6, 2026 18:27
`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 vishal-bala added the auto:patch Increment the patch version when merged label Aug 24, 2026
@vishal-bala
vishal-bala marked this pull request as ready for review August 24, 2026 15:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto:patch Increment the patch version when merged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant