refactor(cache)!: collapse serialization onto ISerializerProxy<byte[]> - #145
refactor(cache)!: collapse serialization onto ISerializerProxy<byte[]>#145cosmin-staicu wants to merge 1 commit into
Conversation
|
🔎 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
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.
|
8c3231f to
70eef47
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Null Redis values and mutable byte-array set keys introduce correctness regressions, and serializer registration documentation is inconsistent.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Consolidates caching serialization on ISerializerProxy<byte[]>, preserving the default wire format while introducing dedicated raw-byte handling.
Changes:
- Migrates caches, providers, broadcasts, and DI to byte-array serialization.
- Adds raw-byte serialization and structural set-member equality.
- Updates compatibility tests, public APIs, documentation, and migration guards.
File summaries
| File | Description |
|---|---|
tests/UiPath.Caching.Tests/SystemJsonSerializerProxyTests.cs |
Removes legacy serializer tests. |
tests/UiPath.Caching.Tests/SystemJsonByteSerializerProxyTests.cs |
Tests JSON/base64 byte behavior. |
tests/UiPath.Caching.Tests/SetCacheProviderTests.cs |
Uses the byte serializer. |
tests/UiPath.Caching.Tests/Redis/RedisSetCacheTests.cs |
Migrates Redis set tests. |
tests/UiPath.Caching.Tests/Redis/RedisHashCacheTests.cs |
Migrates Redis hash tests. |
tests/UiPath.Caching.Tests/Redis/RedisCacheTests.cs |
Migrates Redis cache tests. |
tests/UiPath.Caching.Tests/RawByteSerializerProxyTests.cs |
Tests raw-byte serialization. |
tests/UiPath.Caching.Tests/MultilayerSetCacheTests.cs |
Migrates multilayer set tests. |
tests/UiPath.Caching.Tests/LegacySerializerWireCompatTests.cs |
Verifies legacy wire compatibility. |
tests/UiPath.Caching.Tests/InMemorySetCacheTests.cs |
Tests structural member equality. |
tests/UiPath.Caching.Tests/InMemoryRedisCollectionExtensionsTests.cs |
Verifies byte change-token registration. |
tests/UiPath.Caching.Tests/Distributed/RedisValueSerializerProxyTests.cs |
Removes obsolete adapter tests. |
tests/UiPath.Caching.Tests/CachingBuilderTests.cs |
Tests serializer registration guards. |
tests/UiPath.Caching.Tests/Broadcast/ChangeTokenTests.cs |
Migrates change-token serialization. |
src/UiPath.Caching/SystemJsonSerializerProxy.cs |
Removes the Redis-specific serializer. |
src/UiPath.Caching/Redis/RedisHashCache.cs |
Uses byte-array serialization. |
src/UiPath.Caching/Redis/RedisCacheProvider.cs |
Changes the serializer dependency. |
src/UiPath.Caching/Redis/RedisCache.cs |
Uses byte-array serialization. |
src/UiPath.Caching/PublicAPI.Unshipped.txt |
Records core API changes. |
src/UiPath.Caching/Distributed/RedisValueSerializerProxy.cs |
Removes the bridging serializer. |
src/UiPath.Caching/Config/InMemoryRedisCollectionExtensions.cs |
Registers byte change tokens. |
src/UiPath.Caching/Config/DistributedCacheCollectionExtensions.cs |
Wires raw-byte serialization. |
src/UiPath.Caching/Config/CachingBuilder.cs |
Adds the legacy-registration guard. |
src/UiPath.Caching.Queue/RedisSetCache.cs |
Migrates Redis set serialization. |
src/UiPath.Caching.Queue/RedisQueueCacheProvider.cs |
Changes the serializer dependency. |
src/UiPath.Caching.Queue/PublicAPI.Unshipped.txt |
Records queue API changes. |
src/UiPath.Caching.Queue/MultilayerSetCache.cs |
Migrates the memory set tier. |
src/UiPath.Caching.Queue/MemorySetCache.cs |
Adds structural byte-array sets. |
src/UiPath.Caching.Queue/InMemoryRedisQueueCacheProvider.cs |
Changes the serializer dependency. |
src/UiPath.Caching.Queue/InMemoryQueueCacheProvider.cs |
Changes the serializer dependency. |
src/UiPath.Caching.Queue/GlobalUsings.cs |
Removes the global Redis import. |
src/UiPath.Caching.Queue/Config/QueueCacheCollectionExtensions.cs |
Updates API documentation. |
src/UiPath.Caching.Queue/ByteArrayEqualityComparer.cs |
Adds structural byte comparison. |
src/UiPath.Caching.Azure/GlobalUsings.cs |
Removes the global Redis import. |
src/UiPath.Caching.Azure/AzureEntraConnectionConfigurator.cs |
Adds its explicit Redis import. |
src/UiPath.Caching.Abstractions/SystemJsonByteSerializerProxy.cs |
Makes JSON the default for bytes. |
src/UiPath.Caching.Abstractions/RawByteSerializerProxy.cs |
Adds raw-byte passthrough. |
src/UiPath.Caching.Abstractions/PublicAPI.Unshipped.txt |
Records abstraction API changes. |
src/UiPath.Caching.Abstractions/Broadcast/IEventFormatterProxy.cs |
Removes obsolete string methods. |
docs/how-to/extending.md |
Rewrites serializer guidance. |
CHANGELOG.md |
Documents breaking changes. |
Review details
- Files reviewed: 41/41 changed files
- Comments generated: 5
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
740503d to
bde40a4
Compare
Addresses the Copilot review on #145. Two real defects, both verified. Serialize(null) returned an actual null, which reaches StackExchange.Redis as RedisValue.Null. Tested against Redis 7 with SE.Redis 3.1.13: SADD and the multi-field HSET throw ArgumentException ("A null value is not valid in this context"), SetContains throws, and the single-value HSET/SET paths silently store nothing. So a null set member or a null hash field with CacheNullValues off went from storing the legacy JSON null literal to throwing. Null now goes through JSON like every other value, which is also byte-identical to 1.x. MemorySetCache stored whatever the serializer returned, and RawByteSerializerProxy returns the caller's own array. A caller mutating it after the add would change an element's hash from inside the snapshot, corrupting membership and removal. The store paths now clone; the lookup paths still compare against the caller's array. Two documentation claims were wrong and are corrected: replacing the app-wide serializer does not reach the IDistributedCache adapter, which constructs its own RawByteSerializerProxy; and the empty-buffer contract has an exception, since RawByteSerializerProxy.Deserialize<byte[]> returns the empty array because an empty payload is a value there rather than an absence. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HNxcCLCZd8sBVdSGdh5C23 Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>
cd5f8f5 to
7906193
Compare
Every cache now serializes through the byte seam #121 introduced, and the RedisValue one is gone. The serialization contract no longer names a Redis type, so a custom serializer needs no StackExchange.Redis dependency and one registration covers ICache, IHashCache and ISetCache. Deletes SystemJsonSerializerProxy and the internal RedisValueSerializerProxy shim the distributed cache needed to bridge the two. Measured before committing to it: RedisValue -> ReadOnlyMemory<byte> and RedisValue -> byte[] allocate identically on every RedisValue backing (byte[]-backed is zero-copy and returns the same reference; string- and integer-backed allocate either way), so the zero-copy read path SystemJsonSerializerProxy appeared to buy costs nothing to give up. Two implementations ship, because raw byte passthrough is a requirement of one call site rather than of the seam: SystemJsonByteSerializerProxy (the DI default) - UTF-8 JSON for every value, byte-for-byte what SystemJsonSerializerProxy wrote. No stored entry changes format and nothing needs migrating, byte payloads and nulls included. RawByteSerializerProxy - byte payloads verbatim, JSON otherwise, and derives from the default so the shared TryDeserialize bodies live once and dispatch through the virtual Deserialize. AddDistributedCache constructs it for the provider it builds rather than resolving from DI, so replacing the app-wide serializer cannot change what the adapter stores: UiPathDistributedCache is byte[] end to end, encodes its own absexp/sldexp, and its payload is caller bytes ASP.NET Core has already serialized. Available as an app-wide opt-in, documented as a wire-format change needing a keyspace relocation. Adopting raw passthrough as the default was tried and rejected: it silently corrupted reads, because Deserialize<byte[]> passes bytes through rather than parsing them, so a 1.x base64-in-JSON entry handed the caller base64 ASCII with no exception and therefore no degradation to a cache miss. Sniffing for a quoted base64 payload cannot fix that - arbitrary raw bytes can begin and end with 0x22 - and a version header would break the MS-compatible layout the adapter's data field matches. Serialize(null) must not return null. Verified against Redis 7 with SE.Redis 3.1.13: RedisValue.Null makes SADD, the multi-field HSET and SetContains throw ArgumentException, and the single-value HSET/SET paths silently store nothing. Null therefore goes through JSON like every other value, which is also what 1.x wrote. MemorySetCache keyed its local snapshot on RedisValue, whose structural equality made Contains/Union/Except work. byte[] compares by reference, so the snapshot carries ByteArrayEqualityComparer -- without it every membership test misses, and MultilayerSetCache.ContainsItemAsync treats a populated local tier as authoritative and never corrects itself against the backing tier. Its store paths also clone, since a passthrough serializer returns the caller's own array and a later mutation would change an element's hash from inside the set. AddCaching throws when a leftover ISerializerProxy<RedisValue> registration is present. Nothing resolves that service any more, so it would otherwise be ignored and the application would quietly serialize as JSON instead of with the custom serializer. Also drops IEventFormatterProxy<T>.Decode(string) and EncodeAsString(T), obsolete since the broadcast path moved to ReadOnlyMemory<byte> and both dead, and narrows the StackExchange.Redis global using in the Queue and Azure projects to the one file in each that needs it. Rebased onto main after #144: its TryAddAsync serializes through the same seam now, and the RedisValue local it wrote has to be typed explicitly, because EmptyString and the byte[] the serializer returns convert to one another, so a var-typed conditional cannot pick between them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011QeZZPMo8bFYz98AuPZU2V Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>
7906193 to
1ea6b5b
Compare
|



