perf(cache): remove per-operation allocations on the Redis and memory hot paths - #214
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.
|
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
String L1 keys can corrupt shared application cache entries, and telemetry caching can retain collectible consumer types indefinitely.
Get a fresh assessment by requesting another Copilot review.
Review effort: Balanced
Findings: 1
Open (2)
What changed in this PR
Reduces allocations across Redis command execution, telemetry, key composition, and multilayer memory-cache hot paths.
Changes:
- Replaces async state machines and LINQ allocations with
ValueTaskwrappers and array conversions. - Optimizes telemetry, resilience-pipeline caching, key composition, and comparisons.
- Uses string keys for local memory caching and updates corresponding tests.
| File | Description |
|---|---|
tests/UiPath.Caching.Tests/MultilayerHashCacheTests.cs |
Updates memory-key expectations. |
tests/UiPath.Caching.Tests/MultilayerCacheTryAddTests.cs |
Updates TryAdd memory-key assertions. |
tests/UiPath.Caching.Tests/MultilayerCacheTests.cs |
Updates multilayer memory-key assertions. |
tests/UiPath.Caching.Tests/MemoryCacheSetterTests.cs |
Verifies string-key eviction behavior. |
tests/UiPath.Caching.Tests/LocalCacheSetterTests.cs |
Verifies string-key local caching. |
src/UiPath.Caching/TaskObservation.cs |
Adds task-to-ValueTask conversion. |
src/UiPath.Caching/Redis/ShardPrefixRedisKeyStrategy.cs |
Avoids string.Join allocation. |
src/UiPath.Caching/Redis/RedisHashCache.cs |
Removes redundant async state machines. |
src/UiPath.Caching/Redis/RedisCache.cs |
Optimizes callbacks and array conversions. |
src/UiPath.Caching/Redis/PrefixRedisKeyStrategy.cs |
Uses interpolation for key composition. |
src/UiPath.Caching/PrefixCacheKeyStrategy.cs |
Uses interpolation for prefixed keys. |
src/UiPath.Caching/MultilayerHashCache.cs |
Uses string L1 keys and ordinal filtering. |
src/UiPath.Caching/MultilayerCache.cs |
Optimizes arrays and string L1 keys. |
src/UiPath.Caching/MemoryCacheSetter.cs |
Stores entries under string keys. |
src/UiPath.Caching/LocalMemorySetter.cs |
Seals the local setter. |
src/UiPath.Caching/HashLocalMemorySetter.cs |
Seals the hash setter. |
src/UiPath.Caching/HashCacheEntryBuilder.cs |
Seals the hash builder. |
src/UiPath.Caching/CacheEntryBuilder.cs |
Seals the cache builder. |
src/UiPath.Caching/Broadcast/Redis/RedisStreamsTopic.cs |
Optimizes Redis stream callbacks. |
src/UiPath.Caching/Broadcast/Redis/RedisPubSubTopic.cs |
Optimizes Pub/Sub callbacks. |
src/UiPath.Caching/Broadcast/ChangeTokenFactory.cs |
Uses ordinal event comparison. |
src/UiPath.Caching/Broadcast/CacheEventFactory.cs |
Uses ordinal event comparison. |
src/UiPath.Caching.Queue/TaskObservation.cs |
Adds queue task conversion helper. |
src/UiPath.Caching.Queue/RedisSetCache.cs |
Optimizes Redis set callbacks. |
src/UiPath.Caching.Polly/ResiliencePipelineWrapper.cs |
Adds typed pipeline caches and state callbacks. |
src/UiPath.Caching.CloudEvents/CloudCacheEventFactory.cs |
Uses ordinal event comparison. |
src/UiPath.Caching.Abstractions/Telemetry/TelemetryOperation.cs |
Caches metric names and timestamp state. |
src/UiPath.Caching.Abstractions/Telemetry/NullTelemetryProvider.cs |
Adds allocation-free overload. |
src/UiPath.Caching.Abstractions/PublicAPI.Unshipped.txt |
Records the new public overload. |
src/UiPath.Caching.Abstractions/CacheOfT.cs |
Replaces LINQ array conversions. |
src/UiPath.Caching.Abstractions/Broadcast/TopicKey.cs |
Makes equality ordinal. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
844c05a to
c8e5f8c
Compare
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The static telemetry metric-name cache can grow indefinitely from public high-cardinality inputs.
Get a fresh assessment by requesting another Copilot review.
Review effort: Balanced
Findings: 1
Open (1)
Resolved since last review (2)
37081c5 to
79adbbb
Compare
c8e5f8c to
17f2e66
Compare
17f2e66 to
215df18
Compare
79adbbb to
f1900c3
Compare
f1900c3 to
be904d1
Compare
215df18 to
84731d0
Compare
The base branch was changed.
73115bd to
c292ef2
Compare
… hot paths - NullTelemetryProvider declares StartOperation(string, Type, string), so the interface default no longer builds a TelemetryOperation per call with telemetry off. TelemetryOperation caches its metric names and times with Stopwatch timestamps. - The Polly wrapper keeps one typed pipeline map per result type, so a lookup neither boxes the default nor allocates a factory closure, and the read path passes its callback as Polly state. - Single-command pipeline callbacks return the client task as a ValueTask instead of awaiting it in an async lambda. - Prefixed keys are composed without a params array. - TopicKey compares ordinally, consistent with its hash. The known event types and the hash field filter use OrdinalIgnoreCase, and the filter fills an immutable builder instead of running LINQ with two delegates. - Array projections use Array.ConvertAll, and internal builders are sealed. The single-command members of the three Redis caches, thirteen of them, shared one 20-line skeleton around the pipeline call; they now forward to RedisCacheBase.RunAsync. The TTL and expiration reads, with the pre-Redis-7 fallback from TTL to expiration, live in the base as KeyTimeToLiveAsync and KeyExpireTimeAsync over one SupportsExpireTime, and RedisHashCache reads a hash's fields in one method instead of two copies of the loop. Same behaviour, including a cancelled token being logged and answered with the fallback, and the TTL and expiration reads still composing the key inside the command so a null key is logged and answered the same way. The one difference: on a server without EXPIRETIME the fallback runs under the ExpireTimeAsync operation alone rather than also under a TimeToLiveAsync one. Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>
c292ef2 to
b8965d3
Compare
|





