fix: permit clear() when create_index=False, and drop its FT.INFO dependency - #704
Draft
vishal-bala wants to merge 4 commits into
Draft
fix: permit clear() when create_index=False, and drop its FT.INFO dependency#704vishal-bala wants to merge 4 commits into
vishal-bala wants to merge 4 commits into
Conversation
SearchIndex.clear() read FT.INFO's num_docs to size a runaway backstop for its delete loop. FT.INFO carries only the @search ACL category, so that one call denied the whole method to a `+@READ +@write` credential -- even though the FT.SEARCH and DEL the sweep is actually made of are both granted. Verified on Redis 8.4.5: FT.INFO is denied to `+@READ +@Write -@dangerous`, FT.SEARCH is not. The backstop now comes from a CountQuery, which is what drop_by_filter has used all along and is also FT.SEARCH. Measured under that credential: clear() removed 1500 of 1500 documents and the index survived. A page whose keys cannot all be deleted now advances the paging offset instead of re-reading the same head, so a blockage no longer hides the documents behind it. Measured with 600 of 2000 keys undeletable: the sweep removes exactly the other 1400 and returns. Termination is also verified against a writer inserting 1.04M keys mid-sweep, against every delete failing, and against a second concurrent clear(). Two stubs in test_error_handling.py patched SearchIndex.info for a call clear() no longer makes, so they passed while asserting nothing. They now raise, which makes re-introducing FT.INFO into either twin fail loudly. The new test drives the cluster branch of _delete_batch, the only path that reaches the backstop, and carries its own query cap because pytest-timeout is not installed and a regression would otherwise hang the suite rather than fail it.
create_index=False refused both delete() and clear(), justified as protecting an externally managed index from being destroyed through an attach-only instance. That holds for delete(), which calls _index.delete(drop=True). It does not hold for clear(), which removes entries and leaves the index standing, so a caller attached to a platform-provisioned index had no public way to invalidate it. The guard was not a boundary either. drop(keys=...) is public and unguarded under the same flag, so the whole cache was always reachable through public API by enumerating keys first -- which is what downstream callers did, taking RedisVL's key-layout knowledge with them. clear() and aclear() are now unguarded on all four extensions. delete() and adelete() still raise. EXTERNAL_INDEX_LIFECYCLE_CONFLICT becomes EXTERNAL_INDEX_DROP_CONFLICT, since it now guards exactly one operation. No alias is kept: the message text changed too, so the old name preserved nothing for anyone matching on it. Neither clear() shape verifies the prefix against the live index under this flag, and they fail silently in opposite directions -- prefix-based clearing reaches keys the index never covered, index-based clearing reaches documents this instance never wrote. Both are documented on the methods. remove_route() gains the stored-config caveat add_route() already carried, because clear()'s new docstring points readers at it. Assertions compare against the constant rather than a loose regex on its message, which is what let the rename above pass silently at first. The new MessageHistory integration test proves the FT.INFO removal end to end against a real restricted credential in about two seconds and without a vectorizer; the pre-existing ACL coverage exercises SemanticCache, whose prefix-based clear() never needed it.
The ACL guide said an attach-only extension refuses index-wide delete() and clear() alike. clear() is now permitted, and the operation table listed index.clear() under FT.INFO, which it no longer calls. The replacement section leads with what the flag permits rather than what it refuses, and states the failure mode each clear() shape has: prefix-based clearing reaches keys the index never covered and misses served entries when the live prefix differs, while index-based clearing reaches documents this instance never wrote. Diagnosing either needs the FT.INFO such a credential lacks, so the guidance is to get the index's prefixes from whoever provisions it rather than infer them from a successful query.
The three commits before this added roughly two and a half lines of prose per line of code, which is bloat that has to be maintained. This keeps the split deliberate: a docstring carries only what a caller needs to call the method correctly, the reasoning lives in the commit message, and the detailed hazard discussion lives once in the ACL guide with the docstrings pointing at it. Cut, specifically: the rationale for choosing CountQuery over FT.INFO, which belongs in e1f2f06's message and not on every reader's screen; measured trial counts, which pin a Redis patch version and a race outcome and will age; the 26-line warning on SemanticCache.clear() that restated the guide; five near-identical Raises blocks; and the duplicated hazard note across the two message-history classes. No executable code changes. Verified by comparing the AST of every touched module against its parent with docstrings stripped: all nine identical.
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
An application whose Redis ACL is assembled from
+@read +@writeis deniedFT.INFOandFT.CREATEtogether, because neither command belongs to either category. Such an application cannot create an index and cannot ask whether one exists, so the index is provisioned out of band by whoever holds a privileged credential.create_index=Falseexists for that deployment: it skips the existence check, the schema comparison and creation, and issues no index command at all.The change that added the flag also made
delete()andclear()refuse to run under it, documented as protecting an externally managed index from being destroyed through an attach-only instance. That reasoning holds fordelete(), which calls_index.delete(drop=True). It does not hold forclear(), which removes entries and leaves the index in place. Grouping the two conflated "delete every entry" with "delete the index", and left a caller attached to a platform-provisioned index with no public way to invalidate a stale cache, which is the one runtime operation such a credential still needs.The guard was not an enforcement boundary in any case.
drop(keys=...)is public and unguarded under the same flag, so the entire cache was always reachable through public API by enumerating the keys first. That is what downstream callers did, taking RedisVL's key-layout knowledge with them and reimplementing the keyspace walk against private attributes.Changes
Entry removal is permitted; dropping the index is not
clear()andaclear()no longer raise undercreate_index=FalseonSemanticCache,MessageHistory,SemanticMessageHistoryorSemanticRouter.delete()andadelete()still do.EXTERNAL_INDEX_LIFECYCLE_CONFLICTis renamed toEXTERNAL_INDEX_DROP_CONFLICT, since it now guards exactly one operation, and its message points the caller atclear()rather than at the provisioning path alone. No alias is kept for the old name: the message text changed as well, so anyone matching on it is affected either way.SearchIndex.clear() no longer reads FT.INFO
Three of the four extension
clear()methods delegate toSearchIndex.clear(), which readFT.INFO'snum_docsto size a runaway backstop for its delete loop.FT.INFOcarries only the@searchcategory, so that single call, made for a loop bound, denied the whole method to the credential this flag exists to serve. The commands the sweep is actually made of are both granted:FT.SEARCHis@read @search, andDELis@keyspace @write @slow. Measured on Redis 8.4.5 against a user created ason ~* &* +@read +@write -@dangerous,FT.INFOraises andFT.SEARCHdoes not. Without this half, a uniform-looking fix would have meant one extension working and three failing on a permission error.The backstop now comes from a
CountQuery, whichdrop_by_filterhas used for the same purpose all along and which is alsoFT.SEARCH. Under that restricted credential,clear()removed 1500 of 1500 documents and the index survived.A page whose keys cannot all be deleted now advances the paging offset rather than re-reading the same head, so a blockage no longer hides the documents behind it. Measured with 600 of 2000 keys made undeletable, the sweep removes exactly the other 1400 and returns. Termination was also checked against a writer inserting 1.04 million keys mid-sweep, against every delete failing, and against a second
clear()running concurrently on the same index.The failure modes are documented rather than removed
Neither shape of
clear()verifies its key selection against the live index, because verifying it needs theFT.INFOthis flag exists to avoid. They fail silently in opposite directions, and both are now stated on the methods and in the ACL guide.Prefix-based clearing, which is
SemanticCache, deletes every key under{name}:. That reaches another writer's entries and any unrelated application data sharing the namespace root. It also misses: if the live index covers a different prefix, or is an alias onto one,clear()deletes only what this instance itself wrote, returns successfully, and leaves the cache serving the stale hits it was called to invalidate.Index-based clearing, which is the other three, deletes whatever the live index covers. Against a differently-prefixed, multi-
PREFIXor aliased index that means another application's documents, while this instance's own unindexed entries stay behind.Separately, and unchanged by this PR,
clear()races an in-progress background index scan, since it can only delete what the index currently returns. It drains the documents indexed so far, sees an empty page while unindexed keys remain, and stops, reporting what it deleted as if it were done. Measured on Redis 8.4.6 over 20,000 hashes indexed immediately after loading, two runs cleared 125 and 57; against a fully indexed copy of the same data both cleared all 20,000. Thenum_docsbound this replaces cleared 57 on the same setup, so the early stop is long-standing rather than a consequence of the new bound. It is now stated on the method.Smaller changes
remove_route()gains the stored-config caveatadd_route()already carried, becauseclear()'s new docstring points readers at it.tests/unit/test_error_handling.pypatchedSearchIndex.infofor a callclear()no longer makes, so they passed while asserting nothing. They now raise, which makes re-introducingFT.INFOinto either twin fail loudly._delete_batch, the only path that reaches the backstop, and carries its own query cap:pytest-timeoutis not installed, so a regression would otherwise hang the suite rather than fail it.MessageHistoryintegration test proves theFT.INFOremoval end to end against a real restricted credential in about two seconds and with no vectorizer. The pre-existing ACL coverage exercisesSemanticCache, whose prefix-basedclear()never needed this change.Notes
This is a behaviour change, not only a permissions fix. A call that raised
ValueErrornow performs a destructive operation, and for theSearchIndex.clear()half the loop bound changes on the defaultcreate_index=Truepath too. Callers who wroteexcept ValueErroraroundclear()as a "clearing is unavailable here" idiom will now see real deletions. The exposure is small, since the flag itself is recent, but the change wants a minor release rather than a patch, and a release note that says so plainly.SearchIndex.clear()is on every clearing path in the library, so this touches more than the four extensions. The cluster branch of_delete_batchis exercised only by the new hermetic test, becauserequires_clustertests are skipped in every environment including CI. That test pins control flow given_delete_batch's documented return contract; it says nothing about cross-slot behaviour, node targeting, or whetherFT.SEARCHenumerates every shard.This overlaps the branch that reworks
BaseCache.clear()for cluster cursor handling, which rewrites the sameSCANloop to usescan_iter. The two do not conflict as written, and the assertions here deliberately avoid pinning that loop's call shape, but landing the cluster work first keeps the sequencing clean.Four follow-ups are left out deliberately.
drop_by_filterkeeps the same hang-forever hole when every delete in a batch fails, since its backstop only advances on successful deletes.SearchIndex.delete(drop=True)on cluster callsclear()and then issuesFT.DROPINDEXwithoutDD, so an incomplete sweep orphans keys with no index left to enumerate them. The prefix scans inBaseCacheinterpolate an unescaped name straight intoSCAN MATCH, so a name containing a glob metacharacter silently matches the wrong set: a cache namedtenant[a]clears nothing. Andclear()returning a bare count means0cannot distinguish an already-empty index from a sweep that deleted nothing, whichBulkResultalready solves fordrop_by_filter.Next Steps
Release Notes
SemanticCache,MessageHistory,SemanticMessageHistoryandSemanticRouterconstructed withcreate_index=Falsecan now callclear()to remove their entries. Previously bothclear()anddelete()raisedValueErrorunder that flag;delete()still does, because it drops the index. This is a behavioural change: code that caughtValueErrorfromclear()and treated it as "unavailable" will now delete data.SearchIndex.clear()andAsyncSearchIndex.clear()no longer issueFT.INFO. They size their internal delete loop with aCountQueryinstead, so clearing an index is available to a credential built from+@read +@write, which is deniedFT.INFO. Clearing also no longer stops early on a page whose keys could not be deleted.EXTERNAL_INDEX_LIFECYCLE_CONFLICTinredisvl.extensions.constantsis renamed toEXTERNAL_INDEX_DROP_CONFLICT, and its message has changed. Code importing the old name, or matching on the old text, needs updating.