Skip to content

fix(cache): keep a caller's hash tag and refuse a cross-slot batch - #166

Merged
cosmin-staicu merged 1 commit into
mainfrom
fix/cluster-slot-guard
Sep 10, 2026
Merged

fix(cache): keep a caller's hash tag and refuse a cross-slot batch#166
cosmin-staicu merged 1 commit into
mainfrom
fix/cluster-slot-guard

Conversation

@cosmin-staicu

@cosmin-staicu cosmin-staicu commented Sep 9, 2026

Copy link
Copy Markdown
Member

Two things stood between a batch read and an OSS Cluster, both found while AuthZ migrated from ACR to AMR.

ShardKeyEnabled no longer re-wraps a key that already has a hash tag

The shard strategy wrapped every key in braces, so a caller that had placed its own {tag} ended up with app:s:{authz_{orgid}_groups_1}. Redis hashes from the first { to the next }, so the real tag was the accidental authz_{orgid rather 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 to RedisHashTag, shared with StreamSuffixShardedChannelStrategy.

This relocates existing entries only for an app that runs ShardKeyEnabled: true and puts braces in its keys.

The multi-key methods check the slot map before they run

GetAsync(CacheKey[]) and GetCacheEntriesAsync reach Redis as one MGET, RemoveAsync(CacheKey[]) as one DEL, and the multi-key SetAsync as one MULTI/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:

  • The slot comes from IConnectionMultiplexer.GetHashSlot, which answers NoSlot for every key when the server is not 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, not just the physical ones, so a masking policy configured against caller prefixes still renders precisely.

Reads propagate the throw through MultilayerCache. Multi-key writes and removes still meet the multilayer's existing catch and return false with a warning naming the keys, which is its standing policy for L2 failures.

Test notes

RedisCacheTests stubs _database.Multiplexer.GetHashSlot(Arg.Any<RedisKey>()) to 0: 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_resolved covers the disconnected path, and fails if the slot check moves back outside the command's try.

Full suite green (net10 1701, net8 1680; 12 docker-skipped).

🤖 Generated with Claude Code

https://claude.ai/code/session_01NsPw6MZHPGmpbo6WDuLzF1

@github-actions github-actions Bot added the needs-cla-review A maintainer should assess whether a signed CLA is required (see CONTRIBUTING.md) label Sep 9, 2026
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

🔎 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

  • adds public API surface (PublicAPI.Unshipped.txt in src/UiPath.Caching)

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.

  • If a CLA is needed → add the cla-required label (a contributor comment with signing steps is posted automatically).
  • If it is not needed → replace needs-cla-review with cla-not-required so later pushes don't re-flag it.

Copilot AI 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.

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

Comment thread src/UiPath.Caching/Redis/RedisCacheBase.cs Outdated

Copilot AI 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.

🟡 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 CacheKey or typeof(T), so selective masking can leak the key. For example, AddKeyMasking("session:") masks caller key session:token, but the composed key app:s:session:token no longer matches that prefix and is emitted verbatim. Preserve the caller keys through validation and render with Logged(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

Comment thread src/UiPath.Caching/Redis/RedisCacheBase.cs
@cosmin-staicu
cosmin-staicu force-pushed the feat/key-masking-policy branch from 561ef69 to 5fbe201 Compare September 9, 2026 19:11
@cosmin-staicu
cosmin-staicu force-pushed the fix/cluster-slot-guard branch 3 times, most recently from 0aa8673 to 1d78ffa Compare September 9, 2026 20:50
alinahornet
alinahornet previously approved these changes Sep 10, 2026
Base automatically changed from feat/key-masking-policy to main September 10, 2026 10:36
@cosmin-staicu
cosmin-staicu dismissed alinahornet’s stale review September 10, 2026 10:36

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>
@sonarqubecloud

Copy link
Copy Markdown

@cosmin-staicu
cosmin-staicu merged commit 987358d into main Sep 10, 2026
10 checks passed
@cosmin-staicu
cosmin-staicu deleted the fix/cluster-slot-guard branch September 10, 2026 11:18
cosmin-staicu added a commit that referenced this pull request Sep 11, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-cla-review A maintainer should assess whether a signed CLA is required (see CONTRIBUTING.md)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants