Skip to content

fix: check index existence with FT.INFO instead of listing every index - #672

Merged
vishal-bala merged 1 commit into
mainfrom
fix/index-exists-without-ft-list
Aug 7, 2026
Merged

fix: check index existence with FT.INFO instead of listing every index#672
vishal-bala merged 1 commit into
mainfrom
fix/index-exists-without-ft-list

Conversation

@vishal-bala

@vishal-bala vishal-bala commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

exists() asks a question about one index. It answered that question with FT._LIST, which returns the name of every index in the database, and then checked whether ours was in the list. That works — but it answers a narrow question with a broad command, and Redis prices the two differently.

FT._LIST is tagged @admin, alongside @search and @slow. FT.INFO, which reports on a single named index, is tagged @search only. ACL rules apply left to right, so a credential built the way least-privilege guidance recommends — grant what's needed, then take back administrative commands — loses FT._LIST while keeping FT.INFO:

+@all -@admin      →  FT._LIST denied,  FT.INFO allowed
+@search -@admin   →  FT._LIST denied,  FT.INFO allowed

create() checks exists() before it does anything else, and SemanticCache, SemanticMessageHistory, MessageHistory, and SemanticRouter all call create() while being constructed. So this wasn't a degraded mode users could route around — it was a hard failure on the first Redis call:

NoPermissionError: User <name> has no permissions to run the 'FT._LIST' command

This change points exists() at FT.INFO, reusing the existing _info() helper so it inherits that helper's cluster routing. listall(), which genuinely does need to enumerate the database, still uses FT._LIST.

How narrow is this? Narrower than it may look, and worth stating plainly so the fix isn't over-read. FT._LIST does not require @admin — granting +@search alone permits it. Only rules that explicitly subtract @admin after granting search were affected.

Aliases, and why there's a name comparison. FT.INFO accepts an index alias and answers for the index it points at. Left alone, that would make exists() return True for an alias — and because create(overwrite=True, drop=True) acts on that answer by issuing FT.DROPINDEX <name> DD, it would delete the aliased index and its documents. exists() therefore compares the index_name that FT.INFO returns against the schema's own name. delete() has the same hazard independently of this change and now carries a docstring warning; guarding it would cost a round-trip on every delete, so it's left as a follow-up.

Classifying "missing". Redis Search reports an absent index as an ordinary error reply, with wording that has changed between versions (Unknown index name, <name>: no such index, and SEARCH_INDEX_NOT_FOUND Index not found: as of 8.8). All known wordings now live in one shared predicate in redisvl/exceptions.py, replacing a duplicated copy in the MCP server. It reads __cause__ rather than the wrapping RedisSearchError, whose message interpolates the index name — otherwise an index named after one of the wordings would make an unrelated failure look like an absence. Anything not recognised as a missing index is re-raised rather than reported as absence.

Behaviour changes

  • exists() raises RedisSearchError where it previously let raw redis-py exceptions escape. This makes create() uniform: it already wrapped every other Redis failure that way, and the existence check was the one path that leaked. Callers catching redis.exceptions.ResponseError around create() or an extension constructor should catch RedisSearchError and read __cause__.
  • A credential whose key patterns don't cover the index prefix now gets a permission error instead of True, because FT.INFO is key-scoped and FT._LIST is not. Redis applies the same rule to FT.SEARCH, so such a credential could not have queried the index anyway.

Tests

  • tests/unit/test_index_exists.py — 13 cases covering the three branches for both twins, plus cluster target_nodes routing. They drive the real _info() rather than stubbing it, so the not-found match is exercised through that helper's message rewrapping. Mutation-checked: narrowing the predicate to one wording, replacing the except with a bare return False, deleting the index_name comparison, bypassing _info(), narrowing _info()'s except, and reading the wrapper instead of __cause__ each fail specific cases.
  • Two integration tests. One creates an ACL user with +@all -@admin and pins the premise with pytest.raises(NoPermissionError) on FT._LIST, so it can't silently go vacuous if Redis ever recategorises the command — it fails against the previous implementation with the original error. The other covers alias resolution. Verified on 8.2.7, 8.4.4, and 8.8.0.
  • Four pre-existing assertions moved from a raw FT._LIST call to listall(), which had no direct coverage before.
  • test_no_proactive_module_checks.py updated for the exception-type change above; it now also asserts the redis-py exception is chained.

Docs

