perf(cache)!: read the local tier by span and key it by the name string - #217
cosmin-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
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.
|
4e8ffb3 to
0940878
Compare
bffb69e to
cbf41cd
Compare
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Span fast paths bypass cancellation checks in both multilayer cache implementations.
Review effort: Lite
Findings: 1
Open (2)
What changed in this PR
Adds allocation-free span-based cache reads, string-keyed local entries, optimized writes, tests, documentation, and benchmarks.
Changes:
- Adds span overloads and span-based
CacheKeynormalization. - Optimizes local cache lookup, prefix composition, and entry writes.
- Adds coverage, API documentation, changelog updates, and benchmarks.
| File | Summary |
|---|---|
tests/UiPath.Caching.Tests/SpanKeyReadTests.cs |
Tests span reads and allocations. |
tests/UiPath.Caching.Tests/PrefixCacheKeyStrategyTests.cs |
Tests span key composition. |
tests/UiPath.Caching.Tests/MultilayerHashCacheTests.cs |
Tests hash cache behavior and string keys. |
tests/UiPath.Caching.Tests/MultilayerCacheTryAddTests.cs |
Updates local-key expectations. |
tests/UiPath.Caching.Tests/MultilayerCacheTests.cs |
Tests span reads and local keys. |
tests/UiPath.Caching.Tests/MemoryCacheSetterTests.cs |
Tests string-keyed entries. |
tests/UiPath.Caching.Tests/LocalCacheSetterTests.cs |
Tests local cache storage. |
tests/UiPath.Caching.Tests/Fakes/SpanReads.cs |
Provides span-read test helpers. |
tests/UiPath.Caching.Tests/Fakes/InMemoryMultilayer.cs |
Provides in-memory fixtures. |
tests/UiPath.Caching.Tests/CacheKeyTests.cs |
Tests span key normalization. |
src/UiPath.Caching/SpanKey.cs |
Composes normalized span keys. |
src/UiPath.Caching/PublicAPI.Unshipped.txt |
Records new APIs. |
src/UiPath.Caching/PrefixCacheKeyStrategy.cs |
Adds optimized span composition. |
src/UiPath.Caching/MultilayerHashCache.cs |
Adds hash span fast paths and string keys. |
src/UiPath.Caching/MultilayerCache.cs |
Adds span fast paths and string keys. |
src/UiPath.Caching/MemoryCacheSetter.cs |
Writes entries in place. |
src/UiPath.Caching/HashCacheOfT.cs |
Forwards typed hash span reads. |
src/UiPath.Caching/HashCacheEntryBuilder.cs |
Exposes the key strategy. |
src/UiPath.Caching/CacheOfT.cs |
Forwards typed span reads. |
src/UiPath.Caching/CacheEntryBuilder.cs |
Exposes the key strategy. |
src/UiPath.Caching.Abstractions/PublicAPI.Unshipped.txt |
Records new public APIs. |
src/UiPath.Caching.Abstractions/NullHashCache.cs |
Implements null hash span reads. |
src/UiPath.Caching.Abstractions/NullCache.cs |
Implements null span reads. |
src/UiPath.Caching.Abstractions/IHashCacheOfT.cs |
Adds typed hash span APIs. |
src/UiPath.Caching.Abstractions/IHashCache.cs |
Adds hash span APIs. |
src/UiPath.Caching.Abstractions/ICacheOfT.cs |
Adds typed span APIs. |
src/UiPath.Caching.Abstractions/ICache.cs |
Adds span read APIs. |
src/UiPath.Caching.Abstractions/CacheKey.cs |
Adds span normalization. |
docs/reference/interfaces.md |
Documents new APIs. |
CHANGELOG.md |
Documents behavior and performance changes. |
benchmarks/UiPath.Caching.Benchmarks/README.md |
Documents benchmark execution. |
benchmarks/UiPath.Caching.Benchmarks/LocalHitBenchmark.cs |
Adds local-hit benchmarks. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
0940878 to
b36fd9b
Compare
b36fd9b to
6c5a077
Compare
cbf41cd to
eb358d0
Compare
b36fd9b to
6c5a077
Compare
6c5a077 to
2944538
Compare
eb358d0 to
da27781
Compare
2944538 to
b5b13e1
Compare
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Span fast paths invoke custom key strategies before observing cancellation, producing behavior inconsistent with existing key-based reads.
Review effort: Balanced
Findings: 2
Open (2)
Resolved since last review (1)
12d2fff to
cc38bfe
Compare
cc38bfe to
cb8f85e
Compare
555c8cb to
7287a4a
Compare
28ff3bc to
51a3eef
Compare
c466f44 to
d46ffb2
Compare
51a3eef to
7a997bd
Compare
d46ffb2 to
365a685
Compare
7a997bd to
3358c6e
Compare
365a685 to
0b74a70
Compare
3358c6e to
c1dd3b5
Compare
0b74a70 to
95a3364
Compare
c1dd3b5 to
dbb758a
Compare
95a3364 to
6905a0f
Compare
a476735 to
dd444a6
Compare
d53fd00 to
c9ad742
Compare
dd444a6 to
a72b838
Compare
The local tier passed CacheKey, a struct, to IMemoryCache, whose
members take object: every lookup, set and remove boxed the key, and
the box stayed on as the entry's key. It now passes CacheKey.Name, so a
lookup allocates nothing and MemoryCache uses its string-keyed
dictionary rather than its object-keyed one. Both entry builders refuse a
key strategy that composes an empty key, which the local tier used to
file under one nameless entry.
ICache, ICache<T>, IHashCache and IHashCache<T> gain Span<char> read
overloads, with default bodies that build the key. The multilayer
caches normalize the text on the stack, let the key strategy compose it
through the new ICacheKeyStrategy.TryGetCacheKey<T>, and look the local
tier up through MemoryCache.TryGetValue(ReadOnlySpan<char>) on .NET 9
and later, so a local hit allocates nothing; every other case takes the
string path. Cache<T> and HashCache<T> forward the span, composing their
own strategy's key on the stack. The parameter is Span<char>: under
LangVersion 12 a ReadOnlySpan<char> overload makes GetAsync("literal")
ambiguous against the CacheKey overload.
CacheKey(ReadOnlySpan<char>) and CacheKey(ReadOnlySpan<char>,
CacheKeyCasing) normalize while copying, through the TryNormalize the
span reads use, so the two paths cannot disagree.
MemoryCacheSetter fills the entry CreateEntry returns instead of
copying a MemoryCacheEntryOptions and its lists into it, and
PrefixCacheKeyStrategy concatenates its prefix and separator, computed
once, instead of interpolating them per key.
ICacheKeyStrategy.TryGetCacheKey<T>(ReadOnlySpan<char>, Span<char>, out
int) is the span form of GetCacheKey: the text arrives normalized, the
strategy writes what GetCacheKey would build, and the library normalizes
the result as WithName would. It has no default body, so a strategy that
cannot compose by span declines explicitly and its reads stay on the
string path.
BREAKING CHANGE: an ICacheKeyStrategy implementation outside the library
must add TryGetCacheKey<T>; returning false keeps its behaviour.
Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>
a72b838 to
f40815d
Compare
c9ad742 to
947655f
Compare


