Repository navigation
fix(index,router): keep SemanticRouter in step with the keyspace when a cluster UNLINK fails - #748
Open
vishal-bala wants to merge 6 commits into
Open
vishal-bala wants to merge 6 commits into
vishal-bala wants to merge 6 commits into
Conversation
… keyspace On Redis Cluster `_unlink_batch` unlinks keys one at a time and logs a per-key `RedisError` instead of raising, so `drop_keys` could report fewer deletions than keys requested with no way to tell which ones failed. Callers that mirror the keyspace in their own state then diverged from Redis. `SemanticRouter.delete_route_references` was the concrete case: it dropped the keys, then removed *every* corresponding reference from its in-memory routes and persisted that config. A failed unlink left the hash in the index — still matched by the router — while the config claimed the reference was gone. `remove_route` had the same problem for a whole route. - `_unlink_batch` now returns `(unlinked, failed_keys)`; new `_drop_keys` helper aggregates both across batches. `drop_keys` still returns just the count, so the public contract is unchanged. - `delete_route_references` skips the in-memory removal for keys that failed to unlink and warns; `remove_route` keeps the route with only the references whose keys survived instead of dropping it. - `drop_by_filter` now stops with `completed=False` when a whole batch fails to unlink. It re-queries from offset 0 each round and its runaway backstop counts deletions, so a key that never unlinks kept coming back while the counter stayed put — an infinite loop on cluster. Covers both the sync and async index paths.
Follow-up to the previous commit, addressing findings from code-review,
security, system-design, testing-strategy and documentation passes.
Correctness
- Catch RedisClusterException alongside RedisError in the per-key delete/unlink
loops. SlotNotCoveredError derives from Exception, not RedisError, so a routine
resharding/failover error escaped the loop entirely — aborting the batch and
skipping the router's reconciliation, the exact divergence this was meant to
fix. Verified against the installed redis-py hierarchy.
- clear() had the identical infinite loop just fixed in drop_by_filter, via
_delete_batch. Confirmed by probe: it spins forever when every DEL fails. On
cluster that turns index.delete(drop=True), SemanticRouter.clear() and
MessageHistory.clear() into hangs. _delete_batch now reports failed keys too,
and both loops stop when a batch cannot make progress.
- The stall guard no longer bails on the first fruitless batch: another client may
have deleted exactly those documents, in which case they leave the index and the
run should continue. Keys are accumulated instead and the run stops only once a
fruitless batch brings back nothing new — terminating, without abandoning
batches a transient race would have cost.
- delete_route_references no longer derives the route name from key.split(":")
positionally. It reads route_name from the hash it already fetches, so a route
name containing the key separator no longer raises *after* the keys are gone,
leaving the config advertising references whose hashes are deleted. Nothing
between the unlink and the config write can raise now.
- remove_route no longer collapses duplicate references when a key fails.
API
- delete_route_references and remove_route raise PartialDeletionError after
reconciling and persisting, so a partial delete cannot pass unnoticed and a
retry resumes cleanly. Previously remove_route returned None and logged.
- drop_keys keeps its int contract; its docstring no longer points users at a
private method.
Docs
- Corrected the per-key rationale: redis-py already splits a variadic cross-slot
UNLINK by slot, so the loop is for failure granularity, not cross-slot safety.
The old wording invited someone to "optimize" away the attribution this relies on.
- Export and document BulkResult; it is the return type of four public methods and
was neither importable nor rendered. Refreshed the stale completed description.
- Log keys with %r so a newline in a user-supplied key cannot forge a log line.
`delete_route_references(route_name=...)` normally leaves a route with no references, and `_update_router_state()` persists it. But `Route` validates `references` non-empty on the way *in*, so the config it just wrote could no longer be read back: `SemanticRouter.from_existing` failed with `ValidationError: References must not be empty`, and the router could not be reloaded or repaired through the API. Nothing caught it because no test reloaded such a config. A route with no references cannot match anything, so deleting all of them removes the route -- the same reconciliation `remove_route` already does when all of its keys unlink. Routes whose keys *failed* to unlink keep their references and are unaffected: those hashes are still in the index. Tests: a hermetic pair asserting every persisted route round-trips through `Route`, plus an integration test that empties a route and reloads it with `from_existing`. Two existing tests asserted the unloadable `references: []` shape and now assert the route is gone.
…anges Rebasing fix/cluster-partial-delete-divergence onto main pulled in main's independent clear() rework (a CountQuery-based backstop instead of info()["num_docs"], to avoid needing an @search-only credential). This branch's stall-detection fix (_stalled/_warn_removed_nothing, matching drop_by_filter) was merged on top of that rather than main's own paged-past-the-backstop behavior, since the latter has the same concurrent-deletion skip hazard called out for drop_by_filter. Two tests asserted the old, now-superseded mechanics: - test_clear_terminates_when_no_key_can_be_deleted expected the "paged past its runaway backstop" log line; a stalled batch now stops the run via "stopped after a batch removed nothing" instead. - test_clear_stops_instead_of_looping / test_async_clear_stops_instead_of_looping mocked index.info() for the old num_docs-based backstop; clear() now reads the count via query(CountQuery(...)), so the mock moves there, matching the existing drop_by_filter tests' pattern.
clear() on main already terminates when a page cannot be deleted: it pages past that page and returns to offset 0 after the next successful one, so it still clears deletable documents that sort behind an undeletable page. The stop-on-stall rule this branch had put in its place gave that case up, so clear() goes back to main's loop and only unpacks the new (count, failed_keys) return from _delete_batch. The stall helpers now serve drop_by_filter alone, and the clear() tests return to main's expectations; the two duplicate clear() termination tests go.
vishal-bala
force-pushed
the
fix/cluster-partial-delete-divergence
branch
from
October 5, 2026 13:27
54dde83 to
7d1d500
Compare
vishal-bala
marked this pull request as ready for review
October 7, 2026 15:29
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
❌ 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 7d1d500. Configure here.
…erence remove_route raised PartialDeletionError without the unlink count, so `deleted` read 0 even when some reference keys were removed. delete_route_references removed one list entry per deleted key, but duplicate references share one hash: after the unlink, the extra copies stayed in the route and the persisted config listed a reference whose hash was gone. Every copy is now removed.
This branch has not been deployed
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.

