Skip to content

feat(cache): IDistributedCache adapter with case-sensitive keys and byte[] serialization - #121

Open
cosmin-staicu wants to merge 5 commits into
mainfrom
feat/distributed-cache
Open

feat(cache): IDistributedCache adapter with case-sensitive keys and byte[] serialization#121
cosmin-staicu wants to merge 5 commits into
mainfrom
feat/distributed-cache

Conversation

@cosmin-staicu

Copy link
Copy Markdown
Member

Summary

Adds a Microsoft.Extensions.Caching.Distributed.IDistributedCache implementation backed by the library's ICache pipeline (shared Redis connection, resilience, telemetry), built on two new core capabilities: CacheKey casing modes and a byte-oriented serializer seam with raw byte[] passthrough. Ready to serve as a HybridCache L2 (non-buffered paths; IBufferDistributedCache is a follow-up).

Changes

  • CacheKey casing modesCacheKeyCasing.Insensitive (trim + lowercase, the default) or Sensitive (trim only), per key or globally via CacheOptions.KeyCasingCacheKey.DefaultCasing. Equality moves from InvariantCultureIgnoreCase to Ordinal on Name — now consistent with GetHashCode and with how Redis/IMemoryCache physically key; identical results for lowercased insensitive keys. WithName() and PrefixCacheKeyStrategy preserve the mode through transformations.
  • CacheKeyComparerStringComparer-shaped singletons Sensitive / Insensitive.
  • ISerializerProxy<byte[]>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. Backing tiers: Redis (recommended), InMemoryRedis, InMemory; unknown names fail fast.
  • UiPathDistributedCache — always-Sensitive keys (independent of KeyCasing), binary envelope payloads ([UPDC][ver][flags][sliding][absolute][payload]), TTL = min(sliding, absolute − now) with the CachePolicy default as 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.
  • Docs: byte-serializer section in docs/how-to/extending.md (MessagePack swap example).

Documented deviations from the MS implementation: whitespace-trimmed keys; Refresh fetches the full payload (envelope-in-value, works over any ICache); no-expiration writes take the policy default TTL.

Test plan

  • Unit tests added/updated (~90 new: casing, comparers, serializers, envelope, adapter, registration, end-to-end sliding-session scenarios)
  • Integration tests pass locally (dotnet test — 1355 passed on net8.0 and net10.0)
  • CHANGELOG.md updated (per repo convention, done at release time with the PublicAPI promotion)

Linked issues

N/A

Contributor declaration

  • I signed off my commits per the DCO (git commit -s).
  • I am contributing on behalf of my employer, or in the course of employment / using employer resources. (If checked, your employer may hold IP rights in this work, which can require a signed CLA — a maintainer will follow up. See CONTRIBUTING.md.)

🤖 Generated with Claude Code

https://claude.ai/code/session_01BRcr7GtmVdMwAgKiKa88Dm

…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>
@github-actions

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.Abstractions, src/UiPath.Caching)

Other signals

  • large production change (+502 lines under src/)

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.

@github-actions github-actions Bot added the needs-cla-review A maintainer should assess whether a signed CLA is required (see CONTRIBUTING.md) label Aug 19, 2026
@cosmin-staicu
cosmin-staicu requested a balanced review from Copilot August 19, 2026 13:28

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.

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 CacheKey casing 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.

Comment thread src/UiPath.Caching.Abstractions/SystemJsonByteSerializerProxy.cs Outdated
Comment thread src/UiPath.Caching/Distributed/DistributedCacheEnvelope.cs Outdated
Comment thread src/UiPath.Caching/Config/DistributedCacheCollectionExtensions.cs
Comment thread docs/how-to/extending.md Outdated
Comment thread docs/how-to/extending.md Outdated
Comment thread src/UiPath.Caching/Distributed/UiPathDistributedCache.cs
- 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>
@cosmin-staicu
cosmin-staicu requested a balanced review from Copilot August 19, 2026 14:19
@cosmin-staicu cosmin-staicu self-assigned this Aug 19, 2026
@cosmin-staicu cosmin-staicu added cla-not-required Maintainer reviewed: no CLA required for this contribution and removed needs-cla-review A maintainer should assess whether a signed CLA is required (see CONTRIBUTING.md) labels Aug 19, 2026
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>

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.

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

  • PostConfigure does not run until IOptions<CacheOptions>.Value is first resolved. A host configured with sensitive keys can therefore build its service provider and create new 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 call AddDistributedCache, the first adapter's options and slideByRewrite flag 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, flags 0x04 is 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) passes TryDecode and makes now.AddTicks throw, so malformed cache contents can still fail Get/Refresh instead of becoming a logged miss. Check that the duration can be added to the current clock value before calling AddTicks.
        if (envelope.SlidingTicks is { } slidingTicks)
        {
            var target = now.AddTicks(slidingTicks);

Comment thread src/UiPath.Caching/Distributed/UiPathDistributedCache.cs
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>

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.

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.SlidingExpiration permits values up to TimeSpan.MaxValue, and decoded foreign envelopes can contain the same value. now.AddTicks(long.MaxValue) then throws, turning Get/Refresh into an exception instead of a hit or logged miss. Bound or reject the duration before calling AddTicks (without mapping it to DateTimeOffset.MaxValue, which the Redis refresh path interprets as PERSIST).
            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 preserve IDistributedCache behavior.
        if (options.AbsoluteExpiration is { } absolute)

src/UiPath.Caching/Config/DistributedCacheCollectionExtensions.cs:36

  • Repeated AddDistributedCache calls produce a mismatched singleton: keyed provider/cache resolution selects the last registration for this shared key, while TryAddSingleton<IDistributedCache> keeps the first call's options and slideByRewrite closure. 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 as IDistributedCache.
        builder.Services.AddKeyedSingleton<ICacheProvider>(DistributedCacheServiceKey,
            (sp, _) => CreateProvider(sp, providerName, options));
        builder.Services.AddKeyedSingleton<ICache>(DistributedCacheServiceKey,
            (sp, key) => sp.GetRequiredKeyedService<ICacheProvider>(key!).CreateCache());

Comment thread src/UiPath.Caching.Abstractions/CacheKey.cs

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.

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 ISystemClock and 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 PolicyName silently resolves to null, 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

  • SlidingExpiration accepts positive values up to TimeSpan.MaxValue, and Redis can store such a TTL, but adding those ticks to the current time overflows here. The first Get/Refresh then 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.Delay only 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 controllable ISystemClock and 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>
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla-not-required Maintainer reviewed: no CLA required for this contribution

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants