Skip to content

feat(cache): IBufferDistributedCache, with a caller's buffer reaching Redis with no array in between - #152

Open
cosmin-staicu wants to merge 2 commits into
feat/default-expiration-floorfrom
feat/buffer-distributed-cache
Open

feat(cache): IBufferDistributedCache, with a caller's buffer reaching Redis with no array in between#152
cosmin-staicu wants to merge 2 commits into
feat/default-expiration-floorfrom
feat/buffer-distributed-cache

Conversation

@cosmin-staicu

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

Copy link
Copy Markdown
Member

Summary

Stacked on #151 — review that first; this branch is based on feat/default-expiration-floor and the diff below is only these two commits.

The adapter AddDistributedCache registers now also implements IBufferDistributedCache, the buffer-based half of the same contract. 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, exactly how AddStackExchangeRedisCache exposes 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

  • One read and write path. Both halves go through the same key composition, expiration resolution, sliding and logging, so an entry written through either is readable through the other — and by any other client on the conventional data/absexp/sldexp layout.
  • TryGet separates the hit from the payload. An entry stored with an empty payload is a hit that writes no bytes, where Get returns an empty array for that and for a miss.
  • NullDistributedCache implements it too, so switching caching off changes only whether anything is found, not which half a consumer discovers.
  • Compiled for net9.0+. The net8.0 floor pins Microsoft.Extensions.Caching.Abstractions to 8.0.0, which predates the interface, and PackageVersionFloorTests holds 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>. On Redis, a single-segment sequence handed to SetAsync goes to the connection as the caller's own memory; a segmented one is flattened into an ArrayPool buffer 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:

  1. IMemorySerializerProxy (new, optional) refines ISerializerProxy<byte[]> with ReadOnlyMemory<byte> SerializeToMemory<T>(T? value). The copy was inside the serializer, forced by the byte[] return type. RawByteSerializerProxy answers with the memory it was given; 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 configured serializer offers it. Generic so a struct value isn't boxed on the way in.
  2. ReadOnlyMemory<byte> is a cacheable value type. NotCacheableException rejected every non-nullable struct — which is what had kept the raw serializer's existing ReadOnlyMemory<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.
  3. Tick fields parse and format straight from UTF-8 (Utf8Parser/Utf8Formatter); the -1 sentinel is a shared static. Two strings per read and one per write gone.

Safety of borrowed memory. The memory handed to RedisValue is 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 on IResiliencePipeline, now documented: ExecuteAsync must 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: DefaultTopicKeyStrategy rebuilt a generic type's "friendly name" with a StringBuilder on 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 --inProcess against 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:

Method Tier Payload Before After
SetSequence Redis 16 KB 22.53 KB 6.64 KB
SetArray Redis 16 KB 6.56 KB 6.54 KB
SetSequence Redis 128 B 6.64 KB 6.64 KB
Get / TryGet Redis 128 B 4.22 / 4.17 KB 4.19 / 4.14 KB
Get / TryGet Redis 16 KB 20.12 / 20.07 KB 20.10 / 20.04 KB
Get / TryGet InMemory 128 B 1.24 / 1.17 KB 1.16 / 1.09 KB
SetArray InMemory 128 B 1.83 KB 1.72 KB
SetSequence InMemory 16 KB 17.85 KB 17.74 KB (owned copy, by design)

Reads 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 HostHelper looked for a Sample.AspNetCore folder that no longer exists — the project couldn't run. Fixed to samples/UiPath.Caching.Sample.

Public API

UiPath.Caching.Abstractions: IMemorySerializerProxy + SerializeToMemory<T> on both shipped serializers (PublicAPI.Unshipped.txt). NotCacheableException accepts one more type. IResiliencePipeline gains a contract remark. Nothing in UiPath.Caching's public surface changes — the adapter and both Redis caches are internal.

Test plan

  • dotnet test, Debug and Release, both TFMs, Redis up: 1611 passed net10.0 / 1590 passed net8.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).
  • Adapter: pass-through on the Redis-tier shape hands over the caller's backing array itself; retaining tiers get a different array with the same bytes; segmented sequences flatten correctly; empty sequence is a hit that writes no bytes; Get still returns the stored array without copying; all existing rejected-metadata rows still miss under Utf8Parser (probed: +12 accepted, lone sign / partial parse / whitespace rejected, matching the old long.TryParse).
  • Serializers: SerializeToMemory returns the caller's memory (same array, offset and length) for ReadOnlyMemory<byte>, Memory<byte> and byte[]; JSON for everything else; the JSON serializer still base64-encodes bytes through the memory member.
  • Redis caches: a memory-capable serializer is asked for memory and its array member never runs; a plain serializer keeps the array path.
  • Resilience: the shipped pipeline waits for a callback that ignores cancellation.
  • DefaultTopicKeyStrategy: repeated lookups of a generic type allocate 0 bytes; ReadOnlyMemory<byte>readonlymemory:byte.
  • Redis integration: segmented write lands as one contiguous data field; both halves round-trip.
  • CHANGELOG, docs/reference/settings.md, docs/how-to/extending.md (new "Lending memory" section), benchmarks README.

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.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FG88VqBf4fzJQdxNxTcqWq

…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>
@cosmin-staicu cosmin-staicu changed the title feat(cache): implement IBufferDistributedCache on the distributed adapter feat(cache): IBufferDistributedCache, with a caller's buffer reaching Redis with no array in between Sep 5, 2026
@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 5, 2026
@github-actions

github-actions Bot commented Sep 5, 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.Abstractions)

Other signals

  • large production change (+311 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.

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.

🔵 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 IBufferDistributedCache on the AddDistributedCache adapter (and NullDistributedCache) for net9.0+, sharing the same read/write logic as the array-based API.
  • Introduce IMemorySerializerProxy and update Redis tiers to prefer SerializeToMemory<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.

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.

2 participants