Stacked on #213. Review and merge that one first; this PR's base is
feat/resolve-key-prefix.Removes per-operation allocations on the Redis command path and the local memory path, in six changes:
StartOperation(string, Type, string).NullTelemetryProvidernever declared that overload, so the interface default built aTelemetryOperation, aStopwatchand a scope string on every call. It now returnsNullTelemetryOperation.Instance.TelemetryOperationalso caches its metric names and times withStopwatchtimestamps.(Type, object?), which boxed the default value on every command, and theGetOrAddfactory captured a closure. Now there is one typed map per result type, and the read path passes the callback to Polly as state.ValueTask.string.Join(char, params string[])is replaced by interpolation in the prefix key strategies.TopicKeyequality is now ordinal. Its name is already lowercased and it already hashes ordinally. The event-type sets and the hash field filter useOrdinalIgnoreCase, and the filter builds its result in a loop instead of LINQ withToImmutableDictionary.Array.ConvertAllreplacesSelect().ToArray()over arrays, and the internal builders and setters are sealed.Benchmark
A single-operation BenchmarkDotNet run with
MemoryDiagnoseragainst a local Redis on net10.0. The baseline is #213's head. Time on the Redis rows is dominated by the round trip, so the allocation column is the signal.GetAsyncSetAsyncGetAsync, memory hitGetAsync(fields), memory hitSetAsyncThe multilayer
SetAsyncrow is noise-bound. It also counts allocations from the background stream reader, and repeated runs of either build ranged from 13.2 KB to 16.0 KB.Behavior to review
OrdinalIgnoreCaseinstead ofInvariantCultureIgnoreCase. The event types are ASCII constants. Field names that differ only under culture rules no longer match.try, and Polly produces the same outcome either way.One shape for the single-command members
SonarQube flagged 10.8% duplicated lines on this PR's new code: the thirteen single-command members of
RedisCache,RedisHashCacheandRedisSetCache(ContainsAsync,RemoveAsync,TimeToLiveAsync,ExpireTimeAsync, the set'sCountAsync,ContainsItemAsync,RemoveItemAsync,RemoveItemsAsync) shared one 20-line skeleton around the pipeline call, and this PR touched two lines inside each. They now forward toRedisCacheBase.RunAsync, a protected helper that runs the command under the pipeline, times it, tracks the hit and logs a failure. The TTL and expiration reads, with the pre-Redis-7 fallback from TTL to expiration, live in the base asKeyTimeToLiveAsyncandKeyExpireTimeAsyncover oneSupportsExpireTime, andRedisHashCachereads a hash's fields in oneReadFieldsmethod instead of two copies of the loop. Behaviour is unchanged, including a cancelled token being logged and answered with the fallback, and the TTL and expiration reads still composing the key inside the command so a null key is logged and answered the same way (two tests per cache pin both). The one difference: on a server withoutEXPIRETIMEthe fallback runs under theExpireTimeAsyncoperation alone rather than also under aTimeToLiveAsyncone.Verification
dotnet build -c Release -warnaserrordotnet test: 4001 passed on net8.0 and net10.0, with the Redis integration tests on (RUN_REDIS_INTEGRATION_TESTS=1).