You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
perf(cache)!: read by the key's text wherever a local hit can answer - #221
Reads by the key's text wherever a local hit can answer, so a warm read allocates nothing in two more places: the remaining read operations by span, and every read by a plain string key.
What changes
Span overloads for the remaining reads.
SpanKeyExtensions adds ContainsAsync and GetOrAddAsync (all expiration overloads) over ICache, ICache<T>, IHashCache and IHashCache<T>.
It adds GetCacheEntryAsync over ICache, IHashCache and IHashCache<T>.
They dispatch through new members on the four capability interfaces, as GetAsync does, so Moq and NSubstitute mocks keep answering from their CacheKey setups.
How a span GetOrAddAsync behaves.
A local hit is answered by span.
Anything else builds the key once and takes the CacheKey path. The local lookup never touches the distributed tier, so a miss costs no extra round trip.
A rehydrating policy goes straight to the CacheKey path, since rehydration keeps the key.
String keys read the local tier by their text (.NET 9+).
The multilayer caches' CacheKey reads try the local tier by CacheKey.Name first: GetAsync, GetItemAsync, ContainsAsync, GetCacheEntryAsync and GetOrAddAsync.
Cache<T> and HashCache<T> compose their strategy's key on the stack instead of through GetCacheKey.
So typed.GetAsync("user:42") over a prefix strategy no longer allocates on a local hit, with no call-site change.
A key built with a casing other than CacheKey.DefaultCasing stays on the key path, because the span path normalizes with the default.
Two closures off the hit path.
GetOrAddInternalAsync was an async method whose miss-path lock delegates capture its parameters, so every hit allocated their closure. It is now a sync wrapper over the async core.
TryRehydrate likewise built its closure before its early returns. The trigger now lives in its own method.
This was 208 bytes per GetOrAddAsync hit on the plain CacheKey path, even without rehydration.
Behaviour kept
Each fast path returns what the key path returns, or steps aside:
Disconnected tier, Trace logging, a declining strategy, an overlong key, a cancelled token: fall back to the key path, so exception and cancellation behaviour is unchanged.
.NET 8: has no span lookup on MemoryCache, so it takes the path it took before.
Breaking
The capability interfaces gain members. An implementation outside the library adds them, and forwarding to its CacheKey overloads keeps its behaviour.
Tests
SpanKeyOperationTests covers:
the new span operations against the key forms, over real in-memory tiers, prefix strategies, proxies and the null caches
case-sensitive keys
a declining strategy
rehydrate on a local hit, by span and by key
cancellation
zero allocation for warm string-key and span reads (.NET 9+)
Each guard was red-checked: dropping the casing checks (multilayer, typed, hash, typed hash), skipping rehydrate on either path, or turning off the typed stack composition fails at least one test.
Full suite: net10.0 2153 passed, net8.0 2122 passed.
SpanKeyExtensions gains ContainsAsync and GetOrAddAsync over ICache,
ICache<T>, IHashCache and IHashCache<T>, and GetCacheEntryAsync over
ICache and both hash surfaces, through new members on the four
capability interfaces. A span GetOrAddAsync answers a local hit by span
and otherwise builds the key and takes the CacheKey path, so a miss
costs no second round trip; under a rehydrating policy it takes that
path directly, since rehydration keeps the key.
On .NET 9 and later the CacheKey reads (GetAsync, GetItemAsync,
ContainsAsync, GetCacheEntryAsync, GetOrAddAsync) of the multilayer
caches look the local tier up by the key's text first, and Cache<T> and
HashCache<T> compose their strategy's key on the stack instead of
through GetCacheKey. A string key through a typed cache with a prefix
strategy no longer allocates on a local hit. A key built with a casing
other than CacheKey.DefaultCasing stays on the key path, since the span
path normalizes with the default.
GetOrAddAsync's hit path is no longer async, and TryRehydrate hands the
trigger to a separate method: both used to allocate their lambdas'
closure on every hit, rehydrating or not.
BREAKING CHANGE: ISpanKeyCache, ISpanKeyCache<T>, ISpanKeyHashCache and
ISpanKeyHashCache<T> gain members; an implementation outside the library
adds them, forwarding to its CacheKey overloads keeps its behaviour.
Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>
🔎 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 (+857 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.
The span local-hit branch can return before NotCacheableException.ThrowIfNotCacheable<T>(), unlike the corresponding CacheKey operation. With a custom IMemoryCacheFactory retaining a MemoryCache that contains an ICacheEntry<int>, GetOrAddAsync<int>(Span<char>, ...) now returns that entry instead of rejecting the non-cacheable type. Validate T before probing L1.
Validate non-cacheable types before warm-hit return
src/UiPath.Caching/MultilayerCache.cs:363
Moving the type validation into GetOrAddCoreAsync lets this newly added warm-hit return bypass it. That changes the ICache contract for non-cacheable value types whenever a custom memory-cache factory exposes a pre-populated local entry. Move the validation into this synchronous wrapper before the local probe; the async core no longer needs to repeat it.
This span fast path can return a warm hash entry without running the non-cacheable-type validation performed by the CacheKey path. A retained/custom MemoryCache can therefore make GetOrAddAsync<int>(Span<char>, ...) succeed even though hash-cache operations reject int. Validate T before the local lookup.
Move validation before CacheKey warm-cache probe
src/UiPath.Caching/MultilayerHashCache.cs:396
The warm CacheKey branch now precedes the NotCacheableException check left in GetOrAddCoreAsync, so a local entry can bypass the hash cache's type contract. Move that validation into this wrapper before probing L1, then remove it from the core.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
cla-not-requiredMaintainer reviewed: no CLA required for this contribution
3 participants
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Reads by the key's text wherever a local hit can answer, so a warm read allocates nothing in two more places: the remaining read operations by span, and every read by a plain string key.
What changes
Span overloads for the remaining reads.
SpanKeyExtensionsaddsContainsAsyncandGetOrAddAsync(all expiration overloads) overICache,ICache<T>,IHashCacheandIHashCache<T>.GetCacheEntryAsyncoverICache,IHashCacheandIHashCache<T>.GetAsyncdoes, so Moq and NSubstitute mocks keep answering from theirCacheKeysetups.How a span
GetOrAddAsyncbehaves.CacheKeypath. The local lookup never touches the distributed tier, so a miss costs no extra round trip.CacheKeypath, since rehydration keeps the key.String keys read the local tier by their text (.NET 9+).
CacheKeyreads try the local tier byCacheKey.Namefirst:GetAsync,GetItemAsync,ContainsAsync,GetCacheEntryAsyncandGetOrAddAsync.Cache<T>andHashCache<T>compose their strategy's key on the stack instead of throughGetCacheKey.typed.GetAsync("user:42")over a prefix strategy no longer allocates on a local hit, with no call-site change.CacheKey.DefaultCasingstays on the key path, because the span path normalizes with the default.Two closures off the hit path.
GetOrAddInternalAsyncwas an async method whose miss-path lock delegates capture its parameters, so every hit allocated their closure. It is now a sync wrapper over the async core.TryRehydratelikewise built its closure before its early returns. The trigger now lives in its own method.GetOrAddAsynchit on the plainCacheKeypath, even without rehydration.Behaviour kept
Each fast path returns what the key path returns, or steps aside:
MemoryCache, so it takes the path it took before.Breaking
The capability interfaces gain members. An implementation outside the library adds them, and forwarding to its
CacheKeyoverloads keeps its behaviour.Tests
SpanKeyOperationTestscovers:Each guard was red-checked: dropping the casing checks (multilayer, typed, hash, typed hash), skipping rehydrate on either path, or turning off the typed stack composition fails at least one test.
Full suite: net10.0 2153 passed, net8.0 2122 passed.