feat(cache): IBufferDistributedCache, with a caller's buffer reaching Redis with no array in between - #152
Conversation
…pter The adapter registered by AddDistributedCache now also implements the buffer-based half of the IDistributedCache contract, so callers that already own a buffer -- HybridCache as an L2, output caching, session state -- read into an IBufferWriter<byte> and write from a ReadOnlySequence<byte> instead of trading a fresh array per operation. Discovery is the framework's own: a type check on the resolved IDistributedCache, the way AddStackExchangeRedisCache exposes it. Nothing extra is registered and no consumer code changes. Both halves share one read and write path, so keyspace, expiration, sliding and the stored fields are identical either way and an entry written through one is readable through the other. The shared read now hands back the fields it read rather than a payload, which is what lets TryGet report hit and payload separately -- an entry stored empty is a hit that writes no bytes, where Get returns an empty array for that and for a miss alike. Writes copy the sequence rather than alias it: the memory tiers keep whatever array they are handed, and a pooled caller reclaims its buffer as soon as the call returns. NullDistributedCache implements it too, so switching caching off changes only whether anything is found, not which half a consumer discovers. Compiled for net9.0 and later. The net8.0 floor pins Microsoft.Extensions.Caching.Abstractions to 8.0.0, which predates the interface and which PackageVersionFloorTests holds on its own major; the type check simply comes up empty there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FG88VqBf4fzJQdxNxTcqWq Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>
The distributed adapter now moves its values as ReadOnlyMemory<byte> rather than byte[]. On the Redis tier a single-segment sequence handed to IBufferDistributedCache.SetAsync goes to the connection as the caller's own memory; a segmented one is flattened into a rented buffer that is returned once the write has been awaited. The memory tiers keep what they are handed, so there the sequence is still copied into an owned array -- registration tells the adapter which kind of tier it is on. Three things had to give for that: - ISerializerProxy<byte[]> returns an array, so the copy was inside the serializer. IMemorySerializerProxy refines it with SerializeToMemory<T>(T?), which RawByteSerializerProxy answers with the memory it was given and SystemJsonByteSerializerProxy with its array wrapped, so the wire format does not depend on which member a tier calls. RedisCache and RedisHashCache ask for memory when the serializer offers it. The memory is borrowed and valid only until the operation completes; the Redis tier honors that by copying into the connection's buffer as the command is written and awaiting the write to completion. That await is a contract on IResiliencePipeline as well -- a timeout has to be cooperative -- and a test pins that the shipped Polly pipeline waits for a callback that ignores its token. - NotCacheableException rejected every non-nullable struct, which is what kept the raw serializer's existing ReadOnlyMemory<byte> branches unreachable. The type is admitted on the terms the rule exists for: its default is empty memory, which the tiers already read as absent. - The expiration fields are parsed and formatted straight from UTF-8 (Utf8Parser/Utf8Formatter); the "-1" sentinel is a shared static. Moving to a generic value type exposed a pre-existing per-operation cost on the memory tiers: DefaultTopicKeyStrategy rebuilt a generic type's friendly name with a StringBuilder on every call, about 260 bytes each. It now memoizes per type. DistributedCacheBenchmark measures both halves per tier and payload size. With a 16 KB payload the Redis SetSequence allocation drops from 22.5 KB to 6.6 KB, level with the array overload; every other row is flat or slightly down. The harness's sample path had gone stale after the samples moved; fixed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FG88VqBf4fzJQdxNxTcqWq 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.
🔵 Needs a closer look
It introduces new API surfaces and a borrowed-memory optimization path whose correctness depends on subtle async/timeout and tier-retention contracts.
Pull request overview
Adds buffer-based distributed caching support (IBufferDistributedCache) and threads ReadOnlyMemory<byte> through the adapter and Redis tiers so caller-owned buffers can be written to Redis without allocating intermediate arrays, while keeping wire-compatibility with the existing data/absexp/sldexp layout.
Changes:
- Implement
IBufferDistributedCacheon theAddDistributedCacheadapter (andNullDistributedCache) fornet9.0+, sharing the same read/write logic as the array-based API. - Introduce
IMemorySerializerProxyand update Redis tiers to preferSerializeToMemory<T>when available to avoid array materialization on writes. - Optimize distributed-cache metadata encoding/decoding to parse/format ticks directly from UTF-8 and memoize topic keys per type; add/extend tests, docs, changelog, and benchmarks.
File summaries
| File | Description |
|---|---|
| tests/UiPath.Caching.Tests/SystemJsonByteSerializerProxyTests.cs | Adds coverage that JSON serializer’s memory path preserves existing base64-in-JSON wire bytes. |
| tests/UiPath.Caching.Tests/ResiliencePipelineFactoryTests.cs | Pins pipeline behavior to await callbacks (important for borrowed-memory safety). |
| tests/UiPath.Caching.Tests/Redis/RedisHashCacheTests.cs | Verifies Redis hash cache uses memory-capable serializer path (no array member invoked). |
| tests/UiPath.Caching.Tests/Redis/RedisCacheTests.cs | Verifies Redis string cache uses memory-capable serializer path for writes. |
| tests/UiPath.Caching.Tests/RawByteSerializerProxyTests.cs | Adds tests for SerializeToMemory passthrough semantics (same backing array/window). |
| tests/UiPath.Caching.Tests/NotCacheableExceptionTests.cs | Updates cacheability rules/tests to admit ReadOnlyMemory<byte> and reject related types. |
| tests/UiPath.Caching.Tests/Distributed/UiPathDistributedCacheTests.cs | Migrates distributed adapter tests to ReadOnlyMemory<byte> value flow and UTF-8 tick parsing/formatting. |
| tests/UiPath.Caching.Tests/Distributed/UiPathBufferDistributedCacheTests.cs | New tests for IBufferDistributedCache behavior, keying, sliding, and buffer retention vs pass-through tiers. |
| tests/UiPath.Caching.Tests/Distributed/DistributedCacheRedisIntegrationTests.cs | Adds net9+ Redis integration coverage for buffer-half segmented writes landing contiguously on the wire. |
| tests/UiPath.Caching.Tests/Distributed/DistributedCacheEndToEndTests.cs | Adds net9+ end-to-end interoperability test between buffer and array halves. |
| tests/UiPath.Caching.Tests/Broadcast/DefaultTopicKeyStrategy.cs | Adds topic-key expectations for ReadOnlyMemory<byte> and a regression test for zero allocations on repeated lookups. |
| src/UiPath.Caching/Redis/RedisHashCache.cs | Uses IMemorySerializerProxy when available to serialize hash field values to borrowed memory. |
| src/UiPath.Caching/Redis/RedisCache.cs | Uses IMemorySerializerProxy when available to serialize values to borrowed memory on string writes. |
| src/UiPath.Caching/Distributed/UiPathDistributedCache.cs | Switches adapter payload/fields to ReadOnlyMemory<byte>, adds shared tick sentinel bytes, and uses UTF-8 parser/formatter. |
| src/UiPath.Caching/Distributed/UiPathDistributedCache.Buffers.cs | Implements IBufferDistributedCache with pass-through vs copy behavior depending on tier retention. |
| src/UiPath.Caching/Distributed/NullDistributedCache.cs | Converts to partial to support optional buffer-half implementation on net9+. |
| src/UiPath.Caching/Distributed/NullDistributedCache.Buffers.cs | Implements IBufferDistributedCache no-op behavior for null cache on net9+. |
| src/UiPath.Caching/Config/DistributedCacheCollectionExtensions.cs | Wires tierRetainsValues based on provider kind when constructing the distributed adapter. |
| src/UiPath.Caching/Broadcast/DefaultTopicKeyStrategy.cs | Memoizes computed topic keys per Type to remove per-operation allocations. |
| src/UiPath.Caching.Abstractions/SystemJsonByteSerializerProxy.cs | Implements IMemorySerializerProxy via wrapping existing JSON byte[] output as memory. |
| src/UiPath.Caching.Abstractions/RawByteSerializerProxy.cs | Adds SerializeToMemory<T> override to return caller memory for byte-memory types when possible. |
| src/UiPath.Caching.Abstractions/PublicAPI.Unshipped.txt | Records new/updated public surface for IMemorySerializerProxy and serializer method additions. |
| src/UiPath.Caching.Abstractions/Policies/IResiliencePipeline.cs | Documents required “await callback completion” contract for safety with borrowed memory. |
| src/UiPath.Caching.Abstractions/NotCacheableException.cs | Updates cacheability rule to allow ReadOnlyMemory<byte> and improves message. |
| src/UiPath.Caching.Abstractions/IMemorySerializerProxy.cs | New abstraction for serializers that can lend ReadOnlyMemory<byte> without materializing arrays. |
| docs/reference/settings.md | Documents IBufferDistributedCache behavior, discovery, and tier-specific buffer handling. |
| docs/how-to/extending.md | Documents IMemorySerializerProxy contract and safety implications (“lending memory”). |
| CHANGELOG.md | Adds release notes for buffer-half support, memory serializer seam, cacheability change, and memoized topic keys. |
| benchmarks/UiPath.Caching.Benchmarks/README.md | Updates benchmark instructions and documents new distributed-cache benchmark. |
| benchmarks/UiPath.Caching.Benchmarks/HostHelper.cs | Fixes sample path discovery and allows benchmark-specific caching builder configuration. |
| benchmarks/UiPath.Caching.Benchmarks/DistributedCacheBenchmark.cs | New benchmark covering array vs buffer APIs across tiers and payload sizes with allocation focus. |
Review details
- Files reviewed: 31/31 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Summary
Stacked on #151 — review that first; this branch is based on
feat/default-expiration-floorand the diff below is only these two commits.The adapter
AddDistributedCacheregisters now also implementsIBufferDistributedCache, the buffer-based half of the same contract. Callers that already own a buffer —HybridCacheas an L2, output caching, session state — read into anIBufferWriter<byte>and write from aReadOnlySequence<byte>instead of trading a fresh array per operation. Discovery is the framework's own: a type check on the resolvedIDistributedCache, exactly howAddStackExchangeRedisCacheexposes it. Nothing extra is registered, and no consumer code changes.The second commit makes the buffer half pay all the way down on the Redis tier: a caller's memory reaches the connection with no array in between.
Commit 1 — the interface
data/absexp/sldexplayout.TryGetseparates the hit from the payload. An entry stored with an empty payload is a hit that writes no bytes, whereGetreturns an empty array for that and for a miss.NullDistributedCacheimplements it too, so switching caching off changes only whether anything is found, not which half a consumer discovers.net9.0+. Thenet8.0floor pinsMicrosoft.Extensions.Caching.Abstractionsto 8.0.0, which predates the interface, andPackageVersionFloorTestsholds that group on its own major by design. There the type check comes up empty and consumers stay on the array path.Commit 2 — no array on the way to Redis
The adapter's values now travel as
ReadOnlyMemory<byte>. OnRedis, a single-segment sequence handed toSetAsyncgoes to the connection as the caller's own memory; a segmented one is flattened into anArrayPoolbuffer returned once the write has been awaited. The memory tiers keep what they are handed (their L1 stores the written dictionary as-is), so there the sequence is still copied into an owned array — registration tells the adapter which kind of tier it is on (tierRetainsValues).Three things had to give:
IMemorySerializerProxy(new, optional) refinesISerializerProxy<byte[]>withReadOnlyMemory<byte> SerializeToMemory<T>(T? value). The copy was inside the serializer, forced by thebyte[]return type.RawByteSerializerProxyanswers with the memory it was given;SystemJsonByteSerializerProxywith its array wrapped, so the wire format does not depend on which member a tier calls.RedisCacheandRedisHashCacheask for memory when the configured serializer offers it. Generic so a struct value isn't boxed on the way in.ReadOnlyMemory<byte>is a cacheable value type.NotCacheableExceptionrejected every non-nullable struct — which is what had kept the raw serializer's existingReadOnlyMemory<byte>branches unreachable through any cache. It's admitted on the terms the rule exists for: its default is empty memory, which the tiers already read as absent.Memory<byte>stays out.Utf8Parser/Utf8Formatter); the-1sentinel is a shared static. Two strings per read and one per write gone.Safety of borrowed memory. The memory handed to
RedisValueis valid only until the caller's operation completes. That's sound because (a) StackExchange.Redis copies the value into the connection's buffer as the command is written and completes the task only after — a command that times out in SE.Redis has either been written or pruned unsent — and (b) the write is awaited to completion. (b) is a contract onIResiliencePipeline, now documented:ExecuteAsyncmust not return around a callback still in flight. Polly v8's timeout is cooperative — it cancels the token and awaits the callback — and a new test pins that the shipped pipeline waits for a callback that ignores its token. A custom pipeline that abandoned callbacks would break this, hence the doc.A pre-existing cost surfaced. Moving to a generic value type made the memory tiers ~260 B/op worse at first. Root cause:
DefaultTopicKeyStrategyrebuilt a generic type's "friendly name" with aStringBuilderon every operation —ICache<List<T>>had always paid this;byte[]hadn't because its name is a cached lookup. It now memoizes per type. Keys are unchanged.Benchmarks
DistributedCacheBenchmark(new;MemoryDiagnoser, both halves ×Redis/InMemory× 128 B / 16 KB).--job short --inProcessagainst a local Redis. Allocations per operation — timings in the after-run were taken while the test suite ran concurrently, so only the allocation columns are cited:SetSequenceSetArraySetSequenceGet/TryGetGet/TryGetGet/TryGetSetArraySetSequenceReads on Redis still allocate the payload: SE.Redis has already materialized it by the time we see a
RedisValue, and its lease API is single-value only, so going lease-based would cost a round trip against the three-field HMGET. Not worth it; left as-is.The harness's
HostHelperlooked for aSample.AspNetCorefolder that no longer exists — the project couldn't run. Fixed tosamples/UiPath.Caching.Sample.Public API
UiPath.Caching.Abstractions:IMemorySerializerProxy+SerializeToMemory<T>on both shipped serializers (PublicAPI.Unshipped.txt).NotCacheableExceptionaccepts one more type.IResiliencePipelinegains a contract remark. Nothing inUiPath.Caching's public surface changes — the adapter and both Redis caches areinternal.Test plan
dotnet test, Debug and Release, both TFMs, Redis up: 1611 passednet10.0/ 1590 passednet8.0, 0 failed, 0 skipped (one live-Redis profiler test flaked once on net8.0 in Debug and passed in isolation and in Release; unrelated).Getstill returns the stored array without copying; all existing rejected-metadata rows still miss underUtf8Parser(probed:+12accepted, lone sign / partial parse / whitespace rejected, matching the oldlong.TryParse).SerializeToMemoryreturns the caller's memory (same array, offset and length) forReadOnlyMemory<byte>,Memory<byte>andbyte[]; JSON for everything else; the JSON serializer still base64-encodes bytes through the memory member.DefaultTopicKeyStrategy: repeated lookups of a generic type allocate 0 bytes;ReadOnlyMemory<byte>→readonlymemory:byte.datafield; both halves round-trip.docs/reference/settings.md,docs/how-to/extending.md(new "Lending memory" section), benchmarks README.Contributor declaration
git commit -s).🤖 Generated with Claude Code
https://claude.ai/code/session_01FG88VqBf4fzJQdxNxTcqWq