fix: de-duplicate cursor keys in update_by_filter - #671
Conversation
tests/integration/test_bulk_operations.py was intermittently failing in CI with processed=87 or 107 against matched=60, blocking unrelated PRs. The cause is a real bug, not test isolation: each xdist worker already gets its own Redis container. An FT.AGGREGATE ... WITHCURSOR cursor is a position in an ascending walk of internal document ids, not a snapshot, so it can return the same key on more than one page. Confirmed at raw protocol level on Redis 8.4.4: a 60-document match set came back as 61-72 rows. update_by_filter wrote and counted each emission, so documents were written twice and processed was inflated. Two mechanisms, both measured: - RediSearch has no in-place update. Any write to a document the index covers tombstones its old document id and appends a brand-new higher one (max_doc_id climbed 12->13->14->15 across three single-field HSETs while num_docs held at 10), even when the written field is not in the schema and even when the same value is stored back. A document rewritten while a cursor is open is removed from behind it and re-added in front. - A plain pipelined bulk load creates tombstones on its own: loading 120 fresh documents gave max_doc_id=168 for num_docs=120. Duplication is therefore worst immediately after a load (7/15 runs) and disappears once the index settles (0/15 after 2s), which is exactly the shape of the CI fixture: create(overwrite=True, drop=True), load 120, update immediately. Fix: de-duplicate keys across cursor pages in both the sync and async _iter_keys_by_filter via a shared _dedupe_keys helper. Repro rate before the fix was 11/30 (sync) and 26/60 (async); after, 0/40 and 0/80. The de-duplication is load-bearing beyond the count. Because every write tombstones a document id, redundant writes manufacture the very index churn that provokes further re-emission. It is also what makes the read-then-write phase split necessary rather than merely tidy: interleaving reads and writes does not terminate, since each write re-appends the document ahead of the cursor. Measured, streaming writes into an open cursor rewrote a 60-document match set over 3000 times without finishing. Also corrects two docstring claims that measurement disproved: the cursor CAN be read while the index is written (it neither errors nor loses rows, it just fails to terminate), and processed is at-most-once rather than exactly-once -- a cursor may also return fewer rows than matched, which Redis documents, so a completed run is not proof every match was seen. Tests: two unit tests replay canned cursor pages (real re-emission is timing-dependent, so canning it is what makes them deterministic) and one integration test covers the end-to-end contract with a real count query and real writes. Test set was chosen with a mutation matrix -- seven candidate tests were dropped for adding no unique coverage, and the surviving three each catch a defect nothing else does. Existing assertions were not loosened: processed == 60 is the documented contract.
|
Wouldn't this be a bug in our cursor processing logic in Redis Search? |
|
|
||
| - **Live-traffic impact.** Every delete/update also updates the search index synchronously. Running a large bulk job against a node that is serving queries competes for CPU and reindex bandwidth and can raise query latency—prefer off-peak windows, or narrow the filter and run in waves. | ||
| - **Memory.** `drop_by_filter` holds only one batch of keys at a time (`O(batch_size)`). `update_by_filter` must resolve *all* matching keys before it can write (an open aggregation cursor can't be read while the index is being written), so its client memory grows with the match count—roughly the total size of the matched keys. For very large match sets, partition the filter (see below) rather than updating everything in one call. | ||
| - **Memory.** `drop_by_filter` holds only one batch of keys at a time (`O(batch_size)`). `update_by_filter` must resolve *all* matching keys before it can write (an open aggregation cursor can't be read while the index is being written), so its client memory grows with the match count—roughly the total size of the matched keys, plus about a third again for the set that de-duplicates them (an aggregation cursor can return the same document on more than one page, so keys are de-duplicated to guarantee one write per document). Budget on the order of 120 MB per million 40-character keys. For very large match sets, partition the filter (see below) rather than updating everything in one call. |
There was a problem hiding this comment.
I think this is a worthwhile workaround, but I would make sure we flag the issue to the search team on the cursor duplicates. That seems like it should not be a thing and we are having to absorb it with some less than ideal patterns that could leak memory to client application to a level that is not transparent at scale.
There was a problem hiding this comment.
I don't know that it qualifies as a bug - it's kind of a natural side-effect of the way that cursors work in Redis and us faking a single client-side op with several server-side ops. It's definitely something that isn't documented so clearly as a possible behaviour of SCAN, so that's something worth flagging.
|
🚀 PR was released in |
Problem
tests/integration/test_bulk_operations.pywas intermittently failing in CI across four matrix cells on code that does not touch bulk operations, blocking unrelated PRs. The failures all hadmatched=60correct butprocessedvarying non-deterministically —assert 87 == 60,assert 107 == 60,assert 25 == 60.This is a real bug, not test isolation. Each xdist worker already gets its own Redis container via a per-worker
COMPOSE_PROJECT_NAME, so cross-test contamination was never possible. The bug reproduces serially against a single Redis in under a minute.Root cause
An
FT.AGGREGATE ... WITHCURSORcursor is a position in an ascending walk of internal document ids, not a snapshot of the match set, so it can return the same key on more than one page. Confirmed at raw protocol level on Redis 8.4.4 (search-workers=0) — a 60-document match set comes back as 61-72 rows, with the extra rows arriving on the final page:update_by_filterwrote and counted each emission, so documents were written twice andprocessedwas inflated.matchedstayed correct because the count query is a single round-trip with no cursor.Two mechanisms, both measured:
RediSearch has no in-place update. Any write to a document the index covers tombstones its old document id and appends a brand-new, higher one.
max_doc_idclimbed 12→13→14→15 across three single-fieldHSETs whilenum_docsheld at 10, and the touched document moved to the end of the row order each time — true even when the written field is not in the schema, and even writing the identical value back. So a document rewritten while a cursor is open is removed from behind the cursor and re-added in front of it.A plain pipelined bulk load creates tombstones on its own. Loading 120 fresh documents gave
max_doc_id=168fornum_docs=120— 48 documents were re-indexed during ingestion (num_recordscorroborates exactly: 504 = 360 live + 48×3 orphaned). One-by-one loading gave only 4. Duplication is therefore a transient post-write settling effect:Which is precisely the shape of the failing test:
create(overwrite=True, drop=True)→ load 120 documents → immediatelyupdate_by_filter. Severity tracks outstanding tombstone churn — load only 4/15, load + updates 7/15, load + prior delete/reload 11/15, settled 0/15.Fix
De-duplicate keys across cursor pages in both the sync and async
_iter_keys_by_filter, via a shared_dedupe_keyshelper that also collapses repeats within a page.update_by_filter× Ntest_bulk_operations.py -n 4× 5The de-duplication is load-bearing beyond the count: because every write tombstones a document id, redundant writes manufacture the very index churn that provokes further re-emission.
drop_by_filterneeds none of this — it re-queries from offset 0 and counts actualUNLINKreturns, so repeats are absorbed, because deletes shrink their own match set. An update does not, which is why the same strategy doesn't transfer.Alternatives considered and rejected
Every memory-free alternative relocates O(n) state from the client's private heap onto a shared resource. For a library embedded in many applications, the client heap is the only O(n) budget it legitimately owns.
seenset stops being a counting nicety and becomes the termination guarantee — and it is still O(match count), so there was never an O(batch_size) option to trade for.FILTER @__key > '<last>'+SORTBY @__key) — verified working and duplicate-free by construction, butFILTERis post-query and documented as "not indexed", andLOAD @__keyis documented as "the equivalent of HMGET against a Redis key" per record. Keyset pays that full scan per page: ~10⁹ record-visits at 1M documents versus ~10⁶ for one cursor walk. AlsoPARAMSdoes not reachFILTERexpressions, so the watermark must be string-interpolated into an expression DSL with unspecified escaping — on the one code path whose job is mutating data, with keys that embed user-controlled ids. Worth revisiting only as an opt-in resumability feature.SADDdedup — exact with O(1) client memory, but the marker set is an eligible eviction victim underallkeys-*, so dedup would silently stop exactly under memory pressure. A TTL for crash cleanup makes it a victim undervolatile-*too. PlusCROSSSLOTon Cluster.GROUPBY @__key— a blocking stage that materialises the whole match set server-side before emitting row one.Memory cost of the shipped fix, measured (40-char keys): the
seenset adds 32 MB per million keys on top of ~84 MB thekeyslist and its strings already occupied — a ~38% constant, no change in complexity class. The 1M-match case is the scenario the docs already tell users to partition.Docstring corrections
Two pre-existing claims that measurement disproved:
processedis at-most-once, not exactly-once. A cursor may also return fewer rows than matched (Redis documents both a partial result when it reports cursor id 0, and missing rows under load rebalancing), so a completed run is not proof that every matching document was seen. Corrected in the docstring and indocs/concepts/search-and-indexing.md.Tests
Two unit tests replay canned cursor pages, and one integration test covers the end-to-end contract with a real count query and real writes. Canning is deliberate: real re-emission reproduces only ~4/15 of the time, so a live-server test would carry a ~60% false-pass rate.
The test set was chosen with a mutation matrix rather than by taste. Seven candidate tests were dropped for adding no unique coverage — including one that asserted order preservation and still passed when the implementation was replaced with an order-destroying set comprehension, and one whose only mutation was already caught by two pre-existing tests. The surviving three each catch a defect nothing else does (sync-only regression, async-only regression, end-to-end
processedaccounting), and the reduced set has zero uncaught mutations where the larger set had one.Existing assertions were not loosened —
processed == 60is the documented contract.Follow-ups (not in this PR)
completed=Trueis returned unconditionally byupdate_by_filtereven thoughprocessedcan silently undercount.completed = len(keys) >= matchedwould close that for free, but it changes a documented contract.BaseCache.clearnever advancing its SCAN cursor on Cluster;paginatetreating an empty page as exhaustion;delete_route_referencesraising on duplicate SCAN keys; migration validation comparing indexed-document enumeration against a SCAN-by-prefix count.Note
Medium Risk
Changes bulk partial-update behavior on a data-mutation path; risk is mitigated by focused unit/integration tests and clearer
processedsemantics, but callers relying on the old inflated counts would see different numbers.Overview
Fixes
update_by_filterinflatingBulkResult.processedwhen RediSearch’sFT.AGGREGATE ... WITHCURSORreturns the same document key on multiple pages (cursor walks internal doc ids, not a snapshot; writes can re-append ids ahead of the cursor).Adds
_dedupe_keysand a per-iterationseenset in sync and async_iter_keys_by_filter, so each matching key is written and counted at most once. Docstrings anddocs/concepts/search-and-indexing.mdnow describe at-most-onceprocessed, possibleprocessedvsmatchedgaps, extra client memory for the dedup set (~120 MB/M keys guidance), and why read-then-write must stay separated.Tests: new unit coverage in
tests/unit/test_bulk_cursor_dedup.pyand an integration test that replays canned aggregate pages to assert single writes per document.Reviewed by Cursor Bugbot for commit 04c7d2a. Bugbot is set up for automated code reviews on this repo. Configure here.