Stacked on #216; its base is
refactor/abstractions-implementations. Review and merge that one first.Makes a read that hits the local tier allocate nothing, and gives callers a way to reach it without building a
CacheKey.The local tier is keyed by the name string
IMemoryCache's members takeobject, andCacheKeyis a struct, so every lookup, set and remove boxed the key (32 B on x64), and the box stayed on as the entry's key.MemoryCacheSetter,MultilayerCacheandMultilayerHashCachenow passCacheKey.Name: no box per lookup, one object less per resident entry, andMemoryCache's string-keyed dictionary instead of its object-keyed one, whose probes go through virtualEquals(object).CacheKeyequality is ordinal onName, and the name is never null or empty by the time it reaches the tier: the caller's key is validated as before, and both entry builders now refuse a key strategy that composes an empty key withInvalidOperationExceptionbefore any tier is touched (the local tier used to file every such key under one nameless entry). So the partition of keys is unchanged. Observable only through a customIMemoryCacheFactorythat shares oneIMemoryCachewith other string-keyed entries, which can now collide on equal text; a CHANGELOG Changed entry says so.Reads by
Span<char>ICache.GetAsync<T>,ICache<T>.GetAsync, andGetItemAsyncandGetAsynconIHashCacheandIHashCache<T>gain aSpan<char>overload, each with a default body that builds the key, so outside implementations keep compiling.NullCacheandNullHashCacheanswer without building one.MultilayerCacheandMultilayerHashCache, on .NET 9 and later: normalize the text on the stack, let the key strategy compose it throughTryGetCacheKey<T>, normalize the result asWithNamewould, look the local tier up throughMemoryCache.TryGetValue(ReadOnlySpan<char>), and answer from the entry. Anything else falls back to the string path: a strategy that declines, anotherIMemoryCache, an empty key (rejected as the string path rejects it) or one over 256 characters, a disconnected inner tier that is not served locally, Trace logging, or .NET 8. A hit and a fallback answer the same.Cache<T>andHashCache<T>forward the span, composing their own strategy's key on the stack the same way, and pass it through under the default one.GetItemAsyncreads the field straight from the stored dictionary, only when it is the ordinalImmutableDictionarythe tier itself builds, so the answer is the oneFilterwould give.The parameter is
Span<char>, notReadOnlySpan<char>. The repo compiles withLangVersion 12, and aReadOnlySpan<char>overload makes every existingGetAsync("literal")andGetAsync(stringVariable)call ambiguous against theCacheKeyoverload (CS0121, both are user-defined conversions).Span<char>has no conversion fromstring, andstackallocplusTryWriteyields one anyway. A caller holding aReadOnlySpan<char>writesnew CacheKey(span).An already-cancelled token is observed where the key path observes it: after the empty-key check and before the strategy on
MultilayerCache, first of all onMultilayerHashCache. The two overloads therefore throw the same exception for the same input, a throwing strategy included, and a cancelled read never reaches the strategy or the local tier.Not included:
GetOrAddAsync, whose generator delegate allocates per call regardless.Key strategies compose by span
ICacheKeyStrategygainsbool TryGetCacheKey<T>(ReadOnlySpan<char> key, Span<char> destination, out int written), the span form ofGetCacheKey. The text arrives normalized asCacheKeynormalizes it, the strategy writes the charactersGetCacheKeywould build, and the library normalizes the result asWithNamewould, so casing is not the strategy's concern; a test drives an upper-case-suffix strategy through both paths.DefaultCacheKeyStrategycopies,PrefixCacheKeyStrategywrites its prefix. The member has no default body: a strategy that cannot compose by span (a hash, a lookup) returnsfalsein one line and its reads stay on the string path, rather than silently keeping every span read off the fast path.Breaking for
ICacheKeyStrategyimplementations outside the library, which must add the member.CacheKeyfrom a spanCacheKey(ReadOnlySpan<char>)andCacheKey(ReadOnlySpan<char>, CacheKeyCasing)normalize while copying, so a key formatted on the stack costs one string rather than two. They shareTryNormalizewith the span reads, so a key written through one path and read through the other cannot differ; a test checks the span and string constructors agree, including trimming, non-ASCII lowercasing and names longer than the stack buffer.Local writes
MemoryCacheSetter.Setfills the entryCreateEntryreturns (expiration, change token, eviction callback, size, value) instead of copying aMemoryCacheEntryOptionsand its two lists into it.PrefixCacheKeyStrategyconcatenates itsprefix + separator, computed once, instead of interpolating per key, and gains the internal span writer the reads use.Benchmark
LocalHitBenchmark(new,MemoryDiagnoser,--job short --inProcess, net10.0): one entry held by the in-memory tier, read throughICache<string>.| Local hit through
ICache<string>| Before (#216) | After ||---|---|---|
|
GetAsync(CacheKey)| 174 ns, 32 B | 143 ns, 0 B ||
GetAsync(string)| 172 ns, 32 B | 147 ns, 0 B ||
GetAsync($"user:{id}")| 226 ns, 72 B | 179 ns, 40 B ||
GetAsync(Span<char>),TryWriteon the stack | n/a | 109 ns, 0 B ||
GetAsync(CacheKey)underPrefixCacheKeyStrategy| 229 ns, 80 B | 173 ns, 48 B ||
GetAsync(Span<char>)underPrefixCacheKeyStrategy| n/a | 170 ns, 0 B ||
SetAsync(CacheKey, value)| 1,199 ns, 1,152 B | 1,050 ns, 832 B |The allocation column is exact; the means come from
--job shortand carry wide error bars. The 40 B and 48 B left are the formatted and the composed key string, which the span reads avoid. The prefixed span read pays the strategy call and two normalizations, so it matches the key read in time and wins on allocation.Verification
dotnet build -c Release -warnaserror: 0 warnings.Red checks: dropping the connection-state guard fails the disconnected theory; skipping normalization on the fast path fails the zero-allocation check on
User:42; skipping normalization of a strategy's output fails the custom-strategy zero-allocation check; removing the fast path fails every zero-allocation test.dotnet test: 2067 passed on net8.0 and 2092 on net10.0, 0 failed, with the Redis integration tests on.