fix: tolerate matched docs with missing field payload (Redis 8.8 expiry race)#653
Open
vishal-bala wants to merge 2 commits into
Open
fix: tolerate matched docs with missing field payload (Redis 8.8 expiry race)#653vishal-bala wants to merge 2 commits into
vishal-bala wants to merge 2 commits into
Conversation
…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>
vishal-bala
marked this pull request as ready for review
July 24, 2026 13:12
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 the shared query result-processing path used by all indexes and extensions; behavior is backward compatible but result counts and extension routing can differ under TTL-heavy concurrent search.
Overview
Hardens RedisVL against a Redis 8.8+ background-search race where a document expiring during
FT.SEARCHcan come back as a matched id with an empty field payload (instead of being dropped server-side), which previously causedKeyErrors, partial records, or extension validation failures.Core parsing (
process_results, aggregate/hybrid/SQL paths) now skips those hits when detection is unambiguous (e.g. missingvector_distanceon vector queries with scores, missingjsonon JSON full-object unpack), logs a WARNING, and returns aSearchResultslist subclass exposingdropped_countandcompleteso callers can spot shortened result sets without breaking list usage. Plain hashFilterQuery/TextQuerystill passes through id-only rows to avoid dropping legitimate sparse results.Extensions add defense-in-depth: semantic cache skips invalid
CacheHits (no TTL refresh), message history skips badChatMessagerows, semantic router drops incomplete aggregate rows. Docs document the behavior and completeness signals; tests cover skip branches, over-skip guards, and extension parsers.Reviewed by Cursor Bugbot for commit c554a07. Bugbot is set up for automated code reviews on this repo. Configure here.