Repository navigation
Open
Labels
api-suggestionEarly API idea and discussion, it is NOT ready for implementationEarly API idea and discussion, it is NOT ready for implementationarea-caching-hybridblocked
Description
Background and motivation
There are instances, such as session validation, where a consumer only wants to confirm that an item exists in the cache and not create it. This is not currently (directly) possible with the HybridCache implementation.
API Proposal
namespace Microsoft.Extensions.Caching.Hybrid;
public abstract class HybridCache
{
public abstract ValueTask<T?> GetAsync<T>(string key, HybridCacheEntryOptions? options = null, CancellationToken cancellationToken = default);
}API Usage
public async ValueTask<bool> ValidateSession(string sessionId, HybridCache cache)
{
var session = cache.GetAsync<Session>(sessionId);
return (session is not null);
}Alternative Designs
I can make this work using the existing API but I'm not a fan of my solution so if anyone has a better workaround let me know.
public async ValueTask<T?> GetAsync<T>(string key, HybridCacheEntryOptions? options = null, CancellationToken cancellationToken = default) where T : notnull
{
try
{
return await GetOrCreateAsync<T>(key, _ => throw new CacheMissException(), options, null, cancellationToken);
}
catch (CacheMissException)
{
return default;
}
}More efficient option suggested by @nibdev
public static async Task<(bool Found, T? Result)> TryGetAsync<T>(this HybridCache cache, string key) where T : class
{
var factoryCalled = false;
var result = await cache.GetOrCreateAsync(
key,
_ =>
{
factoryCalled = true;
return ValueTask.FromResult(default(T));
},
new HybridCacheEntryOptions
{
Expiration = TimeSpan.Zero,
LocalCacheExpiration = TimeSpan.Zero,
Flags = HybridCacheEntryFlags.DisableLocalCacheWrite | HybridCacheEntryFlags.DisableDistributedCacheWrite
});
return (!factoryCalled, result);
}Risks
No response
Reacted by Marcin Smółka, Flotoz, ondrej, nigbubski, Bruce Bowyer-Smyth, Kun Huang, AoshiW, Norbert Kelemen, Rans4ckeR, Rudi Visser and 11 more
Metadata
Metadata
Assignees
Labels
api-suggestionEarly API idea and discussion, it is NOT ready for implementationEarly API idea and discussion, it is NOT ready for implementationarea-caching-hybridblocked
Activity
ondrej-zoo commentedon Dec 4, 2024
I have another type of issue with not having a simple Get method. I would like to cache the access token so I don't need to obtain it every time I call some API. The problem is that I want to configure the absolute expiration of the cache entry based on the expiration of the token provided by the factory method.
The solution to my problem would be either
Func<CancellationToken, T, HybridCacheEntryOptions>nibdev commentedon Dec 11, 2024
I don't know if this is better for you but it probably is a bit less expensive as it does not use exceptions. And it could cache
nulland still would tell you if not found ornullwas cachedCoryCharlton commentedon Dec 11, 2024
Thanks for the suggestion.
ThomasBarnekow commentedon Jan 6, 2025
I'd support the first API suggestion. I just replaced my use of
IDistributedCachewithHybridCachein my solution and found the lack of that API a bit odd, to be honest. While it is possible to implement it ourselves, that shouldn't be necessary.Apart from the options already discussed in this thread, I also tried to use the
HybridCacheEntryFlags.DisableUnderlyingDataflag. Based on its documentation, it "Only fetches the value from cache; does not attempt to access the underlying data store." However, if the cache entry does not exist, the following line of code returnsnull, which is unexpected:The compiler strongly believes
valueis non-nullable, so it will complain aboutnullchecks, for example. Thus, the proposed API, which returns aValueTask<T?>, would indeed be very helpful.mgravell commentedon Feb 27, 2025
This has come up a few times; considering API for net10
mvxproject commentedon Feb 28, 2025
Combining the solutions from @nibdev and @ThomasBarnekow :
mgravell commentedon Feb 28, 2025
(I haven't checked whether it would compile, but can that be
Task<T?>? NRT syntax is fun ... Also: best to throw the options instance into a static readonly field that you can reuse, rather than allocating a new one per-call; I'd also be tempted to lose the async/await and return the VT directly, but that's subjective; addingstaticto the lambda won't change anything in this case, but is a good idea to ensure you aren't accidentally capturing anything)Perhaps:
jodydonetti commentedon Feb 28, 2025
As mentioned elsewhere, the problem with a GetAsync method like this is that there's no way to differentiate between "the value in the cache was
null" and "there was nothing in the cache".In case this limitation is ok, I would be more explicit with the name (naming is hard etc):
ValueTask<T?> GetOrDefaultAsync<T>.Also, why returning a
Taskinstead of aValueTask, like theGetOrSetAsyncalready does?Good catch!
Apropos of this: why not just pass
null!as the factory? It will not be called anyway (I tried it and it does work).mgravell commentedon Feb 28, 2025
Typo
Not at a PC to check, and couldn't remember whether we added a null fault in this case (I agree it makes sense not to, when not enabled)
mvxproject commentedon Mar 1, 2025
If we need to know if the cached value was found, @nibdev solution is the way to go.
Usage:
jodydonetti commentedon Mar 1, 2025
I think the big point here is that, imho, it's simply not possible to add
GetOrDefaultAsyncandTryGetAsyncas extension methods: they must be native implementations in HybridCache itself. Why? Because of a subtle but very important detail.Let me explain (and please correct me if I'm wrong).
Both methods are explicitly NOT designed to set a new value in the cache, only to get what's there, right? I mean, for example the
GetOrDefaultmethod does not mean "get the value from the cache and if not there SAVE the default value" but "get the value from the cache and if not there RETURN the default value". This will be even more important when the multi-node notifications part will be implemented. There's a big difference (I've been bitten by this multiple times).To make it formally correct and achieve this behaviour we'd need to use
DisableLocalCacheWrite+DisableDistributedCacheWrite, to avoid writing to the cache when nothing was there at all.But, and this is important, if we use those flags we are now losing on the automatic L1 write in case of an L1 MISS + L2 HIT, which is very important to avoid bombarding L2 every at single call, and basically the whole point of having an L1+L2 cache in the first place.
In theory we may call the method without the 2 flags (to get the auto-L1-save-from-L2) and add them in the factory (like with adaptive caching) but this is not possible because A) there's no way to pass the entry options to the factory and B) the entry options class is intended to be immutable anyway (see here, sealed class + init-only members).
So again, imho there's no way to achieve
GetOrDefaultAsync/TryGetAsyncwith what we currently have (and this is, FWIW, the reasons why in FusionCache I made both methods first class).Here's an example of both
GetOrDefaultAsyncandTryGetAsyncwhich are (again, imho) formally correct in behaviour but without the L1-auto-save-from-L2 feature:NOTE: I also put an alternative version of the
GetOrDefaultAsyncmethod, as an example.Another smaller point to be made is that, as multiple people pointed out, to have a
TryGetAsyncmethod where we can differentiate between "null was in the cache" and "there was nothing in the cache" is to not use a simpleT?result type, because it's not enough. Because of this we saw the use of a tuple like(bool Found, T? Value). This is great, except that these tuples have limitations on the fields names (eg:FoundandValue) being carried around between different compilation units, so I'm not sure what will happen if the ext methods (and returning tuple) are defined in a package (but maybe I'm wrong and these limits are not valid anymore?). This, btw, is the reason why in FusionCache I created theMaybeValue<T>type, so it was clearly defined and would not suffer these limitations.Thoughts?
bezaou commentedon Apr 17, 2025
hello @nibdev @jodydonetti
I’ve tested your implementation with the following concurrent scenario, but the results were not as expected:
Some results have the found flag set to true, which should not be possible.
Somehow, it seems that factory isn't properly executed?
jodydonetti commentedon Apr 19, 2025
Hi @bezaou
Just to be on the same page: you mean just my implementation above or also with my 3rd party HybridCache implementation?
The behaviour is strange, I agree, but I'd have to check the code of the Microsoft default HybridCache implementation (see
DefaultHybridCachehere).But if I have to guess without looking at the code I think it has to do with some low level impl detail related to the cache stampede protection, like directly sharing an inner task/result or something like that. It may be a case of Leaky Abstraction, but I may be wrong.
I'll try to take a deeper look tomorrow.
jodydonetti commentedon Apr 25, 2025
Hi @bezaou , I've been able to make some more tests, in particular this (using the ext methods I posted above):
Running this gives me variations of this:
or this:
So yes, I think I can confirm that the current behaviour for the Microsoft's implementation of HybridCache is "inconsistent" or "non deterministic".
To put it simply, what I mean is that currently you cannot count on the factory being executed when the value is not in the cache (or is skipped via
DisableLocalCache/DisableLocalCacheRead).My supposition for why is still that this is happening because of some internal optimization, like sharing an underlying task or something like that.
Maybe @mgravell can confirm this or shed a light.
jodydonetti commentedon Apr 25, 2025
I also tried the same code with the FusionCache adapter (see here).
By simply adding a reference to FusionCache and changing this line:
with this line:
the result is consistently this:
Hope this helps.
DontEatRice commentedon Sep 11, 2026
Stumbled upon this issue right now, just before .NET 11 release, are there any plans to implement this?
svick commentedon Sep 24, 2026
@DontEatRice This won't be included in .Net 11. We'll see what we can do for .Net 12.
Eng3bbas commentedon Oct 22, 2026
I've opened a PR for this: #7876.
It adds
GetAsync<T>, which returnsValueTask<(bool Found, T? Value)>. It's an experimental (EXTEXP0018) extension method inMicrosoft.Extensions.Caching.Hybrid. It reads L1, then L2, never runs a factory, and never writes a miss to either tier.About the concurrency problem raised above (@bezaou, @jodydonetti): the factory-flag workaround reports
Found = truefor callers that join another caller's lookup, because only the first caller's factory runs. For the default implementation, the PR doesn't rely on the factory. It reuses the stampede pipeline withDisableUnderlyingData, and marks a miss on the shared stampede state, so every joined caller sees it.ConcurrentMissesAreAllReportedAsNotFoundcovers this. OtherHybridCacheimplementations get the factory-based fallback, and its limitation is documented.While working on it, I also found and fixed a reference-count bug on the existing
GetOrCreateAsync+DisableUnderlyingDatamiss path: joined callers over-released a shared item. The PR description has the details.This is an extension method rather than a virtual on the abstract
HybridCacheclass only because that class lives in dotnet/runtime. If API review prefers a virtual method, the implementation can move behind it unchanged.svick commentedon Oct 23, 2026
@Eng3bbas Thank you for your contribution, but we won't be able to accept the PR right now.
GetAsyncshouldn't be an extension method that is specialized forDefaultHybridCache, it should be avirtualmethod onHybridCache. But implementing an API across dotnet/runtime and dotnet/extensions currently doesn't work well.So, the current plan is:
HybridCacheand implement it inDefaultHybridCache. I want to get this done before the end of the year, but no promises.I will let you know when there's more information. Feel free to ping me in 2027 if I haven't done so.
For the bug, I have opened #7880 to track it. Feel free to create a separate PR for the bugfix, there's nothing blocking that.
Sorry for the complications.