Skip to content

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
mainfrom
fix/cluster-partial-delete-divergence
Open

vishal-bala wants to merge 6 commits into
mainfrom
fix/cluster-partial-delete-divergence

Conversation

@vishal-bala

@vishal-bala vishal-bala commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

Motivation

On Redis Cluster, SemanticRouter could persist a config claiming a reference was deleted while its hash was still indexed and still matched. SearchIndex deletes cluster keys one at a time and logs per-key failures rather than raising, so drop_keys returned a short count with no record of which keys survived, and delete_route_references and remove_route removed every reference regardless. Two defects sit on the same path: the per-key handler missed SlotNotCoveredError, which derives from RedisClusterException rather than RedisError (verified on redis-py 8.1.0), and drop_by_filter never returned when a key would not unlink.

Changes

Batch deletes report what they could not remove

_unlink_batch and _delete_batch return (count, failed_keys), aggregated across batches by a private _drop_keys, and the per-key handler also catches RedisClusterException. drop_keys, drop_documents, clear and drop_by_filter keep their signatures and return types.

The router reconciles, persists, then raises

delete_route_references and remove_route remove only the references whose keys actually went away, persist the config, and then raise the new PartialDeletionError. It carries failed_keys and deleted, so a retry targets exactly what is left. The route name now comes from the route_name field 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_references could persist a route with references: [], which Route rejects on load, so SemanticRouter.from_existing failed with ValidationError: References must not be empty. Such a route is now removed, as remove_route already does.

drop_by_filter stops when a batch makes no progress

drop_by_filter stops once a fruitless batch returns only keys that have already failed, reports completed=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

  • BulkResult is exported from redisvl.index, and it and PartialDeletionError are documented in the API reference.
  • Per-key failures are logged with %r, so a newline in a key cannot forge a log line.
  • Hermetic unit coverage in tests/unit/test_unlink_failure_reconciliation.py, because requires_cluster tests are skipped everywhere including CI, plus an integration test that empties a route and reloads it with from_existing.

Release Notes

On Redis Cluster, SemanticRouter.remove_route and delete_route_references now raise PartialDeletionError when some reference keys cannot be deleted, instead of returning normally. Deleting a route's last reference now removes the route, so code that reads to_dict()["routes"] by position should look routes up by name.


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/UNLINK on cluster now returns (count, failed_keys) (via _drop_keys), catches RedisClusterException (e.g. SlotNotCoveredError) alongside RedisError, and documents that drop_keys may return a short count with only logs—no exception. drop_by_filter detects batches that remove nothing, sets BulkResult.completed=False, and logs whether deletes failed vs. stale index entries. BulkResult is exported from redisvl.index.

Semantic router: remove_route and delete_route_references unlink via _drop_keys, reconcile in-memory/persisted references to keys that actually disappeared, then raise PartialDeletionError (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 by from_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.

… 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
vishal-bala force-pushed the fix/cluster-partial-delete-divergence branch from 54dde83 to 7d1d500 Compare October 5, 2026 13:27
@vishal-bala
vishal-bala marked this pull request as ready for review October 7, 2026 15:29

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

❌ 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.

Comment thread redisvl/extensions/router/semantic.py
Comment thread redisvl/extensions/router/semantic.py
…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.
@vishal-bala vishal-bala added the auto:patch Increment the patch version when merged label Oct 8, 2026
@vishal-bala
vishal-bala requested a review from limjoobin October 12, 2026 15:28

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto:patch Increment the patch version when merged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant