fix: tolerate matched docs with missing field payload (Redis 8.8 expiry race) - #653
Open
vishal-bala wants to merge 4 commits into
Open
fix: tolerate matched docs with missing field payload (Redis 8.8 expiry race)#653vishal-bala wants to merge 4 commits into
vishal-bala wants to merge 4 commits into
Conversation
vishal-bala
marked this pull request as ready for review
July 24, 2026 13:12
…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 <[email protected]>
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 <[email protected]>
vishal-bala
force-pushed
the
fix/missing-field-payload-result-parsing
branch
from
July 29, 2026 13:57
c554a07 to
d9340cf
Compare
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 <[email protected]>
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 6d065e1. Configure here.
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 <[email protected]>
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
Harden RedisVL result parsing so a matched document with a missing field payload is tolerated (skipped) instead of raising or surfacing a partial record. This is client-side future-proofing for a Redis 8.8+ server-side race and the durable fix behind the recent CI flakiness on
redis:latest.Background
Redis 8.8 changed a RediSearch default: the query worker pool now runs a nonzero number of background worker threads. With a background executor active, a document (or an indexed hash field) that expires via TTL/
HPEXPIREat the exact instant a backgroundFT.SEARCHmaterializes it is returned as a matched id with anilfield array instead of being dropped server-side. redis-py collapses that into aDocumentcarrying only{"id": ..., "payload": None}— no exception — so the malformed record lands in RedisVL, which previously raisedKeyErrorin the vector-normalize branch or leaked a partial record intoCacheHit/ChatMessage/RouteMatch. The prior mitigation (--search-workers 0in CI) only hid this; real users on Redis 8.8 with TTLs are still exposed.What changed
redisvl/index/index.py):process_resultsnow skips a matched doc whose field payload is missing, detected only on zero-false-positive signals — a vector/range query missing its always-presentvector_distance, or a JSON full-object unpack missing itsjsonkey. A plain hashFilterQuery/TextQuerydoc is passed through as an id-only dict rather than dropped, so legitimate id-only projections andINDEXMISSINGresults are never over-skipped.process_aggregate_resultsand the hybrid/SQL paths drop entirely-empty rows.SearchIndex.query()now returns aSearchResultsobject — a drop-inlistsubclass that additionally exposesdropped_countandcomplete, so callers that need completeness guarantees (audit, eval harnesses, agents) can detect and react to a race-shortened result set. Fully backward compatible: it behaves exactly like a list everywhere else.WARNINGlevel, aggregated once per query.docs/concepts/queries.mdexplains the behavior, the detection boundaries, and how to useresults.complete/dropped_count.Behavior and compatibility
Backward compatible for every caller not hitting the race. Under the race, result counts become best-effort:
query()may return fewer thannum_results,paginate()fewer thanpage_size, andCountQuery.totalcan exceed the number of materialized documents (it reflects the server's match count). Silent skip is the default; no config knob is added.Testing
Deterministic unit tests simulate the malformed response (a
Resultwith aNonefields slot / hand-builtDocument), covering every raise-site and skip branch, an over-skip guard proving legitimate id-only results survive, theSearchResultssignal, and the extension guards. CI stays pinned at--search-workers 0; a liveREDIS_SEARCH_WORKERS>0reproduction is documented as a manual step.make check-typesandmake formatare clean; unit and integration query tests pass.Follow-ups (out of scope)
A process-level skip metric/callback and semantic-cache race-miss vs. real-miss decomposition, a nightly
workers>0integration test to guard the undocumented server shape, optional router detect-and-retry for the top-1 mis-route case, andraw=Truemessage-history hardening.Note
Medium Risk
Changes default query result handling and extension behavior under a rare server race; mis-detection could drop valid sparse hash results, though guards intentionally keep id-only hash matches.
Overview
Hardens RedisVL against a Redis 8.8+ RediSearch race where background workers can return matched document ids with empty field payloads when TTL/expiry coincides with
FT.SEARCH, which previously causedKeyError, validation failures, or partial records downstream.Core parsing (
process_results, aggregates, hybrid/SQL paths) now skips or drops those rows when detection is unambiguous (e.g. missingvector_distanceon scored vector queries, missingjsonon full JSON unpack, empty aggregate/hybrid/SQL rows), logs a single WARNING per query, and returns aSearchResultslist subclass withdropped_countandcompleteso callers can spot shortened result sets without breaking list-shaped APIs. HashFilterQuery/TextQueryid-only matches are still passed through to avoid false positives.Extensions add defense-in-depth: semantic cache skips invalid
CacheHitrows (no TTL refresh), message history skips badChatMessageconstruction, semantic router drops incomplete aggregate rows before routing.CI/docs: docker-compose now passes
--search-workerson the redis-server command line (fixing a no-op env-based pin); integration tests compare vector results by user key instead of order; new unit tests cover parsers and extensions; queries docs describe expiring-document behavior andSearchResults.Reviewed by Cursor Bugbot for commit 8dbf9fd. Bugbot is set up for automated code reviews on this repo. Configure here.