Summary
ISerializerProxy<byte[]>— the seam #121 added for theIDistributedCacheadapter — becomes the library's only serialization seam, andISerializerProxy<RedisValue>is removed. The serialization contract no longer names a Redis type, so a custom serializer needs no StackExchange.Redis dependency and one registration coversICache,IHashCache,ISetCacheand the distributed adapter alike.No stored cache entry changes format and nothing needs migrating. The default serializer emits byte-for-byte what
SystemJsonSerializerProxyemitted; raw byte passthrough is scoped to the one call site that actually needs it.Changes
One seam.
RedisCache,RedisHashCache,RedisSetCache, the memory set tier and all four providers takeISerializerProxy<byte[]>; the broadcast change token is built asChangeTokenFactory<byte[]>.SystemJsonSerializerProxyand the internalRedisValueSerializerProxyshim (which existed only to bridge the two seams) are deleted.Two implementations, because raw passthrough turned out to be a property of one call site rather than of the seam:
SystemJsonByteSerializerProxy(default)TryAddSingletonRawByteSerializerProxyUiPathDistributedCacheisbyte[]end to end and encodes its ownabsexp/sldexpwithEncoding.UTF8.GetBytes, so it never asks the serializer to handle anything but abyte[]payload. Those bytes are the caller's, already serialized by ASP.NET Core — base64-ing them costs a third again in size for nothing, and a consumer swapping in MessagePack should not have their session bytes re-encoded.AddDistributedCachetherefore constructsRawByteSerializerProxydirectly rather than resolving from DI, so replacing the app-wide serializer cannot reach into what the adapter stores in itsdatafield.MemorySetCachestructural equality. The local snapshot was keyed onRedisValue, whose structural equality madeContains/Union/Exceptwork;byte[]compares by reference, so the snapshot now carries aByteArrayEqualityComparer. Without it every membership test misses — andMultilayerSetCache.ContainsItemAsynctreats a populated local tier as authoritative, so the wrong answer is never corrected against the backing tier. No perf cost:RedisValue.GetHashCodealready hashed the full payload.Startup guard.
AddCachingthrows when a leftoverISerializerProxy<RedisValue>registration is present. Nothing resolves that service any more, so it would otherwise be ignored and the application would quietly serialize as JSON instead of with the custom serializer.Docs (
docs/how-to/extending.md): the "Custom serializer" section is rewritten around the byte seam, the two implementations are tabulated with the reason each exists, and the old "swap both registrations" caveat is gone.Why the default keeps base64 for
byte[]Adopting raw passthrough app-wide was implemented first and rejected. It corrupts reads silently:
Deserialize<byte[]>passes bytes through rather than parsing them, so a 1.x base64-in-JSON entry hands the caller the base64 ASCII — no exception, so nothing degrades it to a cache miss. Two fixes were considered and don't work:0x22(~1 in 65536 at random, not rare at cache scale). It also reintroduces the format sniffing the design deliberately rejected.Microsoft.Extensions.Caching.StackExchangeRedis-compatible layout the adapter'sdatafield matches on purpose.Scoping passthrough to the adapter removes the problem rather than detecting it. Registering
RawByteSerializerProxyapp-wide stays available as an opt-in, documented as a wire-format change requiring a keyspace relocation.LegacySerializerWireCompatTestspins both halves, includingThe_raw_proxy_would_misread_a_legacy_byte_payload_silently, so the reason for the split can't be refactored away.On performance
The usual argument for keeping
RedisValueis that it avoids materializing abyte[]on reads. Measured against StackExchange.Redis 3.1.13 before committing to the change:→ ReadOnlyMemory<byte>→ byte[]byte[](what a JSON payload read off the wire becomes)stringlongThe two conversions are allocation-identical on every backing, so
SystemJsonSerializerProxy's apparent zero-copy read path costs nothing to give up.BREAKING
Source-level, not data-level. Six shipped constructor signatures change their serializer parameter type (
RedisCacheProvider,RedisSetCache,InMemoryQueueCacheProvider,InMemoryRedisQueueCacheProvider,RedisQueueCacheProvider, and the memory set tier), andSystemJsonSerializerProxyis gone. Consumers who pass these constructors a serializer directly, or who register a custom one, need a one-line type change — and the startup guard makes a missed one fail loudly.One wire difference, deliberate: a null value now serializes to a null payload rather than the four-byte JSON
nullliteral, following the seam's documented contract. Entries of either shape read back asdefault, and the caches normalize nulls before serializing, so it surfaces only for a deliberately-null value such as a null set member.Test plan
RawByteSerializerProxyTests,LegacySerializerWireCompatTests,SystemJsonByteSerializerProxyTestsrewritten for JSON-everything, a set-cache test pinning that members match by serialized bytes rather than by reference, and two startup-guard tests)dotnet test— 1464 passed, 0 failed, 10 skipped on both net8.0 and net10.0; build clean at the same 16 pre-existingCS0618warnings asmain)Linked issues
N/A — follow-up to #121.
Contributor declaration
git commit -s).🤖 Generated with Claude Code
https://claude.ai/code/session_01HNxcCLCZd8sBVdSGdh5C23