feat(cache): IDistributedCache adapter with case-sensitive keys and byte[] serialization - #121
feat(cache): IDistributedCache adapter with case-sensitive keys and byte[] serialization#121cosmin-staicu wants to merge 5 commits into
Conversation
…yte[] serialization Adds an IDistributedCache implementation backed by the library's ICache pipeline (shared Redis connection, resilience, telemetry), built on two new core capabilities: - CacheKey casing modes: Insensitive (trim + lowercase, the default) or Sensitive (trim only), selectable per key and globally via CacheOptions.KeyCasing -> CacheKey.DefaultCasing. Equality moves from InvariantCultureIgnoreCase to Ordinal on Name (consistent with GetHashCode and with how Redis and IMemoryCache physically key; identical results for lowercased insensitive keys). CacheKeyComparer exposes Sensitive/Insensitive singletons; WithName() and PrefixCacheKeyStrategy preserve the casing mode through key transformations. - ISerializerProxy<byte[]> seam: SystemJsonByteSerializerProxy passes byte[]/ReadOnlyMemory<byte> through raw (matching Microsoft.Extensions.Caching.StackExchangeRedis wire behavior) and JSON-encodes everything else. The ISerializerProxy<RedisValue> default registration and all existing wire formats are untouched. AddDistributedCache(providerName) registers a private provider + ICache pair as keyed singletons (never added to CacheFactory) with the byte serializer swapped in, and UiPathDistributedCache adapts it: always-Sensitive keys (independent of KeyCasing), payloads in a binary envelope ([UPDC][ver][flags][sliding][absolute][payload]), TTL = min(sliding, absolute - now) with the CachePolicy default as the floor (never unbounded keys in shared Redis), sliding extension via fire-and-forget KEYEXPIRE on Redis tiers and via re-write on the memory-only tier (whose multilayer RefreshAsync would otherwise evict the entry), foreign values decode as a logged miss. Works as a HybridCache L2 out of the box; IBufferDistributedCache is a follow-up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRcr7GtmVdMwAgKiKa88Dm Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>
|
🔎 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
Other 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.
|
There was a problem hiding this comment.
Pull request overview
Adds an IDistributedCache adapter over the existing cache pipeline, supporting case-sensitive keys, binary envelopes, expiration handling, and raw byte serialization.
Changes:
- Adds configurable
CacheKeycasing and comparers. - Adds byte-oriented serialization and distributed-cache registration.
- Adds envelope, expiration, registration, and end-to-end tests.
Reviewed changes
Copilot reviewed 25 out of 25 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
tests/UiPath.Caching.Tests/SystemJsonByteSerializerProxyTests.cs |
Tests byte serialization. |
tests/UiPath.Caching.Tests/PrefixCacheKeyStrategyTests.cs |
Tests casing preservation. |
tests/UiPath.Caching.Tests/Distributed/UiPathDistributedCacheTests.cs |
Tests adapter behavior. |
tests/UiPath.Caching.Tests/Distributed/RedisValueSerializerProxyTests.cs |
Tests Redis serialization bridge. |
tests/UiPath.Caching.Tests/Distributed/DistributedCacheEnvelopeTests.cs |
Tests envelope encoding. |
tests/UiPath.Caching.Tests/Distributed/DistributedCacheEndToEndTests.cs |
Tests cache scenarios end to end. |
tests/UiPath.Caching.Tests/Config/KeyCasingOptionsTests.cs |
Tests casing configuration. |
tests/UiPath.Caching.Tests/Config/DistributedCacheRegistrationTests.cs |
Tests DI registration. |
tests/UiPath.Caching.Tests/CacheKeyComparerTest.cs |
Tests key comparers. |
tests/UiPath.Caching.Tests/CacheKeyCasingTest.cs |
Tests key normalization. |
src/UiPath.Caching/PublicAPI.Unshipped.txt |
Declares new caching APIs. |
src/UiPath.Caching/PrefixCacheKeyStrategy.cs |
Preserves key casing during prefixing. |
src/UiPath.Caching/Distributed/UiPathDistributedCacheOptions.cs |
Defines adapter options. |
src/UiPath.Caching/Distributed/UiPathDistributedCache.cs |
Implements IDistributedCache. |
src/UiPath.Caching/Distributed/RedisValueSerializerProxy.cs |
Bridges byte and Redis serializers. |
src/UiPath.Caching/Distributed/DistributedCacheEnvelope.cs |
Implements binary envelopes. |
src/UiPath.Caching/Config/DistributedCacheCollectionExtensions.cs |
Registers the adapter and provider. |
src/UiPath.Caching/Config/CachingBuilder.cs |
Registers casing and byte serialization. |
src/UiPath.Caching.Abstractions/SystemJsonByteSerializerProxy.cs |
Adds byte-oriented JSON serialization. |
src/UiPath.Caching.Abstractions/PublicAPI.Unshipped.txt |
Declares new abstraction APIs. |
src/UiPath.Caching.Abstractions/CacheOptions.cs |
Adds global casing configuration. |
src/UiPath.Caching.Abstractions/CacheKeyComparer.cs |
Adds key comparers. |
src/UiPath.Caching.Abstractions/CacheKeyCasing.cs |
Defines casing modes. |
src/UiPath.Caching.Abstractions/CacheKey.cs |
Implements casing-aware keys. |
docs/how-to/extending.md |
Documents byte serializers. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- Symmetric byte passthrough: empty byte[] and ReadOnlyMemory<byte> now round-trip through SystemJsonByteSerializerProxy. - Corrupt envelopes with out-of-range tick values decode to null (a logged miss) instead of throwing from the DateTimeOffset constructor. - AddDistributedCache installs the memory-cache factory for memory tiers and fails with an actionable message when a Redis tier is used without AddRedisConnection. - Entries past their absolute expiration are treated as a miss and removed at read time, so a raced sliding refresh can never serve or extend them. - Docs: compilable MessagePack example; passthrough wording no longer implies Redis-level interop with the MS implementation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRcr7GtmVdMwAgKiKa88Dm Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRcr7GtmVdMwAgKiKa88Dm Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 25 out of 25 changed files in this pull request and generated 1 comment.
Suppressed comments (4)
src/UiPath.Caching/Config/CachingBuilder.cs:32
PostConfiguredoes not run untilIOptions<CacheOptions>.Valueis first resolved. A host configured with sensitive keys can therefore build its service provider and createnew CacheKey("AbC")as"abc"; resolving a cache later flips the process-global behavior mid-run. Multiple service providers can likewise overwrite one another's default. Apply the setting eagerly and once, or avoid deriving process-global key behavior from lazily resolved DI options.
Services.PostConfigure<CacheOptions>(options => CacheKey.DefaultCasing = options.KeyCasing);
src/UiPath.Caching/Config/DistributedCacheCollectionExtensions.cs:36
- These keyed registrations are last-wins, while the
TryAddSingleton<IDistributedCache>below is first-wins. If two modules callAddDistributedCache, the first adapter's options andslideByRewriteflag are paired with the last backing cache; in particular, a memory/Redis combination can select the wrong sliding-refresh strategy. Make all registrations use the same duplicate semantics (or reject a second registration).
builder.Services.AddKeyedSingleton<ICacheProvider>(DistributedCacheServiceKey,
(sp, _) => CreateProvider(sp, providerName));
builder.Services.AddKeyedSingleton<ICache>(DistributedCacheServiceKey,
(sp, key) => sp.GetRequiredKeyedService<ICacheProvider>(key!).CreateCache());
src/UiPath.Caching/Distributed/DistributedCacheEnvelope.cs:59
- Reserved flag bits are accepted as if they were part of a valid v1 envelope. For example,
UPDC, version 1, flags0x04is decoded and returned as a cache hit instead of being treated as corrupt/foreign data. Reject any bits other than the two defined metadata flags.
var flags = span[5];
src/UiPath.Caching/Distributed/UiPathDistributedCache.cs:106
- A corrupt envelope with a positive but very large sliding tick count (for example
long.MaxValue) passesTryDecodeand makesnow.AddTicksthrow, so malformed cache contents can still failGet/Refreshinstead of becoming a logged miss. Check that the duration can be added to the current clock value before callingAddTicks.
if (envelope.SlidingTicks is { } slidingTicks)
{
var target = now.AddTicks(slidingTicks);
The no-expiration TTL fallback relied on the tier's DefaultExpiration, which is nullable - a null there plus no policy DistributedExpiration produced unbounded Redis entries, contradicting the stated guarantee. AddDistributedCache now fails at first resolution unless the effective default TTL is bounded, accepting configurations where a cache policy supplies DistributedExpiration. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRcr7GtmVdMwAgKiKa88Dm Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 25 out of 25 changed files in this pull request and generated 1 comment.
Suppressed comments (3)
src/UiPath.Caching/Distributed/UiPathDistributedCache.cs:106
- A valid positive sliding duration can still overflow here:
DistributedCacheEntryOptions.SlidingExpirationpermits values up toTimeSpan.MaxValue, and decoded foreign envelopes can contain the same value.now.AddTicks(long.MaxValue)then throws, turningGet/Refreshinto an exception instead of a hit or logged miss. Bound or reject the duration before callingAddTicks(without mapping it toDateTimeOffset.MaxValue, which the Redis refresh path interprets asPERSIST).
var target = now.AddTicks(slidingTicks);
src/UiPath.Caching/Distributed/UiPathDistributedCache.cs:134
- When callers set both absolute forms, this branch silently ignores
AbsoluteExpirationRelativeToNow. Microsoft distributed-cache providers give the relative option precedence, so the adapter can retain an entry until a different deadline—or throw for a past absolute value—when swapping implementations. Resolve the relative value first to preserveIDistributedCachebehavior.
if (options.AbsoluteExpiration is { } absolute)
src/UiPath.Caching/Config/DistributedCacheCollectionExtensions.cs:36
- Repeated
AddDistributedCachecalls produce a mismatched singleton: keyed provider/cache resolution selects the last registration for this shared key, whileTryAddSingleton<IDistributedCache>keeps the first call'soptionsandslideByRewriteclosure. For example, InMemory followed by Redis wraps the Redis cache with InMemory rewrite behavior and the first instance options. Reject duplicate calls or make the keyed registrations follow the same first-wins policy asIDistributedCache.
builder.Services.AddKeyedSingleton<ICacheProvider>(DistributedCacheServiceKey,
(sp, _) => CreateProvider(sp, providerName, options));
builder.Services.AddKeyedSingleton<ICache>(DistributedCacheServiceKey,
(sp, key) => sp.GetRequiredKeyedService<ICacheProvider>(key!).CreateCache());
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 25 out of 25 changed files in this pull request and generated no new comments.
Suppressed comments (5)
tests/UiPath.Caching.Tests/Distributed/DistributedCacheEndToEndTests.cs:54
- Both 300 ms waits rely on resuming within a 500 ms sliding window; under CI scheduling delays either wait may exceed that deadline and make the test fail nondeterministically. Use a shared controllable
ISystemClockand explicit time advancement for this expiration scenario.
await cache.SetAsync("k", [1], new DistributedCacheEntryOptions { SlidingExpiration = TimeSpan.FromMilliseconds(500) }, token);
await Task.Delay(300, token);
await cache.RefreshAsync("k", token);
await Task.Delay(300, token);
src/UiPath.Caching/Distributed/DistributedCacheEnvelope.cs:59
- Unknown flag bits are accepted and their metadata bytes are interpreted as payload. A value with the correct magic/version but a future or corrupt flag (for example
0x04) therefore becomes a cache hit with corrupted data instead of the documented miss. Reject any bits outside the two defined flags before parsing.
var flags = span[5];
src/UiPath.Caching/Distributed/UiPathDistributedCache.cs:25
- A configured but unknown
PolicyNamesilently resolves tonull, causing the adapter to use the provider's default policy. A typo can therefore change TTL and other policy behavior without failing startup. When a name was explicitly supplied, fail if the factory cannot resolve it.
_policy = options.PolicyName is { } policyName ? policyFactory?.Resolve(policyName) : null;
src/UiPath.Caching/Distributed/UiPathDistributedCache.cs:106
SlidingExpirationaccepts positive values up toTimeSpan.MaxValue, and Redis can store such a TTL, but adding those ticks to the current time overflows here. The firstGet/Refreshthen throws instead of extending the valid entry. Saturate the target at the maximum representable timestamp before applying the absolute cap.
var target = now.AddTicks(slidingTicks);
tests/UiPath.Caching.Tests/Distributed/DistributedCacheEndToEndTests.cs:37
- This test assumes a 200 ms delay resumes before the 400 ms sliding deadline, but
Task.Delayonly guarantees a minimum delay. A scheduler pause on a loaded CI worker can exceed the TTL and fail the assertion even when sliding expiration is correct. Drive both the adapter and memory cache with a controllableISystemClockand advance it deterministically instead of relying on wall-clock timing.
This issue also appears on line 51 of the same file.
for (var i = 0; i < 3; i++)
{
await Task.Delay(200, token);
(await cache.GetAsync("Session-AbC", token)).Should().NotBeNull("touch {0} slides the window", i);
Batch_GetOrAdd_policy_FactoryTimeout_cancels_slow_generator (from #118, not touched by this PR) flaked on the Linux runner. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRcr7GtmVdMwAgKiKa88Dm Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>
|



Summary
Adds a
Microsoft.Extensions.Caching.Distributed.IDistributedCacheimplementation backed by the library'sICachepipeline (shared Redis connection, resilience, telemetry), built on two new core capabilities:CacheKeycasing modes and a byte-oriented serializer seam with rawbyte[]passthrough. Ready to serve as aHybridCacheL2 (non-buffered paths;IBufferDistributedCacheis a follow-up).Changes
CacheKeycasing modes —CacheKeyCasing.Insensitive(trim + lowercase, the default) orSensitive(trim only), per key or globally viaCacheOptions.KeyCasing→CacheKey.DefaultCasing. Equality moves fromInvariantCultureIgnoreCasetoOrdinalonName— now consistent withGetHashCodeand with how Redis/IMemoryCachephysically key; identical results for lowercased insensitive keys.WithName()andPrefixCacheKeyStrategypreserve the mode through transformations.CacheKeyComparer—StringComparer-shaped singletonsSensitive/Insensitive.ISerializerProxy<byte[]>—SystemJsonByteSerializerProxypassesbyte[]/ReadOnlyMemory<byte>through raw (matchingMicrosoft.Extensions.Caching.StackExchangeRediswire behavior) and JSON-encodes everything else. TheISerializerProxy<RedisValue>default registration and all existing wire formats are untouched.AddDistributedCache(providerName)— registers a private provider +ICachepair as keyed singletons (never added toCacheFactory) with the byte serializer swapped in. Backing tiers:Redis(recommended),InMemoryRedis,InMemory; unknown names fail fast.UiPathDistributedCache— always-Sensitivekeys (independent ofKeyCasing), binary envelope payloads ([UPDC][ver][flags][sliding][absolute][payload]), TTL =min(sliding, absolute − now)with theCachePolicydefault as floor (never unbounded keys in shared Redis), sliding extension via fire-and-forgetKEYEXPIREon Redis tiers and via re-write on the memory-only tier (whose multilayerRefreshAsyncwould otherwise evict the entry), foreign values decode as a logged miss.docs/how-to/extending.md(MessagePack swap example).Documented deviations from the MS implementation: whitespace-trimmed keys;
Refreshfetches the full payload (envelope-in-value, works over anyICache); no-expiration writes take the policy default TTL.Test plan
dotnet test— 1355 passed on net8.0 and net10.0)Linked issues
N/A
Contributor declaration
git commit -s).🤖 Generated with Claude Code
https://claude.ai/code/session_01BRcr7GtmVdMwAgKiKa88Dm