fix: escape glob metacharacters in SCAN match patterns - #702
Merged
Merged
Conversation
SCAN/KEYS MATCH patterns are glob-style, but several call sites interpolated a caller-supplied literal straight into one. A cache name, index prefix or route name containing *, ?, [ or \ therefore produced a pattern matching keys the caller did not own -- and on the destructive paths those keys were then deleted or rewritten. Verified on Redis 8.4.5: SemanticCache(name="cache[ab]").clear() deletes every entry under cachea: and cacheb: while leaving its own keys intact, silently and with no error. The same shape reaches delete_route_references and the migration executor's RENAME/DUMP-RESTORE-DEL path. Patterns are now built with match_pattern(), which escapes each literal segment and appends the trailing glob. Exposing a builder rather than a bare escaper is deliberate: escaping stops being a step a future call site can forget, which matters because no lint rule can express the invariant (ruff and flake8 are unconfigured, mypy is not strict, and patterns are consumed several call sites away from where they are built). Covered sites: BaseCache.clear/aclear, SemanticRouter._route_pattern, build_scan_match_patterns (and through it executor, async_executor, validation, async_validation), and both planners' key sampling. Escaping rather than rejecting such names, because rejecting is a breaking change that orphans keys: a deployment whose cache is already named "cache[ab]" could no longer construct the object to clean up after itself. Tests split by what can actually answer each question. That RedisVL emits the right pattern is an exact-string assertion, needing no matcher. That Redis then interprets it as intended is asked of a real server: the existing cache regression in test_embedcache.py, and a new migration and router prefix case in test_migration_v1.py. No glob matcher is reimplemented anywhere, so nothing can drift from Redis. Two pre-existing issues in the same neighbourhood are deliberately left alone: BaseCache.clear/aclear never advance a Mapping cursor on Redis Cluster (fixed separately by replacing the hand-rolled loop with scan_iter), and async_planner appends the key separator where the other three sites do not, so it samples a narrower key set than the index covers.
Two things that surprise anyone assembling a least-privilege credential, both measured on Redis 8.4.5 rather than inferred. A cache read is not read-only: with a TTL configured, every hit refreshes the matched entries' TTL, so the read path issues EXPIRE, which is in @Write and not @READ. A lookup-only credential therefore fails on a cache hit rather than on the write that populated the entry. Granting the command is only half of it -- ACL DRYRUN shows +expire under a read-only key pattern such as %R~llmcache:* is denied on the key, and that is the shape the Key permissions table presents as sufficient for querying. Placed in "Roles built from @READ and @Write", where a reader assembling exactly that role will be. ACL key patterns are also glob-style, matched by the same engine as SCAN MATCH, so they carry the same metacharacter trap: ~cache[ab]:* grants cachea:1 and cacheb:1 and denies the literal cache[ab]:1.
vishal-bala
marked this pull request as ready for review
August 25, 2026 14:04
limjoobin
approved these changes
Sep 9, 2026
limjoobin
left a comment
Contributor
There was a problem hiding this comment.
LGTM, this PR introduces a fix to escape glob-style patterns in caller-supplied names such that they are no longer interpolated in their raw form into SCAN MATCH patterns.
Resolves against #703, which replaced the hand-rolled SCAN cursor loops with scan_iter to fix the cluster hang and cross-slot DEL. Both sides are kept: main's scan_iter iteration, with the caller-supplied prefix escaped through match_pattern rather than interpolated. - BaseCache.clear/aclear: main's scan_iter batching, match_pattern(prefix) - MigrationPlanner._sample_keys and the async twin: main's scan_iter (and its aclosing wrapper), with the local renamed to scan_match so it no longer shadows the imported match_pattern helper - test_scan_pattern_escaping: the recording clients now capture scan_iter instead of scan, matching what the code under test calls Audited every match= site after the merge: all nine are either built by match_pattern or come from build_scan_match_patterns, which escapes. None of the four build_scan_match_patterns consumers imports match_pattern, so their loop variable of that name shadows nothing.
04_vectorizers.ipynb cell 19 constructs a VoyageAIVectorizer for voyage-context-4 and calls embed_many, which reaches the live API and times out at nbval's 60s limit. The cell arrived unguarded in #692 and fails on main; this branch only surfaced it by merging main in. The notebook already marks eight other live-API cells with # NBVAL_SKIP, so this follows the established convention: notebook validation must not call external providers, and the API-keyed coverage for these models lives in the integration job (tests/integration/test_vectorizers.py exercises voyage-context-4 behind --run-api-tests). Guards only the failing cell. The basic VoyageAI cell above it passes on the same key and is left alone.
|
🚀 PR was released in |
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.
What's wrong
BaseCache.clear()built itsSCAN MATCHpattern by interpolating the cache name directly. Per the SCAN docs that pattern is glob-style, and the name is caller-supplied and unvalidated — so a name containing*,?,[or\produced a pattern matching keys the cache does not own, andclear()then deleted them.Reproduced on Redis 8.4.5:
So
SemanticCache(name="cache[ab]").clear()wipes two unrelated caches and leaves its own entries intact. It is silent — no error, and the caller believes their cache was cleared.This is data loss on a destructive path, not a matching quirk: low likelihood (unusual names) but high consequence, with a blast radius covering other keys in the same keyspace. In a multi-tenant naming scheme such as
EmbeddingsCache(name=f"cache:{tenant_id}")a tenant registeringx[ab]destroys other tenants' entries.Grepping
match=across the repo found the same shape in three more places, two of them also destructive:BaseCache.clear/aclearSemanticRouter._route_patterndelete_route_referencesbuild_scan_match_patternsRENAME/DUMP-RESTORE-DELpathMigrationPlanner._sample_keysand the async twinThe fix
A
match_pattern(*segments)builder inredisvl/utils/utils.pyescapes each literal segment and appends the trailing glob. All four sites now build patterns through it;build_scan_match_patternsis itself a chokepoint, so fixing it coversexecutor,async_executor,validationandasync_validationat once.Exposing a builder rather than a bare
escape()is deliberate. Escaping at the call site is a step the next contributor can forget, and no lint rule can express the invariant here: ruff and flake8 are unconfigured, mypy is not strict, and patterns are consumed several call sites away from where they are built. A builder makes the omission unrepresentable — there is no other way to make a pattern.The escape set is
\,*,?,[.],^and-are deliberately absent: they are only meaningful inside a[...]class, which can never open once[is escaped. Confirmed empirically —cache\[ab]:*andcache\[ab\]:*return identical results.Escaping rather than rejecting such names. Rejecting at construction would be a breaking change that orphans keys: a deployment whose cache is already named
cache[ab]could no longer construct the object to clean up after itself. If maintainers prefer validation, it wants a deprecation cycle rather than a straight raise.Testing
Two questions, separated by what can actually answer each:
tests/unit/test_scan_pattern_escaping.pycovers all four sites hermetically, includingaclearand both planners.tests/integration/test_embedcache.pyadds the cache regression (three caches colliding under an unescaped glob; clear one, assert the others survive), andtests/integration/test_migration_v1.pyadds the migration and router prefix case.No glob matcher is reimplemented anywhere, so nothing can drift from Redis. Every new test was checked to fail without the fix.
Verified: mypy clean (119 files), 1421 unit tests pass, integration green across embedcache and migration.
Deliberately out of scope
Four pre-existing issues surfaced while working in this code. None is caused or worsened by this change, and each wants its own PR:
BaseCache.clear()has a containment bug escaping cannot fix._get_prefix()isf"{name}:", so a cache namedfooclearingfoo:*deletes the entries of a cache namedfoo:bar— ordinary names, no metacharacters, same blast radius.SearchIndex.clear()already does this correctly by deleting the doc idsFT.SEARCHreturns;SemanticCacheshould route through it, andEmbeddingsCache(which has no index) should verify each scanned key's prefix segment before deleting.clear()/aclear()never advance aMappingcursor on Redis Cluster, so they re-scan page 1 forever. Notecursor = cursor_intis not the fix — a dict cannot be passed back as a SCAN cursor; per-node iteration viascan_iteris. Already addressed on a separate branch.async_plannerappends the key separator where the other three sites do not, so it samples a narrower key set than the index covers —FT.CREATE PREFIXis a literal string-prefix match (verified:PREFIX 1 zzglobgl[ab]indexes only its own keys), which makes the sync planner the correct one. Flagged in a comment here rather than changed, since fixing it is a behaviour change. The natural follow-up consolidates all three pattern builders and dropsbuild_scan_match_patterns' unusedkey_separatorparameter.delete_route_referencessplits with a hardcoded":"rather thankey_separator, and raises afterdrop_keyshas already deleted.Note
High Risk
Changes destructive SCAN-based clear, route deletion, and migration key enumeration; the fix prevents cross-tenant/keyspace deletes but any remaining pattern bugs would still cause data loss.
Overview
Fixes a silent data-loss bug where caller-supplied cache names, route names, or index prefixes containing Redis glob metacharacters (
*,?,[,\) were interpolated intoSCAN MATCHpatterns unescaped, so destructive operations could target the wrong keys (e.g.clear()oncache[ab]deletingcachea/cachebwhile leaving its own entries).Introduces
match_pattern(*segments)inredisvl/utils/utils.pyto escape literal segments and append*, and routes all pattern construction through it:BaseCache.clear/aclear,SemanticRouter._route_pattern,build_scan_match_patterns, and sync/async migration key sampling. Unit and integration tests assert emitted patterns and live Redis behavior; the async planner only renames a local variable and documents a pre-existing separator sampling divergence.Docs:
installation.mdadds cache TTL refresh (EXPIREon hits) ACL guidance and notes that ACL key patterns use the same glob rules asSCAN; the Voyage contextualized notebook cell gets# NBVAL_SKIPfor CI.Reviewed by Cursor Bugbot for commit 0886e9a. Bugbot is set up for automated code reviews on this repo. Configure here.