Skip to content

perf(cache)!: take the remaining allocations off the Redis command path - #215

Open
cosmin-staicu wants to merge 1 commit into
mainfrom
perf/breaking-hot-path
Open

cosmin-staicu wants to merge 1 commit into
mainfrom
perf/breaking-hot-path

Conversation

@cosmin-staicu

@cosmin-staicu cosmin-staicu commented Sep 26, 2026 •

Copy link
Copy Markdown
Member

Follows #214, now merged; the base is main.

This is the breaking follow-up to #214, for the unreleased major. It removes the per-operation allocations #214 could not reach without changing the API, in four changes:

  1. Redis key prefix as bytes (breaking).
    • PrefixRedisKeyStrategy built a new string for every key. It now encodes its prefix once and returns a RedisKey carrying that byte prefix next to the caller's name.
    • StackExchange.Redis writes both parts into the command buffer and hashes both for the cluster slot, so the key and slot are unchanged. I checked this against the client's own slot function, including hash tags that sit in the prefix and tags that span the boundary.
    • The cross-slot check also stops re-encoding the connector prefix for every key: ConnectorKeyPrefix keeps the bytes it reads.
    • The protected Prefix/Separator setters are removed, and a surrogate separator is refused: encoded apart from the name it could no longer pair with a name that starts with its low half.
  2. Pipeline state overload.
    • IResiliencePipeline.ExecuteAsync<TResult, TState> hands a state value to a static callback. The caches and topics use it, so a command no longer allocates a closure and a delegate.
    • The Polly wrapper boxes the state once. Polly copies its state into each strategy's async state machine, and a first cut that passed a wide struct measured worse than the closures.
    • The new member has a default implementation, so custom pipelines keep compiling.
  3. Pooled write buffers.
    • With the built-in JSON serializer, a write is serialized through a per-thread Utf8JsonWriter into an ArrayPool buffer, which the cache returns after the write. The pooled payload type is internal, so no caller outside the caches can return a buffer twice.
    • The bytes are identical to Serialize: custom options and runtime-type contracts are covered by tests. A subclass of the JSON proxy, like any other serializer, keeps lending its own memory.
    • Only writes use it. The write pipeline never abandons a command, so a buffer is never returned while the client may still be sending it.
  4. Telemetry scope as a struct (breaking).
    • With telemetry on, every operation allocated a TelemetryOperation. TelemetryScope replaces it: a struct timed by the cache's TimeProvider that reports through the provider's TrackMetric/TrackDependency with the same names and tags.
    • ITelemetryOperation, TelemetryOperation, NullTelemetryOperation and ICachingTelemetryProvider.StartOperation are removed.

TelemetryScope and the pooled writer live in UiPath.Caching. The abstractions package keeps only interfaces, null implementations and DTOs.

One follow-up from the #214 review: RedisCacheBase.SupportsExpireTime resolves the server version on first use instead of in the constructor, so constructing a cache that never reads an expiration, such as RedisSetCache, no longer connects. A test pins that construction does not touch Version.

Benchmark

A single-operation BenchmarkDotNet run with MemoryDiagnoser against a local Redis on net10.0. The telemetry-on column uses the OpenTelemetry provider, with a MeterListener subscribed the way an SDK would. Each row is the bytes allocated per call after that change. Time is dominated by the Redis round trip, so allocations are the signal.

After Get, telemetry off Get, telemetry on Set, telemetry off Set, telemetry on
#214 head 3121 B 3230 B 2360 B 2470 B
1. key prefix as bytes 3065 B 3175 B 2303 B 2407 B
2. pipeline state 3025 B 3135 B 2278 B 2384 B
3. pooled write buffers 3025 B 3135 B 2239 B 2342 B
4. telemetry scope 3041 B 3046 B 2261 B 2254 B
Total −80 B −184 B −99 B −216 B
  • Step 3 removes an array the size of the payload, so its saving grows with the value. The benchmark writes a 41-byte payload.
  • Step 4 adds 16 bytes with telemetry off: the scope lives in each async method's state machine, where it replaces an 8-byte reference. That is why it carries only a shared target and two timestamps. With telemetry on, it removes the per-operation object.
  • The memory-hit rows are unchanged at 32 B for a string hit and 576 B for a hash hit.
  • Multilayer SetAsync is left out. It also counts allocations from the background stream reader and varies by more than these steps between runs.