New "Redis permissions (ACLs)" section in docs/user_guide/installation.md with an operation-to-command table, a subsection on how Redis Cloud and Redis Software differ (both manage ACLs through their own control plane rather than ACL SETUSER), and a note that RedisVL's CLIENT SETINFO/ECHO identification step also needs permission. docs/api/exceptions.rst gains a section on telling a missing index apart from other failures. docs/api/cli.rst notes which commands FT._LIST gates.

Not in scope

Sync listall() still lacks the cluster branch its async twin has. delete()'s alias hazard is documented but not guarded. migration/utils.py and migration/async_executor.py still have bare except Exception: return False blocks that this predicate would improve. Separately, RedisConnectionFactory.get_redis_connection cannot open a connection under an ACL that denies both CLIENT SETINFO and ECHO, because the fallback at connection.py:550 is itself unguarded and NoPermissionError subclasses ResponseError — pre-existing, and filed separately.


Note

Medium Risk
Changes core index lifecycle (exists() drives create() and several constructors) with new error semantics and alias handling; behavior shifts for restricted ACLs and key-scoped FT.INFO, but scope is focused and well tested.

Overview
exists() on sync and async search indexes no longer calls FT._LIST (membership in the full index list). It uses FT.INFO via _info(), so least-privilege ACLs such as +@search -@admin can run create() and extension constructors that depend on exists().

Missing-index detection is centralized in _is_missing_index_error() in redisvl/exceptions.py (version-specific Redis error text, matched on __cause__). exists() returns False only for those replies; permission and connection failures raise RedisSearchError. FT.INFO on an alias is treated as non-existent by comparing index_name from the response to the schema name, avoiding create(overwrite=True) dropping the aliased index.

listall() still uses FT._LIST. MCP reuses the shared missing-index helper. Docs add Redis ACL guidance, exception handling notes, and CLI notes for listall / migration discovery. Tests cover unit branches, ACL integration, and alias behavior.

Reviewed by Cursor Bugbot for commit fb00bb1. Bugbot is set up for automated code reviews on this repo. Configure here.

…work

exists() asked a question about one index but answered it with FT._LIST,
which returns every index name in the database and which Redis tags @admin.
ACL rules apply left to right, so a credential built the way least-privilege
guidance recommends -- grant what is needed, then take back administrative
commands -- lost the command. Because create() checks exists() first, index
creation and the construction of SemanticCache, SemanticMessageHistory,
MessageHistory and SemanticRouter failed outright rather than degrading.

exists() now runs FT.INFO, which Redis tags @search only, through the
existing _info() helper so it inherits that helper's cluster routing. It
returns False only for a recognised missing-index reply and re-raises
anything else, so a permission or connection failure is never reported as an
absent index. FT.INFO answers for an alias's target, so the resolved
index_name is compared against the schema name: reporting an alias as
existing would let create(overwrite=True) drop the index it points at.

listall() still uses FT._LIST, since enumerating the database is what it is
for. The missing-index wordings, which differ across Redis versions, now
live in a single shared predicate in redisvl/exceptions.py, replacing a
duplicated copy in the MCP server.

Two behaviour changes:

- exists() raises RedisSearchError where it previously let raw redis-py
  exceptions escape. This makes create() uniform, since it already wrapped
  every other Redis failure that way; the existence check was the one path
  that leaked.
- A credential whose key patterns do not cover the index prefix now gets a
  permission error instead of True, because FT.INFO is key-scoped and
  FT._LIST is not. Redis applies the same rule to FT.SEARCH, so such a
  credential could not have queried the index anyway.
@vishal-bala vishal-bala changed the title fix: check index existence with FT.INFO so ACLs that subtract @admin work fix: check index existence with FT.INFO instead of listing every index Aug 7, 2026
@vishal-bala vishal-bala added the auto:patch Increment the patch version when merged label Aug 7, 2026
@vishal-bala
vishal-bala marked this pull request as ready for review August 7, 2026 13:25
@vishal-bala vishal-bala added the auto:release Create a release when this PR is merged label Aug 7, 2026
@vishal-bala
vishal-bala merged commit af1e460 into main Aug 7, 2026
56 checks passed
@vishal-bala
vishal-bala deleted the fix/index-exists-without-ft-list branch August 7, 2026 15:28
@applied-ai-release-bot

Copy link
Copy Markdown

🚀 PR was released in v0.25.1 🚀

@applied-ai-release-bot applied-ai-release-bot Bot added the released This issue/pull request has been released. label Aug 7, 2026
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 auto:release Create a release when this PR is merged released This issue/pull request has been released.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants