Description
Open questions (will make more sense after reading):
- key: span or interpolated string handler?
- if interpolated string handler:
DefaultInterpolatedStringHandler
or something bespoke (for example a new[InterpolatedStringHandler] public ref struct KeyBuilder { ... }
)?
- if interpolated string handler:
- does this single API review also encompass the proposed additions to
DefaultInterpolatedStringHandler
?- UPDATE: probably not; that ^^^ is now
api-approved
- UPDATE: probably not; that ^^^ is now
- does this single API review also encompass the proposed additions to
HybridCache
?
(note: a lot of the discussion in comments below has already been incorporated into this top post down to end-of 2024)
Relevant runtimes / targets
Short version: .NET 10+ (omit new APIs in down-level multi-target builds)
Longer version: M.E.C.M. is an OOB NuGet multi-targeting package; the proposed API additions would by default be limited to .NET 10+; it may be technically possible to backport for .NET 9, but since .NET 9 is not LTS I'm not sure this is hugely advantageous, and would presumably require a few hacks such as [InternalsVisibleTo]
or [UnsafeAccessor]
, which probably isn't ideal. It is not useful to consider backport lower than .NET 9, due to requiring TryGetAlternateLookup
(a .NET 9 feature) to be useful.
Background and motivation
The existing MemoryCache
API takes object
keys, however: in many cases, the key is actually a string
.
There is a set of existing APIs of the form:
namespace Microsoft.Extensions.Caching.Memory;
public interface IMemoryCache
{
bool TryGetValue(object key, out object? value);
// ...
}
public class MemoryCache
{
public bool TryGetValue(object key, out object? result);
// ...
}
public static class CacheExtensions
{
public static bool TryGetValue<TItem>(this IMemoryCache cache, object key, out TItem? value);
// ...
}
The impact of this is that when using string
keys (the most common use-case), it is required to pay the cost of allocating the string
key, which is usually string concatenation of some constants with contextual tokens, for example $"/foos/{region}/{id}"
.
Proposal: add a new API to facility allocation-free fetch:
public class MemoryCache : IMemoryCache
{
// DECISION NEEDED HERE re span vs interpolated string handler
// (this version may not be optimal)
+ [OverloadResolutionPriority(1)]
+ public bool TryGetValue(ReadOnlySpan<char> key, out object? value);
+ [OverloadResolutionPriority(1)]
+ public bool TryGetValue<TItem>(ReadOnlySpan<char> key, out TItem? value);
// ...
}
Observations:
- this API is specific to
string
-like keys; however, this is a huge majority of keys used in caching - this API is specific to the concrete
MemoryCache
; no change toIMemoryCache
is proposed; this is not an issue, and has been the approach used for.Count
,.Keys
, etc; consumers can type-test as if necessary; since it is specific toMemoryCache
, no change toCacheExtensions
is proposed - implementing this API requires the .NET 9
TryGetAlternateLookup
feature, that allows dictionaries to be accessed in such ways - implementing this API requires
MemoryCache
to use a separate dictionary forstring
-based objects; this already exists (it was merged via "internal" for .NET 6, .NET 8 and .NET 9, and explicitly for .NET 10) - corollary of the above two: we can just grab the alt lookup in the init, and use that to provide this API. If it turns out that we do not have the alt lookup available: just
new
astring
and use that instead. - the usage of
[OverloadResolutionPriority(1)]
here is to avoid ambiguity withcache.TryGetValue($"/foos/{region}/{id}", out var value)
(CS0121), however; note that this still uses astring
; you can see this here - in particular note the value passed is(ReadOnlySpan<char>)defaultInterpolatedStringHandler.ToStringAndClear()
- i.e. "create astring
, and shim it to the span"; if we want to avoid that issue so that$"/foos/{region}/{id}"
uses the allocation-free approach (without Roslyn changes), we would need to use an interpolated string handler parameter; for example
public class MemoryCache : IMemoryCache
{
// DECISION ALTERNATE
// (this version may be preferable)
+ public bool TryGetValue(ref DefaultInterpolatedStringHandler key, out object? value);
+ public bool TryGetValue<TItem>(ref DefaultInterpolatedStringHandler key, out TItem? value);
// ...
}
Note that the compiler automatically prefers interpolated string handlers over string
in relevant scenarios (except for pure constant values, although the problematic scenarios from this link do not apply in this scenario); for a fully worked version see here. A further complication is that we would want the interpolated string handler to expose some additional methods, to allow efficient access to the span contents - although since this is "runtime", IVTA may be sufficient here (untested). It may be simpler, however, to consider these two proposals inherently linked.
A secondary win here is that pre-existing inline usage of interpolated strings, i.e. cache.TryGetValue($"/foos/{region}/{id}", ...)
will, when recompiled, pick up the lower-allocation API without any code changes. However, the real intended "win" here is via secondary APIs such as HybridCache
(below).
Question: if using interpolated string handlers should we use DefaultInterpolatedStringHandler
? or a bespoke KeyBuilder
that encapsulates a DefaultInterpolatedStringHandler
? (there are some complications in the latter due to the compiler considering exposing the span that far as unsafe).
Use-cases:
We want allocation-free fetch from complex APIs like HybridCache
, which use IMemoryCache
(and usually, although we'd need to type-test) therefore: MemoryCache
. By adding this API, along with some "format" APIs in new HybridCache
APIs, we allow the scenario where composite keys can be passed as (for example) value-tuples, with a formatter that writes not a string
but a Span<char>
, used for reads. The impact here is reduced allocations for the key in the hot "cache hit" path. Obviously cache misses still need to pay the cost, but we can't have everything.
Usage example:
HybridCache
currently has a GetOrCreateAsync<T>
API that takes a string key
parameter.
We would add a GetOrCreateAsync<T>
overload that takes an alloc-based char-based key (with the same considerations as above), passing the key along, allowing local-cache "hit" scenarios (which should be a common event) to be allocation free end-to-end. I presume this would be a separate API proposal, but will have very similar considerations to the above. Likely conclusion:
namespace Microsoft.Extensions.Caching.Hybrid;
public abstract class HybridCache
{
public abstract ValueTask<T> GetOrCreateAsync<TState, T>(string key, TState state, Func<TState, CancellationToken, ValueTask<T>> factory,
HybridCacheEntryOptions? options = null, IEnumerable<string>? tags = null, CancellationToken cancellationToken = default);
public ValueTask<T> GetOrCreateAsync<T>(string key, Func<CancellationToken, ValueTask<T>> factory,
HybridCacheEntryOptions? options = null, IEnumerable<string>? tags = null, CancellationToken cancellationToken = default)
=> GetOrCreateAsync(key, factory, WrappedCallbackCache<T>.Instance, options, tags, cancellationToken);
+ public virtual ValueTask<T> GetOrCreateAsync<TState, T>(ref DefaultInterpolatedStringHandler key, TState state, Func<TState, CancellationToken, ValueTask<T>> factory,
+ HybridCacheEntryOptions? options = null, IEnumerable<string>? tags = null, CancellationToken cancellationToken = default)
+ => GetOrCreateAsync(key.ToStringAndClear(), factory, WrappedCallbackCache<T>.Instance, options, tags, cancellationToken);
+ public ValueTask<T> GetOrCreateAsync<T>(ref DefaultInterpolatedStringHandler key, Func<CancellationToken, ValueTask<T>> factory,
+ HybridCacheEntryOptions? options = null, IEnumerable<string>? tags = null, CancellationToken cancellationToken = default)
+ => GetOrCreateAsync(ref key, factory, WrappedCallbackCache<T>.Instance, options, tags, cancellationToken);
// ...
}
i.e. a new virtual
method that takes ref DefaultInterpolatedStringHandler key
, that concrete implementations should override
to perform allocation-free local-cache lookups, otherwise defaulting to existing string
usage (if they do not override
).
Question: is the above sufficient to consider part of this API proposal, or should a separate API process be followed for the HybridCache
addition?
API Proposal
covered above
API Usage
covered above
Alternative Designs
span vs interpolated string handler covered above
Risks
No response