fix: cache clear() and migration key scans hang or crash on Redis Cluster - #703
Draft
vishal-bala wants to merge 5 commits into
Draft
fix: cache clear() and migration key scans hang or crash on Redis Cluster#703vishal-bala wants to merge 5 commits into
vishal-bala wants to merge 5 commits into
Conversation
`BaseCache.clear`/`aclear` could spin forever on Redis Cluster, leaving
the cache populated. `RedisCluster.scan` broadcasts SCAN to every primary
and replies with a `{node_name: cursor}` mapping, so `cursor_int == 0`
was never true, the `Mapping` branch only broke once every node reported
0, and the `else` that advanced the cursor was unreachable. The cursor
stayed 0 and `SCAN 0` was re-issued indefinitely.
Re-issuing `SCAN 0` accidentally makes progress when every key in the DB
matches the cache prefix: each round deletes the first page, the keyspace
shrinks, and the loop drains. That is why small-cache tests passed. The
genuine hang needs keys that do NOT match the prefix -- the normal case,
since redisvl shares a keyspace between index docs, caches and app data.
Then a `SCAN 0` page can match nothing, nothing is deleted, and the loop
makes zero progress. Measured against a real 3-primary cluster with
50k unrelated keys and 200 cache keys: 20,000 SCAN calls without
terminating, 197 of 200 cache keys orphaned.
Cursors are node-local, so the mapping cannot be handed back to
`scan(cursor=...)` (redis-py raises DataError) and a single value cannot
be broadcast. Rather than hand-roll the per-node walk, delegate to
redis-py's `scan_iter`, which already drives each primary on its own
cursor via `target_nodes`. That drops the sync/async duplication and all
six `# type: ignore`s, and leaves cluster cursor semantics to redis-py.
Deletes are batched at CLEAR_BATCH_SIZE rather than one DEL per SCAN
page. Also documents what callers actually get: SCAN is not a
point-in-time snapshot, so this is a best-effort sweep, not an atomic
flush. Those docstrings are published API reference text, since
docs/api/cache.rst autodocs SemanticCache and EmbeddingsCache with
`:inherited-members:`.
Affects `SemanticCache` and `EmbeddingsCache`, neither of which overrides
`BaseCache`.
Six migration SCAN loops fed the previous reply's cursor straight back
into `client.scan(cursor=...)`. On a cluster client that reply is a
`{node_name: cursor}` mapping, so `cursor == 0` is never true and the
second iteration passes a dict as the cursor. Verified against a real
3-primary cluster: `DataError: Invalid input of type: 'dict'`. Unlike the
cache bug this is a hard crash on the second SCAN, and it fires for any
cluster keyspace.
Reachability, most to least exposed:
validation._count_index_keys every validate run
async_validation._count_index_keys every validate run
async_planner._async_sample_keys count=max(limit, 10) rarely fills
the limit on the first page
planner._sample_keys count=max(limit, 1000) usually
returns early, but not when the
keyspace is smaller than the limit
executor._enumerate_with_scan fallback paths only
async_executor._enumerate_with_scan fallback paths only
All six are plain "enumerate keys matching a pattern", so each collapses
to `scan_iter`, which handles the per-node cursors upstream. The sample
sites gain a small bonus: a generator lets the sample limit stop us
mid-page instead of draining the page first.
Also widens `scan_by_pattern` from `Redis` to `SyncRedisClient` -- it was
already cluster-correct via `scan_iter`, but its annotation said
otherwise.
The migration test doubles define `scan` but not `scan_iter`, so they now
bind redis-py's real `scan_iter` and drive their own `scan` through the
actual library loop.
`test_embeddings_cache_cluster_sync`/`_async` passed `text=` to `EmbeddingsCache.set`/`aset`, whose parameter is `content`. They raised TypeError on their first statement and had never executed, which is part of why the cluster clear hang shipped. Both also called `clear()` with no assertion afterward, so even once repaired they would not have caught it. Adds a dedicated multi-page regression test, sync and async. It seeds both unrelated keys and a cache larger than one SCAN page, because a cache whose keys are the entire keyspace clears fine even with the bug present -- 100 keys, what the existing test used, is exactly the size that passes either way. It bounds the SCAN calls clear() may issue, since a regression hangs rather than fails and pytest-timeout is not installed, and asserts paging actually happened so the test cannot pass vacuously. Counts keys with `scan_iter`, never KEYS or DBSIZE: those are routed to a single node on a cluster and silently report roughly one shard's worth (measured: 201 of 600). The async test seeds with `aset` rather than `amset`, because `amset` silently writes nothing on an async cluster client -- it awaits the pipeline object returned by the queueing call, which drains the queue before `execute()`. That is a separate bug, filed separately; this test is about `aclear`. Note these tests only run under `--run-cluster-tests`, which no CI workflow passes today.
`_async_sample_keys` returns from inside `async for` once the sample limit is reached. An async generator abandoned that way is not closed until loop shutdown, which surfaced as `RuntimeWarning: coroutine method 'aclose' of 'AsyncScanCommands.scan_iter' was never awaited` during the migration tests. Introduced when the hand-rolled SCAN loop became `scan_iter` -- the old `while` loop held no generator. Wraps the iteration in `contextlib.aclosing`. The sync planner has the same early return, but a plain generator is closed deterministically by refcounting on CPython and emits no warning, so it is left alone.
Mutation-tested the new suite. Of 12 cache clear tests only 3 killed
anything no other test killed, and two killed nothing at all. Now that
the cursor walk lives in redis-py rather than in `clear`, most of what
those tests asserted was upstream's behavior.
Cut 12 tests to 5, with a strictly larger kill set:
- Dropped the single-node, duplicate-key and concrete-EmbeddingsCache
cases: kill sets were subsets of, or identical to, the multi-primary
drain test. The duplicate-key test also asserted `sorted(set(...))`,
discarding the duplication it claimed to check.
- Dropped the standalone cursor-arithmetic tests. They pinned
`Redis.scan_iter`'s internals, down to its habit of seeding the cursor
with the string "0". Standalone clear/aclear is covered end to end
against a real Redis by tests/integration/test_llmcache.py.
- Dropped the `cursor != 0` assertion on continuations. With `scan_iter`
driving, no version of our 9-line `clear` can violate it.
- Kept `test_deletes_are_batched`: it is the only thing standing between
us and `client.delete(*list(client.scan_iter(...)))`, which would OOM
on a large cache. Now patches CLEAR_BATCH_SIZE instead of depending on
its value, so tuning the constant does not touch the test.
- Fixed `test_empty_cache_issues_no_delete`, which previously asserted
nothing that could fail. The fake now rejects a zero-argument DEL, the
way real Redis does ("wrong number of arguments for 'del'"), making
these two the only guard on dropping the `if batch:` flush guard.
Adds tests/unit/test_migration_cluster_scan.py. Reverting all six
migration modules left 65/65 existing tests green -- that fix had no
regression coverage at all. The fake replies with per-node cursors and
raises DataError on a dict cursor, so the old loop fails there exactly as
it fails against a real cluster. Covers the four always-reachable sites;
the two executor sites sit behind index-info mocking and are the same
one-line pattern, so they are left to integration.
Also tightens the three migration test doubles: `_type=None` alone is
what upstream passes, so `**kwargs` is dropped and an unexpected future
kwarg now fails loudly instead of being swallowed.
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.
Two independent bugs with one root cause: four modules hand-rolled a
SCANcursor loop, and a cluster client'sSCANreply is not a cursor.RedisCluster.scanbroadcastsSCANto every primary and replies with a{node_name: cursor}mapping of node-local cursors. A single value cannot be broadcast back and the mapping cannot be handed toscan(cursor=...). Every hand-rolled loop in the codebase got this wrong, in one of two ways.Why
BaseCache.clear/aclearspins forever and leaves the cache populated.cursor_int == 0was never true against the mapping, theMappingbranch only broke once every node reported 0, and theelsethat advanced the cursor was unreachable — so the cursor stayed 0 andSCAN 0was re-issued indefinitely. AffectsSemanticCacheandEmbeddingsCache; neither overridesBaseCache.The reason this shipped is worth stating, because it is what made the bug invisible to tests. Re-issuing
SCAN 0accidentally makes progress when every key in the DB matches the cache prefix: each round deletes the first page, the keyspace shrinks, and the loop drains. A cache that is the entire keyspace therefore clears fine even with the bug present. The genuine hang needs keys that do not match the prefix — the normal case, since RedisVL shares a keyspace between index documents, caches and application data. Then aSCAN 0page can match nothing, nothing is deleted, and the loop makes zero progress.Measured against a real 3-primary Redis 8.4 cluster with 50k unrelated keys and 200 cache keys: 20,000
SCANcalls without terminating, 197 of 200 cache keys orphaned.Six migration
SCANloops had the same bug, and worse. They fed the reply cursor straight back intoclient.scan(cursor=...), so the second iteration passes a dict and redis-py raisesDataError: Invalid input of type: 'dict'. Verified against a real cluster. Unlike the cache hang this is a hard crash on the secondSCAN, not a silent under-count, and it fires for any cluster keyspace regardless of what the keys look like — arguably the wider blast radius of the two, even though it was only found while chasing the cache hang. Reachability, most to least exposed:validation._count_index_keysvalidaterunasync_validation._count_index_keysvalidaterunasync_planner._async_sample_keyscount=max(limit, 10)rarely fills the limit on the first pageplanner._sample_keyscount=max(limit, 1000)usually returns early, but not when the keyspace is smaller than the limitexecutor._enumerate_with_scanasync_executor._enumerate_with_scanWhat changed
All seven sites are plain "enumerate keys matching a pattern", so each collapses to redis-py's
scan_iter, which already drives each primary on its own cursor viatarget_nodes. Cluster cursor semantics move upstream where they belong. That also drops the sync/async duplication inclearand both# type: ignorecomments inbase.py(a third goes fromplanner.py); none are added back, andmypy ./redisvlis clean.redisvl/extensions/cache/base.pyclear/aclearbecome 9 lines each onscan_iter. Deletes batch atCLEAR_BATCH_SIZE = 500rather than oneDELperSCANpage.redisvl/migration/{planner,executor,validation}.py+ async twinsscan_iter. The sample sites gain a bonus: a generator lets the sample limit stop mid-page instead of draining the page first.redisvl/utils/utils.pyscan_by_patternwidened fromRedistoSyncRedisClient. It was already cluster-correct viascan_iter; only the annotation said otherwise.The
clear/acleardocstrings now document what callers actually get:SCANis not a point-in-time snapshot, so this is a best-effort sweep and not an atomic flush — concurrent writers may or may not be swept, duplicate pages are harmless becauseDELon a gone key is a no-op, and a mid-sweep failure is safe to retry because the operation is idempotent. Worth reviewing as prose, not just as comments:docs/api/cache.rstautodocs both caches with:inherited-members:, soBaseCache.clear's docstring is the published API reference text.Commit split
Five commits, each independently reviewable, in an order where every fix is separable from its tests:
fix(cache):the cache hang —base.pyplus its unit tests.fix(migration):the six migration loops — a different subsystem with a different failure mode, and reviewable without an opinion on the cache.test(cluster):repairs the pre-existing cluster cache tests, described below.fix(migration):anaclosingfollow-up on_async_sample_keys— see below.test:the mutation-driven trim of the cache suite and the new migration regression file.Commit 4 exists because
scan_iterintroduced a generator where awhileloop had none:_async_sample_keysreturns from insideasync foronce the sample limit is reached, and an async generator abandoned that way is not closed until loop shutdown. That surfaced asRuntimeWarning: coroutine method 'aclose' ... was never awaitedin the migration tests. Wrapped incontextlib.aclosing. The sync planner has the same early return but a plain generator is closed deterministically by refcounting on CPython and emits no warning, so it is left alone.Tests
Two pre-existing cluster tests had never executed.
test_embeddings_cache_cluster_sync/_asyncpassedtext=toEmbeddingsCache.set/aset, whose parameter iscontent. They raisedTypeErroron their first statement — which is part of why the cluster clear hang shipped. Both also calledclear()with no assertion afterward, so even once repaired they would not have caught it.The two new cluster regression tests (sync + async) therefore seed both unrelated keys and a cache larger than one
SCANpage, because of the accidental-progress effect above — 100 keys, what the existing test used, is exactly the size that passes either way. They bound the number ofSCANcallsclear()may issue, since a regression hangs rather than fails andpytest-timeoutis not installed, and they assert paging actually happened so the test cannot pass vacuously. Key counting goes throughscan_iter, neverKEYSorDBSIZE: those route to a single node on a cluster and silently report roughly one shard's worth (measured: 201 of 600).Unit coverage, at both ends.
tests/unit/test_migration_cluster_scan.py(4 tests) is new because reverting all six migration modules left 65/65 existing tests green — that fix had no regression coverage at all.tests/unit/test_cache_clear_cluster_cursor.pywent the other way: mutation testing showed that of 12 cache tests only 3 killed anything no other test killed and two killed nothing, so it is trimmed to 5 with a strictly larger kill set. Now that the cursor walk lives in redis-py, most of what the dropped tests asserted was upstream's behavior.test_deletes_are_batchedis kept deliberately — it is the only thing standing between us andclient.delete(*list(client.scan_iter(...))), which would OOM on a large cache — and now patchesCLEAR_BATCH_SIZEinstead of depending on its value.Both fake clients bind redis-py's real
scan_iterover their ownscan, so the tests drive the actual library loop rather than a reimplementation of it. The migration fake replies with per-node cursors and raisesDataErroron a dict cursor, so the old loop fails there exactly as it fails against a real cluster.1411 passed, 1 skippedon the unit suite;mypy,blackandisortclean.Not in scope
There is no CI signal for any of this.
--run-cluster-testsis passed by no workflow in.github/workflows/andmake testnever sets it, so all 20requires_clustertests — including the 2 added here — are skipped in CI. The unit tests above are what actually guards these fixes on every run; the cluster tests only ran locally. A CI job that passes the flag is the obvious follow-up, but it is a cost/runtime decision (6 containers per run) and is left to a separate PR.clear()usesDELwheredrop_keysusesUNLINK.SearchIndex.drop_keyswas deliberately moved toUNLINKin #616 (issue #600) to avoid stalling the server on bulk deletes.BaseCache.clearon a large cache has the same blocking profile and was not changed here, to keep this PR to the cursor bug. Worth its own issue.EmbeddingsCache.amsetsilently writes nothing on an async cluster client — it awaits the pipeline object returned by the queueing call, which drains the queue beforeexecute(). That is a separate bug being fixed separately. The async cluster test here deliberately seeds withasetinstead and says so in a comment, since this test is aboutaclear.Adjacent but untouched: #601 (
drop_keysdoes not validate cluster hash-tag co-location) is the same "written against standalone, wrong on cluster" family, if you want a theme for a follow-up sweep.