fix(cache): keep a caller's hash tag and refuse a cross-slot batch - #166
Conversation
|
🔎 Maintainer heads-up: automated triage flagged this PR as potentially material, so it may need a signed CLA in addition to the DCO sign-off. Strong signals
This is advisory only — the bot does not decide. Please judge against the CLA criteria (material, product-critical, patent-sensitive, corporate contributor, broad commercial use). Note that thresholds can be gamed by splitting PRs, so use your judgement.
|
ed60603 to
181ea8a
Compare
1b05b23 to
9b404dd
Compare
There was a problem hiding this comment.
🟡 Changes recommended
The new exception message can expose keys protected by configured prefix masking.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds Redis Cluster slot validation and preserves caller-provided hash tags, building on #165’s key-masking infrastructure.
Changes:
- Preserves valid existing Redis hash tags.
- Rejects cross-slot multi-key operations.
- Adds tests, public API entries, and documentation.
File summaries
| File | Description |
|---|---|
CHANGELOG.md |
Records behavioral changes. |
docs/how-to/telemetry-and-strategies.md |
Explains shard-key behavior. |
docs/reference/interfaces.md |
Documents cross-slot exceptions. |
docs/reference/settings.md |
Clarifies ShardKeyEnabled. |
src/UiPath.Caching/Broadcast/Redis/StreamSuffixShardedChannelStrategy.cs |
Uses shared hash-tag detection. |
src/UiPath.Caching/PublicAPI.Unshipped.txt |
Exposes the new exception. |
src/UiPath.Caching/Redis/CrossSlotKeysException.cs |
Defines the public exception. |
src/UiPath.Caching/Redis/RedisCache.cs |
Validates multi-key slots. |
src/UiPath.Caching/Redis/RedisCacheBase.cs |
Implements shared slot validation. |
src/UiPath.Caching/Redis/RedisHashTag.cs |
Centralizes hash-tag parsing. |
src/UiPath.Caching/Redis/ShardPrefixRedisKeyStrategy.cs |
Preserves caller hash tags. |
tests/UiPath.Caching.Tests/Redis/RedisCacheTests.cs |
Tests cross-slot rejection. |
tests/UiPath.Caching.Tests/ShardPrefixRedisKeyStrategyTests.cs |
Tests hash-tag preservation. |
Review details
- Files reviewed: 13/13 changed files
- Comments generated: 1
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
181ea8a to
8d009ca
Compare
9b404dd to
eb56099
Compare
eb56099 to
da39449
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Slot validation can bypass existing failure handling and selectively masked keys may be exposed in exception messages.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
src/UiPath.Caching/Redis/RedisCacheBase.cs:140
- This renders each composed Redis key without the original
CacheKeyortypeof(T), so selective masking can leak the key. For example,AddKeyMasking("session:")masks caller keysession:token, but the composed keyapp:s:session:tokenno longer matches that prefix and is emitted verbatim. Preserve the caller keys through validation and render withLogged(cacheKey, redisKey, typeof(T)).
throw new CrossSlotKeysException(
$"{operation} was given {redisKeys.Length} keys that Redis Cluster maps to different slots, " +
$"starting with '{Logged(redisKeys[0])}' and '{Logged(redisKeys[i])}'. " +
- Files reviewed: 13/13 changed files
- Comments generated: 1
- Review effort level: Balanced
561ef69 to
5fbe201
Compare
0aa8673 to
1d78ffa
Compare
The base branch was changed.
Two things stood between a batch read and an OSS Cluster.
ShardKeyEnabled wrapped every key in braces, so a caller that had already
placed its own {tag} got app:s:{authz_{orgid}_groups_1}, whose real tag is
authz_{orgid: Redis hashes from the first { to the next }. The tag was the
accidental one rather than the org the caller asked for. A key that already
carries a valid hash tag is now prefixed and left alone; keys with no braces,
or with braces that do not form a tag, wrap exactly as before. The brace rule
moved to RedisHashTag, shared with the sharded-channel strategy.
The multi-key methods each reach Redis as one command on one node: GetAsync
and GetCacheEntriesAsync an MGET, RemoveAsync one DEL, the multi-key SetAsync
a MULTI/EXEC. A batch whose keys span slots is answered with an error, which
the caches log and report as a miss, so migrating onto a cluster turned a
working batch read into a cache that never hits with nothing thrown to say so.
Each path now compares the slots first and throws CrossSlotKeysException,
naming both keys and how to fix it. The slot comes from the multiplexer, which
answers NoSlot off a cluster, so nothing changes on a single node.
The check runs inside each command's own try, because reading a slot resolves
the connection and an unreachable Redis has to keep answering with a miss
rather than a connection error; CrossSlotKeysException is rethrown past the
catch that would otherwise report it as one. The message names the caller's
keys, so a masking policy configured against them still renders precisely.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NsPw6MZHPGmpbo6WDuLzF1
Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>
1d78ffa to
b1e7cc5
Compare
|
The deprecation note claimed the flag changes which keys co-locate and "nothing more", which its own next sentence contradicted: with the flag on, ShardPrefixRedisKeyStrategy also refuses a key whose braces form no valid tag, where the strategy behind the flag-off path accepts it. The claim is now about spread rather than exclusivity, and the refusal is named where it was implied. The brace-free-prefix caveat also left out RedisCacheOptions.KeyPrefix, which settings.md documents as prepended ahead of AppShortName, so braces there are reached before anything the strategy renders. And the #166 changelog entry asserted flatly that a caller's own tag picks the slot, which holds only for a brace-free prefix. Claude-Session: https://claude.ai/code/session_01NsPw6MZHPGmpbo6WDuLzF1 Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>



Two things stood between a batch read and an OSS Cluster, both found while AuthZ migrated from ACR to AMR.
ShardKeyEnabledno longer re-wraps a key that already has a hash tagThe shard strategy wrapped every key in braces, so a caller that had placed its own
{tag}ended up withapp:s:{authz_{orgid}_groups_1}. Redis hashes from the first{to the next}, so the real tag was the accidentalauthz_{orgidrather than the org the caller asked for. A key carrying a valid hash tag is now prefixed and left alone; keys with no braces, or with braces that do not form a tag, wrap exactly as before. The brace rule moved toRedisHashTag, shared withStreamSuffixShardedChannelStrategy.This relocates existing entries only for an app that runs
ShardKeyEnabled: trueand puts braces in its keys.The multi-key methods check the slot map before they run
GetAsync(CacheKey[])andGetCacheEntriesAsyncreach Redis as oneMGET,RemoveAsync(CacheKey[])as oneDEL, and the multi-keySetAsyncas oneMULTI/EXEC. A batch whose keys span slots is answered with an error, which the caches log and report as a miss - so moving an app onto a cluster turned a working batch read into a cache that never hits, with nothing thrown to say so.Each of those paths now compares the slots first and throws
CrossSlotKeysException, naming both keys that disagree and how to fix it. Three details:IConnectionMultiplexer.GetHashSlot, which answersNoSlotfor every key when the server is not a cluster, so nothing changes on a single node.try, because reading a slot resolves the connection and an unreachable Redis has to keep answering with a miss rather than a connection error.CrossSlotKeysExceptionis rethrown past the catch that would otherwise report it as one.Reads propagate the throw through
MultilayerCache. Multi-key writes and removes still meet the multilayer's existing catch and returnfalsewith a warning naming the keys, which is its standing policy for L2 failures.Test notes
RedisCacheTestsstubs_database.Multiplexer.GetHashSlot(Arg.Any<RedisKey>())to0: AutoFixture's auto-values were handing each key a random int, so every batch read as cross-slot.Multi_get_still_reports_a_miss_when_the_connection_cannot_be_resolvedcovers the disconnected path, and fails if the slot check moves back outside the command'stry.Full suite green (net10 1701, net8 1680; 12 docker-skipped).
🤖 Generated with Claude Code
https://claude.ai/code/session_01NsPw6MZHPGmpbo6WDuLzF1