fix(cache): co-locate a stream with its quarantine hash, and refuse a key whose braces form no tag - #177
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved findings include a critical quarantine-key collision and moderate separator-normalization and empty-key issues.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This pull request fixes Redis Cluster slot affinity for stream quarantine metadata and centralizes hash-tag validation.
Changes:
- Builds quarantine keys using the stream’s hash tag while preserving casing.
- Rejects malformed hash-tag patterns and skips invalid streams.
- Adds coverage for affinity, malformed keys, and skip behavior.
File summaries
| File | Summary and findings |
|---|---|
tests/UiPath.Caching.Tests/ShardPrefixRedisKeyStrategyTests.cs |
Adds coverage for malformed hash-tag rejection. |
tests/UiPath.Caching.Tests/Broadcast/RedisStreamTopicMonitorTests.cs |
Tests quarantine affinity and stream-skip behavior. |
src/UiPath.Caching/Redis/ShardPrefixRedisKeyStrategy.cs |
Uses centralized hash-tag validation. |
src/UiPath.Caching/Redis/RedisHashTag.cs |
Centralizes tag handling. Findings: summary wording is inaccurate (nit, 1 vote); empty keys can produce {} and recreate CROSSSLOT behavior (moderate, 1 vote). |
src/UiPath.Caching/Broadcast/Redis/StreamSuffixShardedChannelStrategy.cs |
Reuses centralized hash-tag validation. |
src/UiPath.Caching/Broadcast/Redis/RedisStreamHealthMaintainer.cs |
Generates slot-affine quarantine keys. Findings: normalize the separator marker (moderate, 1 vote); quarantine keys collide for distinct keys such as x and {x} (critical, 1 vote). |
Review details
Suppressed comments (3)
src/UiPath.Caching/Broadcast/Redis/RedisStreamHealthMaintainer.cs:91
PrefixRedisKeyStrategyandPrefixStrategynormalize the configured separator to lowercase, but this marker is built from the raw separator and is then preserved byCacheKeyCasing.Sensitive. WithSeparator = 'B', the quarantine key containscachingBmetaBstreamwhile the stream and keyspace prefixes useb, producing a layout inconsistent with the rest of the Redis keys. Normalize the separator when constructing the marker.
_quarantineMarker = string.Join(_cacheOptions.Separator, "caching", "meta", "stream") + "$";
src/UiPath.Caching/Redis/RedisHashTag.cs:20
- The summary is inaccurate for the first branch: when
keyalready has a valid tag, Redis hashes only the content inside that tag, not all ofkey. Please describe the actual contract—reuse an existing valid tag or wrap a brace-free key—so callers do not infer that the whole key participates in hashing.
/// <summary><paramref name="key"/> rendered so Redis Cluster hashes all of it, reusing a tag it already carries.</summary>
src/UiPath.Caching/Redis/RedisHashTag.cs:29
- An empty brace-free key reaches this branch and returns
{}. Redis treats an empty{}pair as no hash tag, so a quarantine key built for an empty stream key hashes the whole quarantine key instead of the stream key (when sharding is disabled), recreating the CROSSSLOT failure. Treat an empty key as unmaintainable and throw/skip it rather than wrapping it.
if (ContainsNoBraces(key))
{
return "{" + key + "}";
- Files reviewed: 6/6 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
89a415d to
da91458
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved moderate issues affect key identity and slot-affinity guarantees.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
src/UiPath.Caching/Broadcast/Redis/RedisStreamHealthMaintainer.cs:94
CacheOptions.Separatoris only rejected when it is whitespace (src/UiPath.Caching/Redis/Guard.cs:5-12), so{and}are valid configurations. This marker puts that separator before the stream's tag; with a brace separator, default stream keys contain unusable braces and are skipped, while a custom pre-tagged stream makes the marker's first brace become Redis's tag. The new quarantine/stream slot-affinity guarantee therefore still fails for a permitted separator; reject brace separators or use a brace-free marker encoding.
_quarantineMarker = string.Join(_cacheOptions.Separator, "caching", "meta", "stream") + "$";
src/UiPath.Caching/Redis/RedisHashTag.cs:29
ContainsNoBracesalso accepts the empty string, so this returns{}for an empty key. Redis treats{}as an empty tag and hashes the whole key instead, meaning the quarantine key will not share the empty stream key's slot (and the outer shard strategy may reject it). A custom stream-key strategy or search pattern can expose an empty stream key, so handle it explicitly rather than claiming the wrapping guarantees affinity.
if (ContainsNoBraces(key))
{
return "{" + key + "}";
- Files reviewed: 6/6 changed files
- Comments generated: 1
- Review effort level: Lite
da91458 to
92d6e07
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
One or more issues must be addressed before approval.
Review details
Suppressed comments (1)
src/UiPath.Caching/Redis/RedisHashTag.cs:29
- An empty key passes
ContainsNoBracesand is wrapped as{}. BecauseHasValidTagtreats an empty brace pair as invalid, Redis hashes this key by its full name rather than by a tag; an empty stream key would therefore be cross-slot with its quarantine hash whenKeyDeleteAsyncsends both keys together. Reject empty input before this branch (or otherwise handle it without claiming slot affinity).
if (ContainsNoBraces(key))
{
return "{" + key + "}";
- Files reviewed: 6/6 changed files
- Comments generated: 0 new
- Review effort level: Lite
There was a problem hiding this comment.
🟡 Changes recommended
Empty keys and custom Redis key strategies can still produce quarantine keys on a different cluster slot.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 6/6 changed files
- Comments generated: 2
- Review effort level: Balanced
92d6e07 to
6cf6324
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
Empty Redis keys still produce an unusable hash tag and can trigger the CROSSSLOT failure being fixed.
Review details
Suppressed comments (1)
src/UiPath.Caching/Redis/RedisHashTag.cs:29
- An empty Redis key is brace-free, so this returns
{}. Redis ignores an empty hash tag and hashes the entire quarantine key, while the empty stream key hashes the empty byte sequence; the resulting multi-key delete is therefore stillCROSSSLOT. Empty Redis keys are valid and can be found by a customMaintainerSearchPattern, so reject this case and let the maintainer's existing exception handling skip it.
if (ContainsNoBraces(key))
{
return "{" + key + "}";
- Files reviewed: 7/7 changed files
- Comments generated: 0 new
- Review effort level: Balanced
6cf6324 to
7462a15
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Sharded maintenance can abort when discovered stream names or configured separators contain unmatched braces.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
src/UiPath.Caching/Broadcast/Redis/RedisStreamHealthMaintainer.cs:144
- With
ShardKeyEnabled, this composed name is passed toShardPrefixRedisKeyStrategy.EnsureTag. A valid Redis stream name such asstream}(discoverable throughMaintainerSearchPatternor a custom stream strategy) therefore throws whileGetAllStreamsAsyncbuilds contexts, and the outer catch aborts the entire maintenance pass. A configured{/}separator causes the same failure for ordinary streams because separators are only required to be non-whitespace. Since deletion no longer requires slot affinity, encode the stream identity into a brace-free representation before applying the hash strategy, or otherwise keep internal metadata names out of this rejection path, and add regression coverage.
_hashKeyStrategy.GetRedisKey(new CacheKey(
_quarantineMarker + streamKey.ToString() + StreamKeyTerminator,
CacheKeyCasing.Sensitive));
- Files reviewed: 7/7 changed files
- Comments generated: 3
- Review effort level: Balanced
64fd2f9 to
1551ab5
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Malformed-brace streams can abort maintenance, and deletion ordering can permanently orphan metadata.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 7/7 changed files
- Comments generated: 3
- Review effort level: Balanced
1551ab5 to
6654135
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Brace separators can abort maintenance, and legacy quarantine hashes are not guaranteed to expire.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 7/7 changed files
- Comments generated: 2
- Review effort level: Balanced
… its stream
RedisStreamHealthMaintainer built its quarantine key by rendering a *prefix*
through IRedisKeyStrategy.GetRedisKey and concatenating the stream key onto it,
but that method renders a complete key. With ShardKeyEnabled the rendered prefix
carried its own '{...}', so every quarantine hash inherited one constant tag and
all of them landed on a single slot. The key is built in one call now, so it
spreads with the stream key that names it, and a trailing terminator keeps
CacheKey's trim -- which runs in both casings -- from collapsing two stream keys
that differ only by surrounding whitespace.
CheckEmptyStreamAsync also deleted the stream and its quarantine hash in one
multi-key KeyDelete, which Redis Cluster answers with CROSSSLOT unless both keys
hash to the same slot, so that cleanup never completed on a cluster. Co-locating
the pair would not have been sound: the configured IRedisKeyStrategy and any
connection key prefix both shape the key that reaches Redis, and either can put
the two on different slots whatever this library composes. The pair is deleted
one command at a time instead, and nothing depends on a shared slot.
Wrapping a key that already holds a brace pairs the added '{' with it and leaves
a tag that is only a prefix of the key, quietly collapsing unrelated keys onto
one slot; an empty key wraps to '{}', which Redis reads as no tag at all.
RedisHashTag.EnsureTag now owns that rule for the whole library --
ShardPrefixRedisKeyStrategy and StreamSuffixShardedChannelStrategy both go
through it, and the former refuses such a key instead of wrapping it.
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>
6654135 to
df6a9f6
Compare
|
There was a problem hiding this comment.
🟢 Approval recommended
The revised implementation addresses Redis Cluster slot constraints and the identified edge cases with focused regression coverage.
Review details
- Files reviewed: 7/7 changed files
- Comments generated: 0 new
- Review effort level: Balanced
… than leaving it off
The flag wraps a cache key in a '{...}' hash tag so the slot follows the key
rather than AppShortName and the differentiator. For a brace-free key that
changes which keys share a slot and nothing else: the tag becomes the whole key,
unique per key just as the untagged key was, so it spreads no better, and it
cannot make a multi-key batch land on one node. A key that already carries a
valid tag is rendered identically on either setting, so a caller that places its
own tag keeps the slot it chose with or without the flag -- the flag makes no
difference to batching in either direction. What it does do for a brace-free key
is make its slot independent of everything rendered in front of it, so one key
lands on one slot across every cache rendering through
DefaultRedisKeyStrategyFactory -- ICache's 's', IHashCache's 'h',
RedisSetCache's 'se', the distributed adapter's 'dh', and any differentiator
passed to its string overload -- and across apps with a different AppShortName.
Two shapes fall outside that: a key whose braces form no valid tag, or that is
empty, is refused outright when the flag is set (#177); and AppShortName and
Separator permit braces, which supply the first tag and override the key's own,
since the strategy validates only the key. The docs say so now rather than
promising the general case.
What does matter on a cluster is unconditional already: a caller's own tag
survives, and a cross-slot batch is refused with a message naming the two keys
that disagree. settings.md told readers to "enable for Redis Cluster deployments
that span multiple shards", which the flag does not deliver; it, the how-to, the
sample settings and the sample README now say what it actually does.
Still read, because a deployment that set it would relocate every entry with a
brace-free key if it stopped being honored; the factory suppresses its own
warning for that reason.
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>
… than leaving it off
The flag wraps a cache key in a '{...}' hash tag so the slot follows the key
rather than AppShortName and the differentiator. For a brace-free key that
changes which keys share a slot and nothing else: the tag becomes the whole key,
unique per key just as the untagged key was, so it spreads no better, and it
cannot make a multi-key batch land on one node. A key that already carries a
valid tag is rendered identically on either setting, so a caller that places its
own tag keeps the slot it chose with or without the flag -- the flag makes no
difference to batching in either direction. What it does do for a brace-free key
is make its slot independent of everything rendered in front of it, so one key
lands on one slot across every cache rendering through
DefaultRedisKeyStrategyFactory -- ICache's 's', IHashCache's 'h',
RedisSetCache's 'se', the distributed adapter's 'dh', and any differentiator
passed to its string overload -- and across apps with a different AppShortName.
Two shapes fall outside that: a key whose braces form no valid tag, or that is
empty, is refused outright when the flag is set (#177); and AppShortName and
Separator permit braces, which supply the first tag and override the key's own,
since the strategy validates only the key. The docs say so now rather than
promising the general case.
What does matter on a cluster is unconditional already: a caller's own tag
survives, and a cross-slot batch is refused with a message naming the two keys
that disagree. settings.md told readers to "enable for Redis Cluster deployments
that span multiple shards", which the flag does not deliver; it, the how-to, the
sample settings and the sample README now say what it actually does.
Still read, because a deployment that set it would relocate every entry with a
brace-free key if it stopped being honored; the factory suppresses its own
warning for that reason.
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>
… than leaving it off
The flag wraps a cache key in a '{...}' hash tag so the slot follows the key
rather than AppShortName and the differentiator. For a brace-free key that
changes which keys share a slot and nothing else: the tag becomes the whole key,
unique per key just as the untagged key was, so it spreads no better, and it
cannot make a multi-key batch land on one node. A key that already carries a
valid tag is rendered identically on either setting, so a caller that places its
own tag keeps the slot it chose with or without the flag -- the flag makes no
difference to batching in either direction. What it does do for a brace-free key
is make its slot independent of everything rendered in front of it, so one key
lands on one slot across every cache rendering through
DefaultRedisKeyStrategyFactory -- ICache's 's', IHashCache's 'h',
RedisSetCache's 'se', the distributed adapter's 'dh', and any differentiator
passed to its string overload -- and across apps with a different AppShortName.
Two shapes fall outside that: a key whose braces form no valid tag, or that is
empty, is refused outright when the flag is set (#177); and AppShortName and
Separator permit braces, which supply the first tag and override the key's own,
since the strategy validates only the key. The docs say so now rather than
promising the general case.
What does matter on a cluster is unconditional already: a caller's own tag
survives, and a cross-slot batch is refused with a message naming the two keys
that disagree. settings.md told readers to "enable for Redis Cluster deployments
that span multiple shards", which the flag does not deliver; it, the how-to, the
sample settings and the sample README now say what it actually does.
Still read, because a deployment that set it would relocate every entry with a
brace-free key if it stopped being honored; the factory suppresses its own
warning for that reason.
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>
… than leaving it off
The flag wraps a cache key in a '{...}' hash tag so the slot follows the key
rather than AppShortName and the differentiator. For a brace-free key that
changes which keys share a slot and nothing else: the tag becomes the whole key,
unique per key just as the untagged key was, so it spreads no better, and it
cannot make a multi-key batch land on one node. A key that already carries a
valid tag is rendered identically on either setting, so a caller that places its
own tag keeps the slot it chose with or without the flag -- the flag makes no
difference to batching in either direction. What it does do for a brace-free key
is make its slot independent of everything rendered in front of it, so one key
lands on one slot across every cache rendering through
DefaultRedisKeyStrategyFactory -- ICache's 's', IHashCache's 'h',
RedisSetCache's 'se', the distributed adapter's 'dh', and any differentiator
passed to its string overload -- and across apps with a different AppShortName.
Two shapes fall outside that: a key whose braces form no valid tag, or that is
empty, is refused outright when the flag is set (#177); and AppShortName and
Separator permit braces, which supply the first tag and override the key's own,
since the strategy validates only the key. The docs say so now rather than
promising the general case.
What does matter on a cluster is unconditional already: a caller's own tag
survives, and a cross-slot batch is refused with a message naming the two keys
that disagree. settings.md told readers to "enable for Redis Cluster deployments
that span multiple shards", which the flag does not deliver; it, the how-to, the
sample settings and the sample README now say what it actually does.
Still read, because a deployment that set it would relocate every entry with a
brace-free key if it stopped being honored; the factory suppresses its own
warning for that reason.
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>
… than leaving it off
The flag wraps a cache key in a '{...}' hash tag so the slot follows the key
rather than AppShortName and the differentiator. For a brace-free key that
changes which keys share a slot and nothing else: the tag becomes the whole key,
unique per key just as the untagged key was, so it spreads no better, and it
cannot make a multi-key batch land on one node. A key that already carries a
valid tag is rendered identically on either setting, so a caller that places its
own tag keeps the slot it chose with or without the flag -- the flag makes no
difference to batching in either direction. What it does do for a brace-free key
is make its slot independent of everything rendered in front of it, so one key
lands on one slot across every cache rendering through
DefaultRedisKeyStrategyFactory -- ICache's 's', IHashCache's 'h',
RedisSetCache's 'se', the distributed adapter's 'dh', and any differentiator
passed to its string overload -- and across apps with a different AppShortName.
Two shapes fall outside that: a key whose braces form no valid tag, or that is
empty, is refused outright when the flag is set (#177); and AppShortName and
Separator permit braces, which supply the first tag and override the key's own,
since the strategy validates only the key. The docs say so now rather than
promising the general case.
What does matter on a cluster is unconditional already: a caller's own tag
survives, and a cross-slot batch is refused with a message naming the two keys
that disagree. settings.md told readers to "enable for Redis Cluster deployments
that span multiple shards", which the flag does not deliver; it, the how-to, the
sample settings and the sample README now say what it actually does.
Still read, because a deployment that set it would relocate every entry with a
brace-free key if it stopped being honored; the factory suppresses its own
warning for that reason.
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>
… than leaving it off
The flag wraps a cache key in a '{...}' hash tag so the slot follows the key
rather than AppShortName and the differentiator. For a brace-free key that
changes which keys share a slot and nothing else: the tag becomes the whole key,
unique per key just as the untagged key was, so it spreads no better, and it
cannot make a multi-key batch land on one node. A key that already carries a
valid tag is rendered identically on either setting, so a caller that places its
own tag keeps the slot it chose with or without the flag -- the flag makes no
difference to batching in either direction. What it does do for a brace-free key
is make its slot independent of everything rendered in front of it, so one key
lands on one slot across every cache rendering through
DefaultRedisKeyStrategyFactory -- ICache's 's', IHashCache's 'h',
RedisSetCache's 'se', the distributed adapter's 'dh', and any differentiator
passed to its string overload -- and across apps with a different AppShortName.
Two shapes fall outside that: a key whose braces form no valid tag, or that is
empty, is refused outright when the flag is set (#177); and AppShortName and
Separator permit braces, which supply the first tag and override the key's own,
since the strategy validates only the key. The docs say so now rather than
promising the general case.
What does matter on a cluster is unconditional already: a caller's own tag
survives, and a cross-slot batch is refused with a message naming the two keys
that disagree. settings.md told readers to "enable for Redis Cluster deployments
that span multiple shards", which the flag does not deliver; it, the how-to, the
sample settings and the sample README now say what it actually does.
Still read, because a deployment that set it would relocate every entry with a
brace-free key if it stopped being honored; the factory suppresses its own
warning for that reason.
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>
… than leaving it off
The flag wraps a cache key in a '{...}' hash tag so the slot follows the key
rather than AppShortName and the differentiator. For a brace-free key that
changes which keys share a slot and nothing else: the tag becomes the whole key,
unique per key just as the untagged key was, so it spreads no better, and it
cannot make a multi-key batch land on one node. A key that already carries a
valid tag is rendered identically on either setting, so a caller that places its
own tag keeps the slot it chose with or without the flag -- the flag makes no
difference to batching in either direction. What it does do for a brace-free key
is make its slot independent of everything rendered in front of it, so one key
lands on one slot across every cache rendering through
DefaultRedisKeyStrategyFactory -- ICache's 's', IHashCache's 'h',
RedisSetCache's 'se', the distributed adapter's 'dh', and any differentiator
passed to its string overload -- and across apps with a different AppShortName.
Two shapes fall outside that: a key whose braces form no valid tag, or that is
empty, is refused outright when the flag is set (#177); and AppShortName and
Separator permit braces, which supply the first tag and override the key's own,
since the strategy validates only the key. The docs say so now rather than
promising the general case.
What does matter on a cluster is unconditional already: a caller's own tag
survives, and a cross-slot batch is refused with a message naming the two keys
that disagree. settings.md told readers to "enable for Redis Cluster deployments
that span multiple shards", which the flag does not deliver; it, the how-to, the
sample settings and the sample README now say what it actually does.
Still read, because a deployment that set it would relocate every entry with a
brace-free key if it stopped being honored; the factory suppresses its own
warning for that reason.
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>



The bug
RedisStreamHealthMaintainerbuilt its quarantine key by rendering a prefix throughGetRedisKeyand concatenating the stream key onto it:GetRedisKeyrenders a complete key, so withShardKeyEnabledthe rendered prefix carried its own{...}, leavingapp:h:{caching:meta:stream$}app:st:topic. Every quarantine hash inherited that one constant tag and landed on a single slot.Separately,
CheckEmptyStreamAsyncdeletes the stream and its quarantine hash in one command:Redis Cluster answers a multi-key command with
CROSSSLOTunless every key hashes to the same slot, and these two never did — so the empty-stream cleanup has never completed on a cluster.The fix
Build the key in one call. The quarantine key is the marker, the stream key and a terminator, rendered once:
It spreads with the stream key that names it, and is injective because the stream key appears verbatim. The trailing terminator matters because
CacheKeycallsTrim()even under sensitive casing (CacheKey.cs:29) — without it the composed name ended in the stream key, andtopiccollapsed ontotopic.Delete one key per command. Co-locating the pair is not soundly achievable: the configured
IRedisKeyStrategyand any connection key prefix both shape the key that reaches Redis, and either can put the two on different slots whatever this library composes.RedisCacheOptions.KeyPrefixis already in play in this class —ParseStreamScanstrips it soWithKeyPrefixcan re-add it — and a brace-free prefix alone is enough to break a tag-matching scheme. SoCheckEmptyStreamAsyncissues two single-key deletes and nothing depends on a shared slot. The cost is one extra round trip on a rare cleanup path; an orphaned quarantine hash carries the TTLCheckStreamWithGroupsAsyncsets.The wrapping rule, in one place
Wrapping a key that already holds a brace pairs the added
{with it and leaves a tag that is only a prefix of the key, quietly collapsing unrelated keys onto one slot —bla{}blawrapped to{bla{}bla}gives Redis the tagbla{. An empty key is no better:{}is a brace pair Redis reads as no tag at all.RedisHashTag.EnsureTagnow owns the rule the library already applied inStreamSuffixShardedChannelStrategy: reuse a valid tag, wrap a non-empty brace-free key, refuse anything else. BothShardPrefixRedisKeyStrategyandStreamSuffixShardedChannelStrategygo through it.Behaviour changes
ShardPrefixRedisKeyStrategynow throws on a key it cannot tag —bla{bla,bla}bla,bla{}bla, and the empty key.bla{blapreviously wrapped to a correct tag by accident; the others silently truncated it or produced no tag. No regression for the caches:RedisCache.ToRedisKeyalready rejects an emptyCacheKeybefore the strategy is reached.MaintainerQuarantineInterval × 10(10h by default), so they expire on their own.Tests
net10.0 1715 pass, net8.0 1694 pass, 0 failures (Redis integration tests skipped — no Docker). New coverage pins the rendered quarantine key under both
ShardKeyEnabledsettings, its injectivity including keys differing only by surrounding whitespace, that the empty-stream cleanup uses single-key deletes and never the multi-key overload, and the refused shapes in both strategies.🤖 Generated with Claude Code
https://claude.ai/code/session_01NsPw6MZHPGmpbo6WDuLzF1