Migration

  • A telemetry provider that implements TrackMetric/TrackDependency/TrackEvent/TrackException needs no change.
  • Code that called StartOperation times an operation with new TelemetryScope(provider, timeProvider, providerName, method, type), calling Stop() and Track(hit, keyCount). The tag constants moved from TelemetryOperation to TelemetryScope, which is in the UiPath.Caching package.
  • A subclass of PrefixRedisKeyStrategy that assigned Prefix or Separator passes them to the constructor instead.

Verification

  • dotnet build -c Release -warnaserror
  • dotnet test: 4071 passed on net8.0 and net10.0, with the Redis integration tests on.

@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 (+691 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 Sep 26, 2026
@cosmin-staicu
cosmin-staicu requested a lite review from Copilot September 26, 2026 04:05

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.

Copilot review overview

🟡 Changes recommended

Three moderate findings remain unresolved, covering JSON validation, serializer overload resolution, and pooled-buffer cleanup.

Review effort: Lite
Findings: 1 Medium severity

Open (1)
What changed in this PR

This breaking PR reduces Redis command-path allocations through byte-based keys, stateful callbacks, pooled serialization buffers, and struct-based telemetry.

Changes:

  • Adds byte-prefix Redis keys and state-aware resilience callbacks.
  • Introduces pooled SerializedPayload write buffers.
  • Replaces telemetry operation objects with TelemetryScope.
  • Updates tests, documentation, and public API records.
File Summary
tests/​UiPath.Caching.Tests/​Telemetry/​TelemetryScopeTests.cs Tests struct-based telemetry scopes.
tests/​UiPath.Caching.Tests/​Telemetry/​TelemetryOperationTests.cs Updates legacy telemetry operation coverage.
tests/​UiPath.Caching.Tests/​SerializedPayloadTests.cs Tests pooled payload behavior.
tests/​UiPath.Caching.Tests/​ResiliencePipelineWrapperTests.cs Tests stateful pipeline execution.
tests/​UiPath.Caching.Tests/​ResiliencePipelineStateOverloadTests.cs Tests default interface compatibility.
tests/​UiPath.Caching.Tests/​Redis/​RedisHashCacheTests.cs Updates Redis hash behavior coverage.
tests/​UiPath.Caching.Tests/​Redis/​RedisCacheTests.cs Updates Redis cache behavior and telemetry coverage.
tests/​UiPath.Caching.Tests/​Redis/​ConnectorKeyPrefixTests.cs Tests preserved connector prefix bytes.
src/​UiPath.Caching/​Redis/​ShardPrefixRedisKeyStrategy.cs Uses byte-based key prefixes.
src/​UiPath.Caching/​Redis/​RedisHashCache.cs Uses state callbacks and pooled hash writes; rented buffers need cleanup on pre-write failures.
src/​UiPath.Caching/​Redis/​RedisCacheBase.cs Updates prefix and telemetry handling.
src/​UiPath.Caching/​Redis/​RedisCache.cs Uses pooled writes and state callbacks; multi-key telemetry allocation should check IsEnabled.
src/​UiPath.Caching/​Redis/​PrefixRedisKeyStrategy.cs Stores encoded prefixes.
src/​UiPath.Caching/​Redis/​ConnectorKeyPrefix.cs Preserves resolved prefix bytes.
src/​UiPath.Caching/​PublicAPI.Unshipped.txt Records API changes.
src/​UiPath.Caching/​Broadcast/​Redis/​RedisStreamsTopic.cs Uses stateful callbacks.
src/​UiPath.Caching/​Broadcast/​Redis/​RedisPubSubTopic.cs Uses stateful callbacks.
src/​UiPath.Caching.Queue/​RedisSetCache.cs Uses stateful Redis callbacks.
src/​UiPath.Caching.Polly/​ResiliencePipelineWrapper.cs Adapts state callbacks to Polly.
src/​UiPath.Caching.Abstractions/​Telemetry/​TelemetryScope.cs Adds struct-based telemetry timing.
src/​UiPath.Caching.Abstractions/​Telemetry/​TelemetryOperation.cs Removes the legacy telemetry operation.
src/​UiPath.Caching.Abstractions/​Telemetry/​NullTelemetryProvider.cs Updates no-op telemetry support.
src/​UiPath.Caching.Abstractions/​Telemetry/​NullTelemetryOperation.cs Removes legacy null operation support.
src/​UiPath.Caching.Abstractions/​Telemetry/​ITelemetryOperation.cs Removes the legacy telemetry interface.
src/​UiPath.Caching.Abstractions/​Telemetry/​ICachingTelemetryProvider.cs Updates the telemetry provider contract.
src/​UiPath.Caching.Abstractions/​SystemJsonByteSerializerProxy.cs Adds pooled serialization; method lookup must handle derived overloads unambiguously.
src/​UiPath.Caching.Abstractions/​SerializedPayload.cs Adds disposable pooled payload ownership.
src/​UiPath.Caching.Abstractions/​PublicAPI.Unshipped.txt Records abstraction API changes.
src/​UiPath.Caching.Abstractions/​PooledJsonWriter.cs Adds pooled JSON writing; validation must remain enabled for byte-compatible output.
src/​UiPath.Caching.Abstractions/​Policies/​IResiliencePipeline.cs Adds state-aware execution overloads.
src/​UiPath.Caching.Abstractions/​Policies/​EmptyResiliencePipeline.cs Implements state-aware execution.
src/​UiPath.Caching.Abstractions/​IMemorySerializerProxy.cs Adds the payload serialization contract.
docs/​reference/​interfaces.md Updates interface documentation.
docs/​how-to/​telemetry-and-strategies.md Documents telemetry migration.
docs/​concepts.md Updates telemetry terminology.
CHANGELOG.md Documents breaking and performance changes.

💡 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/Redis/RedisHashCache.cs Outdated

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.

Copilot review overview

🟡 Changes recommended

Address the null serialization contract issue and the unnecessary per-key telemetry allocation.

Review effort: Lite
Findings: 1 Medium severity · 1 Low severity

Open (2)
Resolved since last review (1)

Comment thread src/UiPath.Caching.Abstractions/PooledJsonWriter.cs Outdated
Comment thread src/UiPath.Caching/Redis/RedisCache.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.

Copilot review overview

🔵 Needs a closer look

Two moderate unresolved findings affect telemetry timing and allocations for supported custom serializers.

Review effort: Lite
Findings: None

Resolved since last review (2)

@cosmin-staicu

Copy link
Copy Markdown
Member Author

Benchmark across the stack

These are the three PR heads measured in one session with the same harness: #213 (37081c5), #214 (c8e5f8c) and this PR (0eadcc9). The harness is a single-operation BenchmarkDotNet run with MemoryDiagnoser against a local Redis on net10.0. For telemetry on, it uses the OpenTelemetry provider with a MeterListener subscribed.

Bytes allocated per call

Operation #213 +#214 +#215 Change
Redis get, telemetry off 3721 3128 3041 −680 (−18%)
Redis get, telemetry on 3733 3237 3046 −687 (−18%)
Redis set, telemetry off 3014 2359 2254 −760 (−25%)
Redis set, telemetry on 3014 2463 2254 −760 (−25%)
Hash get with fields, memory hit 896 576 576 −320 (−36%)
String get, memory hit 32 32 32 0

At #213, telemetry off allocates as much as telemetry on, because NullTelemetryProvider still built a TelemetryOperation for every call. #214 fixes that.

Time per call

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.

Copilot review overview

🟡 Changes recommended

Resolve pooled payload double-disposal, JSON validation, and uncovered metadata serialization issues.

Review effort: Lite
Findings: 1 High severity

Open (1)

Comment thread src/UiPath.Caching.Abstractions/SerializedPayload.cs Outdated

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.

Copilot review overview

🟡 Changes recommended

The critical pooled-writer validation issue and moderate metadata-allocation issue remain unresolved.

Review effort: Lite
Findings: 1 High severity

Open (1)
Resolved since last review (1)

Comment thread src/UiPath.Caching/PooledJsonWriter.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.

Copilot review overview

🟡 Changes recommended

A synchronous transaction-queueing failure can leak a rented metadata buffer, and critical segmented-key slot behavior lacks regression coverage.

Review effort: Balanced
Findings: 1 Medium severity · 1 Low severity

Open (2)

Comment thread src/UiPath.Caching/Redis/RedisHashCache.cs
Comment thread src/UiPath.Caching/Redis/PrefixRedisKeyStrategy.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.

Copilot review overview

🟡 Changes recommended

The new buffer-return test relies on ArrayPool.Shared returning the same instance, which its contract does not guarantee.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
Resolved since last review (2)

Comment thread tests/UiPath.Caching.Tests/Redis/RedisHashCacheTests.cs Outdated
@cosmin-staicu
cosmin-staicu force-pushed the perf/breaking-hot-path branch 2 times, most recently from 95ae7db to db70dcb Compare September 28, 2026 17:25
@cosmin-staicu
cosmin-staicu requested a balanced review from Copilot September 28, 2026 17:25

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.

Copilot review overview

🟡 Changes recommended

Pooled serialization bypasses custom JsonConverter<object> implementations and can change persisted Redis bytes.

Review effort: Balanced
Findings: 1 High severity

Open (1)
Resolved since last review (1)

Comment thread src/UiPath.Caching/PooledJsonWriter.cs Outdated

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.

Copilot review overview

🟡 Changes recommended

Key compatibility, telemetry timestamps, and pooled-buffer cleanup still have unresolved correctness issues.

Review effort: Balanced
Findings: 3 Medium severity

Open (3)
Resolved since last review (1)

Comment thread src/UiPath.Caching/Redis/PrefixRedisKeyStrategy.cs
Comment thread src/UiPath.Caching/Redis/RedisHashCache.cs
Comment thread src/UiPath.Caching/Telemetry/TelemetryScope.cs Outdated
- PrefixRedisKeyStrategy encodes its prefix once and returns a RedisKey that
  carries it as a byte prefix next to the caller's name, so no key string is
  built per command. The cross-slot check uses the connector prefix bytes
  ConnectorKeyPrefix already reads.
- IResiliencePipeline gains ExecuteAsync<TResult, TState>, which the caches
  and topics call with static lambdas. The Polly wrapper boxes the state
  once, since Polly copies its state into each strategy's state machine.
- With the built-in JSON serializer, writes serialize into a buffer rented
  from ArrayPool<byte>.Shared and return it once the write has completed.
  The pooled payload stays internal, so no caller can return a buffer
  twice. The bytes match Serialize, and a subclass of the JSON proxy, like
  any other serializer, lends its own memory.
- The caches time each operation with TelemetryScope, a struct read from the
  cache's TimeProvider, instead of allocating a TelemetryOperation.
- TelemetryScope and the pooled writer live in UiPath.Caching, since the
  abstractions package holds only interfaces, null implementations and
  DTOs.

BREAKING CHANGE: PrefixRedisKeyStrategy.Prefix and Separator lose their
protected setters. ITelemetryOperation, TelemetryOperation,
NullTelemetryOperation, ICachingTelemetryProvider.StartOperation and
NullTelemetryProvider's StartOperation overloads are removed, and
RedisCacheBase.TrackRead takes a TelemetryScope. A telemetry provider that
implements only the Track methods is unaffected.

RedisCacheBase.SupportsExpireTime resolves the server version on first
use instead of in the constructor, so constructing a cache that never
reads an expiration, such as RedisSetCache, no longer connects.

A surrogate separator is refused with ArgumentException: encoded apart
from the key name it could no longer pair with a name that starts with
its low half, so the wire key would change.

Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>
@sonarqubecloud

Copy link
Copy Markdown

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.

Copilot review overview

🟡 Changes recommended

The benchmark rationale is stale after the telemetry scope gained an additional timestamp field, and its allocation claims need revalidation.

Review effort: Balanced
Findings: 2 Low severity

Open (2)
Resolved since last review (3)


namespace UiPath.Caching.Telemetry;

/// <summary>Times one cache operation and reports it; a struct, so an operation allocates nothing for its telemetry.</summary>

private readonly Target? _target;
private readonly long _startTimestamp;
private readonly long _startUtcTicks;

This branch has not been deployed

No deployments
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