Motivation
On Redis Cluster,
SemanticRoutercould persist a config claiming a reference was deleted while its hash was still indexed and still matched.SearchIndexdeletes cluster keys one at a time and logs per-key failures rather than raising, sodrop_keysreturned a short count with no record of which keys survived, anddelete_route_referencesandremove_routeremoved every reference regardless. Two defects sit on the same path: the per-key handler missedSlotNotCoveredError, which derives fromRedisClusterExceptionrather thanRedisError(verified on redis-py 8.1.0), anddrop_by_filternever returned when a key would not unlink.Changes
Batch deletes report what they could not remove
_unlink_batchand_delete_batchreturn(count, failed_keys), aggregated across batches by a private_drop_keys, and the per-key handler also catchesRedisClusterException.drop_keys,drop_documents,clearanddrop_by_filterkeep their signatures and return types.The router reconciles, persists, then raises
delete_route_referencesandremove_routeremove only the references whose keys actually went away, persist the config, and then raise the newPartialDeletionError. It carriesfailed_keysanddeleted, so a retry targets exactly what is left. The route name now comes from theroute_namefield stored in each hash rather than from splitting the key, which broke on names containing the separator.Deleting a route's last reference removes the route
delete_route_referencescould persist a route withreferences: [], whichRouterejects on load, soSemanticRouter.from_existingfailed withValidationError: References must not be empty. Such a route is now removed, asremove_routealready does.drop_by_filterstops when a batch makes no progressdrop_by_filterstops once a fruitless batch returns only keys that have already failed, reportscompleted=False, and logs whether the deletes failed or the index lists keys that no longer exist. A single fruitless batch does not end the run, because another client may have deleted exactly those documents in the meantime.Secondary changes
BulkResultis exported fromredisvl.index, and it andPartialDeletionErrorare documented in the API reference.%r, so a newline in a key cannot forge a log line.tests/unit/test_unlink_failure_reconciliation.py, becauserequires_clustertests are skipped everywhere including CI, plus an integration test that empties a route and reloads it withfrom_existing.Release Notes
On Redis Cluster,
SemanticRouter.remove_routeanddelete_route_referencesnow raisePartialDeletionErrorwhen some reference keys cannot be deleted, instead of returning normally. Deleting a route's last reference now removes the route, so code that readsto_dict()["routes"]by position should look routes up byname.Note
Medium Risk
Changes deletion semantics on Redis Cluster (logged partial failures, new PartialDeletionError from router APIs) and drop_by_filter completion behavior; public drop_keys signatures unchanged but callers must handle new router exceptions.
Overview
Fixes Redis Cluster partial-delete behavior so callers that mirror keyspace state (especially
SemanticRouter) cannot silently diverge from what is still indexed.Index layer: Per-key
DELETE/UNLINKon cluster now returns(count, failed_keys)(via_drop_keys), catchesRedisClusterException(e.g.SlotNotCoveredError) alongsideRedisError, and documents thatdrop_keysmay return a short count with only logs—no exception.drop_by_filterdetects batches that remove nothing, setsBulkResult.completed=False, and logs whether deletes failed vs. stale index entries.BulkResultis exported fromredisvl.index.Semantic router:
remove_routeanddelete_route_referencesunlink via_drop_keys, reconcile in-memory/persisted references to keys that actually disappeared, then raisePartialDeletionError(failed_keys,deleted) if any remain. Route names are read from stored hashes (not key splitting). Deleting a route’s last reference drops the route so configs stay loadable byfrom_existing.Docs and unit/integration tests cover the new exception, stall behavior, and router reconciliation.
Reviewed by Cursor Bugbot for commit 67a6128. Bugbot is set up for automated code reviews on this repo. Configure here.