-
Notifications
You must be signed in to change notification settings - Fork 751
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[API Proposal]: Introduce new memory cache library #4766
Comments
Does it interoperate with TimeProvider? |
No, it doesn't. The whole purpose of this library is to speed up things. We would need to introduce time provider in the hot-path, which adds a virtual dispatch overhead. We can add 'FakeLruRCache' which would be no different from original implementation beside using TimeProvider. That would allow to test algorithm alone. update Actually, we could add TimeProvider in options as well, and until it would be overriden manually we could ignore it. I think it would solve the perf concern. |
What is the thread safety model here of the valueFactory? I typically wrap MemoryCache to ensure that the value factory is only called once in case of a cache miss. This always seemed self evident to me, since you wouldn't be caching in the first place if the valueFactory was cheap. And if there is a locking mechanism, is it global or is it dependent on the key, like in ConcurrentDictionary? |
We are not preventing stampedes atm, so there is a chance that factory is called more than once.
If there is something I am missing let me know.
RCache is wrapping over concurrent dictionary and it has no global locks. |
Nice. I like it. If the Lazy usecase for expensive valueFactories is nicely documented and tested (single execution guraranteed), then it does not need extra API support I believe. What I do VERY often is |
Please consider support for "keyed service dependency injection". |
The library was originally developed before key service support was released, so it comes with: public interface IRCacheProvider
{
public RCache<TKey, TValue> Get<TKey, TValue>(string name);
} and getting it by name from DI: namespace Example;
using Microsoft.Extensions.Cache;
public class UserService
{
private readonly RCache<Guid, User> _users;
public UserService(IRCacheProvider caches)
{
_users = caches.Get<Guid, User>("my-cache-name");
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
}
} I didn't include it in the proposal because it will be changed towards keyd services. |
@rafal-mz Is RCache in a repo somewhere to peek at? I've used and even customized Bitfaster in the past so curious to see what changes were made in this case. |
RCache is not yet public. I will create a PR and link here as soon as it is possible. |
Interesting proposal. Thanks for asking the community for feedback. I am sure lots of us value effort being put into the caching bits of the framework. For me the Lazy ("cache stampede") factory evaluation requirement is pretty fundamental. Personally I struggle to find use cases that warrant a cache without lazy factory. Either not needing a lazy factory shows you don't actually need a cache at all (aka premature optimisation), or you need a cache such that it must have a lazy factory to not overwhelm your system. My experience says it should be lazy factory by default, and lazy factory is what people expect when using a cache for the first time. But I totally accept I'm biased as the author of a library that does just that! And a couple of questions:
|
Thanks for your feedback. I really appreciate it.
I would not go that far. Cache can be used as general purpose data structure that keep memory constrained through eviction policy and capacity limit. I've seen (written few myself :-)) far too many concurrent dictionaries called 'cache' that were accumulating memory to an infinite. RCache is just a concurrent dictionary wrapper that adds efficient eviction policy, so I would not limit the use cases to 'costly to create'. Sometimes it may be 'costly to keep forever'. Generally, the cost of creation matter less the longer entry TTL. So far I've had an opinion that fast and simple is better than safe and slow. Now I am not sure. Need to think it through.
We just had to have a different name than existing MemoryCache. RCache was developed in a project that has a name starting with 'R'.
.NET team has long lasting culture of keeping strict backward compatibility guarantees. That's why expanding is hard when original implementations are not abstract classes. Microsoft.Extensions.Caching.Memory is getting better with every version but if the interface is locked it is only so much you can do. Hopefully the interface we propose now can be extendable enough that in the future we can still improve the library and not introduce yet another API.
We are aware of this problem that's why we call it out in risks. As mentioned above we cannot remove we can just add.
RCache so far implements one algorithm out of many that are available in BitFaster. Some technical details in RCache are different compared to original. Author of BitFaster is a Microsoft employee and he was involved in the process. Its worth to mention that there is also a layering problem. For instance, for a library to be used in runtime repo, it needs to be pulled there. This is also something we call out in risks. |
This does bring up the point that, If there is a good way to add some additional abstractions to make Lazy evaluation simple and efficient, that would be great. At the same time, I know of at least two larger OSS libs that may be able to benefit from a more raw base abstraction. (in one case I saw equivalent performance with lower memory usage compared to a very nice, hand crafted robin hood hash)
If I had to guess, From my own experience hacking at it, Bitfaster is a very good caching library, however if you need a portion of the functionality it is better to specialize the implementation you can get even more from it. Kinda per above, on the average it's really good, but a purpose trimmed and tuned 'general but fixed use case' version is competitive with a 'hand chosen and written algo' cache. |
I am not familiar with it but was contributing to "Bitfaster" considered as an alternative? (Can we cc their owners here?) |
@alastairtree I believe the value of a Lazy model is for a small cache with a high likelihood of concurrent accesses to the same state. But in a high traffic service which may have millions of entries in cache, the risk of wasted work due to concurrent evaluation of the factory method is low enough to simply not be a concern. However, if it is a concern, then the cache can simply be configured to hold Lazy and then it will work as needed and prevent redundant calls to the factory method. I think that seems like a good place to be. Now even if your cache sometimes experiences collisions and does redundant work as a result, it doesn't mean use of Lazy is warranted. It depends on the observed rate of collisions. Using a Lazy imparts a definite measurable cost to every use of the cache, whereas collisions only cost you when they occur. Thus a low collision rate may be cheaper than using Lazy. |
I am excited about the introduction of a new memory cache and will follow its final specification if it proceeds. My experience with caching is not detailed enough to properly take part in the current level of conversation but I am happy to provide input and feedback as the design and first iteration progresses. Picking Bitfaster and extending/improving it sounds like the right plan. The current proposal appears to nearly match the existing Bitfaster or am I missing some key items? |
@RobDeVoer It's very similar on the inside, but the API surface has been considerably simplified by hiding all the complications around how monomorphisation is achieved. And we have some ideas in the pipeline which may deliver additional raw performance to the code as well, but those are still experimental in nature. |
Hi all, here's my thoughts on this effort: I tried to condense all of them into a single answer, therefore sorry for it being quite long. First of all, as others already said, thanks for discussing this in public to gather the community's opinions, including the ones by OSS maintainers like me, @alastairtree , @Turnerj and others. Really appreciated! The most important thing I'd like to highlight is that when this will be finalized and released (I suppose in the .NET 9 timeframe), it will be important to clearly explain the positioning of it.
Following this distinction I see this new
As pointed out by others the name is quite strange: I feel you in wanting to avoid some other uber general/all encompassing name and to avoid confusion with the other 2 existing _memory cache_s, so that this also feels "smaller/specialized/optimized" (again, 1st category from above). Having said that, naming it RCache just because the project where it originated started with an "R"... I don't know. Maybe something that communicate the low-level/no-frills nature of it like RawCache or similar?
Understood, but to do this it seems you are not cleaning up automatically: not having the code to look at I'm wondering if a SET call of key "foo" will also cleanup the expired entry for cache key "bar". If this is not the case you may run into memory remaining allocated by cache entries that ended up not being touched for a long time, so this is something to look out for.
The feature is nice, but watch out for this (been there): I would enable automatic Dispose calls on eviction only as an opt-in feature (like
In general it looks quite good to me: I like that it is an abstract class with different factory methods so that currently there's only the LRU special case but I can imagine in the future there may be others (LFU, TLRU, PLRU, MRU, RR, FIFO, etc). A couple of notes though:
Another thing is that, as already said by others, the lack of cache stampede prevention is quite a big thing. Because of this, if you decide not to implement it, I think it should be very clear to consumers that there's no protection there. Also, now that I'm thinking about it: if no stampede prevention is provided, and no async support is provided (see later), why having a By not having it:
I mean, I understand it's an attractive method to have, but it may be counterproductive.
Oh yes please: if you decide to go with the callbacks route, a single stream of events would definitely be better then a ton of specific callbacks stored inside each cache entry.
This may be a nice idea: in case it will go forward I think it should be explained more and experimented with, including some practical examples on how to implement it.
Understood, but as said by providing a
Anyway fingers crossed for you: I hope it will be enough for the community... but I'm not that sure.
Eh, definitely, I feel that 😅
How would a
I'm interested in this, will follow!
Good call! A couple of final things. This looks good for caches of homogeneous values, but for heterogeneous values? Would we just use Capacity: what happens when the Regarding logging: is there a plan to support logging or is it way too overkill at such a low level? I can imagine the latter, but just asking. Ok, I think it's all. Thanks again for sharing this with us! |
Thanks for your feedback. :-)
If this is going to end up in dotnet/extensions then we have 1 month release cadence for this repository.
I agree with this classification and how you position RCache there. Application level cache IMHO must have much richer semantics to support all those features and it affects overall performance. Lower level cache can be also used as a building block for creating app level caches.
I am not very opinionated on the name. I like
You are correct with the first three sentences. We may leave uncleared memory when cache is long untouched. This is the reason our interface has
Thats great experience. I will add this to proposal. Thanks a lot!
I expect 'Options' parameter to absorb new features instead of adding new permutations to factory method. This is just a preference decision, since nullable is semantically equivalent to what we have now. I think that interfaces that require passing null explicitly are just longer to write and not always clear to interpret.
So to summary, DI is global per name and key value combination. Creating 'RCache' by hand gives you no protection.
Noted, will think about clearer naming.
Could you give an example when maintaing/evolving it is daunting?
If I understand correctly you think that the RCache factory may be returning some wrap over TValue and TTL and accept different parameters than TKey and TState? I think that having TState allow you to pass any parameter type, and returning TValue to return any type of value. Could you share your experience with the issues you had with permutations?
RCache will never have async support. This is explicit design decision and proposal is not supposed to evolve in this direction.
As explained above by Martin, stampade can be easily fixed by returning Lazy from a cache. For big services collision rate is small, so even when cost of creation is high you don't need this protection.
Concurrent dictionary does not support async and does not prevent stampedes but still supports GetOrSet. Why users would expect different behavior from RCache than ConcurrentDictionary?
I've made a mistake, it won't. I've removed this part from proposal.
This is one possible solution.
When capacity reaches nothing happens. When an item is added to a cache with full capacity, cache will evict one entry in approx Lru order.
I think it would be an overkill but we can add optional logging for diagnostic purposes that would be turned off by default. The question is what the log lines would be about? If the case is to log TKey and TValue there comes a problem of not capturing any sensitive data. |
Small remark. I am a big fan of the I just hope that it will not be implemented as is in the BitFaster caching repo like this Where this line indeed creates a closure of the valueFactory and factoryArgument. |
No, its not implemented like that. This overload was specifically added to avoid closure allocation. |
Ah, probably even better.
Exactly, that's the idea!
Yep, most definitely 🤷
Ahah thanks, but part I'm not a Microsoft employee :-) Anyway, another idea that just came up:
Understood, and I like having the ability to not having stuff running in the background and being able to fully control manual calls to Btw I later noticed that the abstract class already implements
Totally got it, and I really like it!
You're welcome!
Makes sense. I still have a doubt about what can actually go into options, when we also consider the
Agree, but I usually also declare the nullable params with a default value of
Yeah sorry, after posting my comment I got to it, sorry for the time wasted.
Makes sense, thanks.
Right, makes sense.
Eh, (un)luckily yes 😅: see here, here and here. They are the same class (splitted in 3 partial files for organization purposes), and they represent the permutation of all the optional params and signatures. I started with some, and then added support for more params, so I had to add all the possible combinations of params to keep the thing aligned with the general approach.
For the return types of the factory lambda, adding new overloads with new lambda signatures is a pita, whereas adding a new prop to an existing return type is much easier and will not explode into a lot of variations. As an exercise I suggest to try to keep the current design, and see what would needs to change in the future by adding an extra return type, not just
Ok, I was referring to this passage "We are going to propose another interface in the future that covers asynchronous caches scenarios and more" and thinking about consistency overall.
Yes, as in
I'm going out on a limb here maybe, but 2 things come to mind:
Got it, thanks!
Got it.
As it is today, logging is probably overkill, I agree. I was just thinking in perspective.
Good call, haven't thought about it.
In FusionCache there can be a lot of stuff going on: async calls, automatic background factory completion, distribuetd cache + backplane communications, eager refresh and so on so logging becomes quite fundamental for when my users go into detective mode (and for my own sanity too, when developing new features 🤣). But here, in such a low-level + hi-perf case, probably it's not worth it, you're right. Anyway... woah, what a good discussion to have, thanks again, I really appreciate it! |
I've meant to leave this decision to the community, reviewers. For me its just a name.
If the cache holds TValues implementing IDisposable, it will dispose each of those at its own disposal. The class is also holding System.Diagnostics.Metrics.Meter, which is IDisposable.
The presented model is general and cannot have any assumptions about underlying algorithm. If we would return 'hotness' or 'priority' it would mean that all of the implementations must have a notion of prioritization. IMHO to specify such details in a correct way one needs to have deep understanding of the implementation details, algorithm used, data access patterns and excesssive measurements. So, I would call it a leaky abstraction. There are some algorithm details which may be useful for tuning the cache for high performance. Such details (if exposed) will be part of the options object for specific implementation. In this case it would be RCacheLruOptions<TK, TV>
I believe that anything like Funny enough, I think in case of time provider and RCache it will be taken from options as instance. The reason for that is we have public factory that don't know about DI container.
I think (again as Martin already mentioned) we want people to pay for what they use. I don't like the idea of slowing down everybody by default to shield less experienced folks from shooting their foot. I would love to do both at the same time. :-)
Thats fair. I will think about it. |
@ReubenBond Regarding your suggestion to include a bool Remove(KeyValuePair<TKey, TValue> entry) API, I agree that this addition would be valuable. I will review how challenging it would be to integrate this into our internal prototype. |
@ReubenBond I tried to adopt I'm waiting for the reply from @bitfaster to confirm. |
This proposal has been updated. @ReubenBond @mgravell would you mind taking one last look before we mark it ready for review? |
Small comments about the metrics naming:
What do you think about these names:
|
Does this add the cache both as a keyed service (with a name derived from If it adds the cache as an unkeyed service only, then the following remarks are not quite right:
|
I'm no @ReubenBond nor @mgravell , but I hope my 2 cents may be helpful. Overall it looks good, I just have a couple of notes. BuilderI see you've revamped the builder part, which in theory is great for extensibility and 3rd party implementations (see: OSS people), but to me it looks like the new builder API is too tied to a specific LRU implementation, mainly because the class is not abstract and because of this: public RCacheBuilder<TKey, TValue> WithOptions(RCacheLruOptions<TKey> options); Wouldn't it be better to make the I'll keep using the Belady cache I've used previously as an example: should I then create an Instead I would propose something like this: public abstract class RCacheBuilder<TKey, TValue>
where TKey : notnull
{
public abstract RCacheBuilder(string name);
public abstract RCacheBuilder<TKey, TValue> WithMeterFactory(IMeterFactory? meterFactory);
public abstract RCache<TKey, TValue> Build();
}
// LRU
public class RCacheLruBuilder<TKey, TValue>
: RCacheBuilder<TKey, TValue>
where TKey : notnull
{
public RCacheLruBuilder(string name);
public RCacheLruBuilder<TKey, TValue> WithOptions(RCacheLruOptions<TKey> options);
}
// BELADY
public class RCacheBeladyBuilder<TKey, TValue>
: RCacheBuilder<TKey, TValue>
where TKey : notnull
{
public RCacheBeladyBuilder(string name);
public RCacheBeladyBuilder<TKey, TValue> WithOptions(RCacheBeladyOptions<TKey> options);
} And they would be used like this: var _cacheLru = new RCacheLruBuilder<Guid, User>("my-cache-name")
.WithMeterFactory(meterFactory)
.Build();
var _cacheBelady = new RCacheBeladyBuilder<Guid, User>("my-cache-name")
.WithMeterFactory(meterFactory)
.Build(); UPDATE 2024-08-23: thinking about it, maybe it was not your intention to for the builder part to be shared between different implementations (using the same DIThe DI registration part seems good 👍 , except for one thing about naming (naming is hard etc).
I think consistency and uniformity is highly desirable. EnumerableSomething else not technically "wrong" but just... I don't know, feels "risky": is it the best thing for RCache to directly implement Other than this it LGTM! Release ChannelOne final question, maybe it has been already said: is this an out of band thing or part of .NET? I'm really interesting in this, not just to use it per-se but also as a potential new L1 in FusionCache. Hope this helps. |
Thought on expiration: what is providing time? Is this |
Yes, dotnet/extensions don't exactly follow the .NET release cycle, nor follow the semver convention. This repo is on a monthly release cadence and each "point" release can bring new API and a possibility of breaking changes (though it's something we, generally, avoid as much as we can). Our build artifacts multi-target, and 8.x train supports net462, net6.0 and net8.0. The 9.x train supports net462, net8.0 and net9.0. |
BuilderThank you for your suggestion regarding the builder. The builder was indeed not intended to be shared among various implementations, but rather focused solely on the LRU implementation. DIThanks for finding the naming inconsistency.
|
Thank you for identifying this issue. You are correct—the |
@mgravell, thank you for the insightful suggestion. Currently, we use That said, I see the value in supporting a |
@mobratil thanks for your answer, appreciate it. And yes the Thanks! |
@mobratil as a minor note: when we wrote the kinda-similar-to-this cache at StackOverflow, we found (during measuring) that the simple act of fetching the current time via tick-count (or similar) was our bottleneck; we fixed this by adding a "now-ish" concept that was a snapshot of time that was incremented separately by a timer at a reasonable frequency, so the bulk of the "has this expired" checks were just a volatile read. I'm not saying you'll have the same experience, but it might at least be worth considering / measuring. |
If I remember correctly, even @davidfowl and the gang used a "less precise" clock measurement technique in Kestrel (but I'm not sure/don't remember the details). It may be worth exploring if it's worth it or not: if it is, since that may change the actual expiration (even if by a microscopic amount) allow users to enable it via a |
Yeah, that's exactly what the |
I did some measurements based on your suggestion, and you're right. The raw measurements show the following differences for the
However, incorporating a timer to periodically update the ticks value would conflict with our design philosophy of avoiding background threads. I believe it’s best to stick with our current approach for now. That said, we can definitely revisit this idea in the future. I’m also open to any further suggestions on how we can optimize within these constraints. |
Thanks @verdie-g, for the suggestions. I agree with your points. I've updated the proposal accordingly, incorporating your suggested names for consistency and clarity. Please take a look and let me know if it makes more sense. |
Hi @joperezr
Will the review be on YT or in private? |
I'll find more details about the schedule and then will report back here with dates and links in case it will be hosted. |
@mobratil one more thing comes to mind as I consider how we would leverage this in dotnet/orleans: the new |
|
I think the |
Just to avoid an involuntary oversight before passing the pearly gates of an official approval, in the current proposal the name is still the old one. |
FYI, the current plan is to Review this tomorrow 9/10 in our design review at 11:00 PST and I do believe that we plan on streaming it here: https://www.youtube.com/@NETFoundation/streams |
namespace Microsoft.Extensions.Caching.Memory;
/// <summary>
/// A synchronous in-memory object cache.
/// </summary>
/// <typeparam name="TKey">Type of keys stored in the cache.</typeparam>
/// <typeparam name="TValue">Type of values stored in the cache.</typeparam>
public abstract class RCache<TKey, TValue> : IEnumerable<KeyValuePair<TKey, TValue>>
where TKey : notnull
{
/// <summary>
/// Gets the name of the cache instance.
/// </summary>
/// <remarks>
/// This name is used to identity this cache when publishing telemetry.
/// </remarks>
public abstract string Name { get; }
/// <summary>
/// Gets the capacity of the cache, which represents the maximum number of items maintained by the cache at any one time.
/// </summary>
public abstract int Capacity { get; }
/// <summary>
/// Tries to get a value from the cache.
/// </summary>
/// <param name="key">Key identifying the requested value.</param>
/// <param name="value">Value associated with the key or <see langword="null"/> when not found.</param>
/// <returns>
/// <see langword="true"/> when the value was found, <see langword="false"/> otherwise.
/// </returns>
public abstract bool TryGet(TKey key, [MaybeNullWhen(false)] out TValue value);
/// <summary>
/// Sets a value in the cache.
/// </summary>
/// <remarks>
/// The value's time to expire is set to the global default value defined for this cache instance.
/// </remarks>
/// <param name="key">Key identifying the value.</param>
/// <param name="value">Value to associate with the key.</param>
public abstract void Set(TKey key, TValue value);
/// <summary>
/// Sets a value in the cache.
/// </summary>
/// <remarks>
/// After time to expire has passed, a value is not retrievable from the cache.
/// At the same time cache might keep the root for it for some time.
/// </remarks>
/// <param name="key">Key identifying the value.</param>
/// <param name="value">Value to cache on the heap.</param>
/// <param name="timeToExpire">Amount of time the value is valid, after which it should be removed from the cache.</param>
/// <exception cref="ArgumentOutOfRangeException">If <paramref name="timeToExpire"/> is less than 1 millisecond.</exception>
public abstract void Set(TKey key, TValue value, TimeSpan timeToExpire);
/// <summary>
/// Gets a value or sets it if doesn't exist.
/// </summary>
/// <remarks>
/// The value's time to expire is set to the global default value defined for this cache instance.
/// </remarks>
/// <typeparam name="TState">Type of the state passed to the function.</typeparam>
/// <param name="key">Key identifying the value.</param>
/// <param name="state">State passed to the factory function.</param>
/// <param name="factory">A function used to create a new value if the key is not found in the cache. It returns the value to be cached.</param>
/// <returns>
/// Data retrieved from the cache or created by the passed factory function.
/// </returns>
public abstract TValue GetOrSet<TState>(TKey key, TState state, Func<TKey, TState, TValue> factory);
/// <summary>
/// Gets a value or sets it if doesn't exist.
/// </summary>
/// <remarks>
/// After time to expire has passed, a value is not retrievable from the cache.
/// At the same time cache might keep the root for it for some time.
/// </remarks>
/// <typeparam name="TState">The type of the state object passed to the factory function.</typeparam>
/// <param name="key">The key identifying the cached value.</param>
/// <param name="state">An additional state object passed to the factory function.</param>
/// <param name="factory">
/// A function used to create a new value if the key is not found in the cache. The function returns a tuple where the
/// first item is the value to cache, and the second item is a <see cref="TimeSpan"/> representing the duration for which
/// the value remains valid in the cache before expiring.
/// </param>
/// <returns>
/// The value associated with the key, either retrieved from the cache or created by the factory function.
/// </returns>
public abstract TValue GetOrSet<TState>(TKey key, TState state,
Func<TKey, TState, (TValue value, TimeSpan timeToExpire)> factory);
/// <summary>
/// Gets a value or sets it if doesn't exist.
/// </summary>
/// <remarks>
/// The value's time to expire is set to the global default value defined for this cache instance.
/// </remarks>
/// <param name="key">Key identifying the value.</param>
/// <param name="factory">A function used to create a new value if the key is not found in the cache. It returns the value to be cached.</param>
/// <returns>
/// Data retrieved from the cache or created by the passed factory function.
/// </returns>
public abstract TValue GetOrSet(TKey key, Func<TKey, TValue> factory);
/// <summary>
/// Gets a value or sets it if doesn't exist.
/// </summary>
/// <remarks>
/// After time to expire has passed, a value is not retrievable from the cache.
/// At the same time cache might keep the root for it for some time.
/// </remarks>
/// <param name="key">Key identifying the value.</param>
/// <param name="factory">
/// A function used to create a new value if the key is not found in the cache. The function returns a tuple where the
/// first item is the value to cache, and the second item is a <see cref="TimeSpan"/> representing the duration for which
/// the value remains valid in the cache before expiring.
/// </param>
/// <returns>
/// Data retrieved from the cache or created by the passed factory function.
/// </returns>
public abstract TValue GetOrSet(TKey key, Func<TKey, (TValue value, TimeSpan timeToExpire)> factory);
/// <summary>
/// Gets the current number of items in the cache.
/// </summary>
/// <returns>
/// This method is inherently imprecise as threads may be asynchronously adding
/// or removing items. In addition, this method may be relatively costly, so avoid
/// calling it in hot paths.
/// </returns>
public abstract int GetCount();
/// <summary>
/// Attempts to remove the value that has the specified key.
/// </summary>
/// <param name="key">Key identifying the value to remove.</param>
/// <returns>
/// <see langword="true"/> if the value was removed, and <see langword="false"/> if the key was not found.
/// </returns>
public abstract bool Remove(TKey key);
/// <summary>
/// Attempts to remove the specified key-value pair from the cache.
/// </summary>
/// <param name="key">Key identifying the value to remove.</param>
/// <param name="value">The value associated with the key to remove.</param>
/// <returns>
/// <see langword="true"/> if the specified key-value pair was found and removed;
/// otherwise, <see langword="false"/>.
/// </returns>
/// <remarks>
/// This method checks both the key and the associated value for a match before removal.
/// If the specified key exists but is associated with a different value, the cache remains unchanged.
/// </remarks>
public abstract bool Remove(TKey key, TValue value);
/// <summary>
/// Removes all expired entries from the cache.
/// </summary>
/// <remarks>
/// Some implementations perform lazy cleanup of cache resources. This call is a hint to
/// ask the cache to try and synchronously do some cleanup.
/// </remarks>
public abstract void RemoveExpiredEntries();
/// <summary>
/// Returns an enumerator that iterates through the items in the cache.
/// </summary>
/// <returns>
/// An enumerator for the cache.
/// </returns>
/// <remarks>
/// This can be a slow API and is intended for use in debugging and diagnostics, avoid using in production scenarios.
///
/// The enumerator returned from the cache is safe to use concurrently with
/// reads and writes, however it does not represent a moment-in-time snapshot.
/// The contents exposed through the enumerator may contain modifications
/// made after <see cref="GetEnumerator"/> was called.
/// </remarks>
public abstract IEnumerator<KeyValuePair<TKey, TValue>> GetEnumerator();
/// <summary>
/// Returns an enumerator that iterates through the items in the cache.
/// </summary>
/// <returns>
/// An enumerator for the cache.
/// </returns>
/// <remarks>
/// This can be a slow API and is intended for use in debugging and diagnostics, avoid using in production scenarios.
///
/// The enumerator returned from the cache is safe to use concurrently with
/// reads and writes, however it does not represent a moment-in-time snapshot.
/// The contents exposed through the enumerator may contain modifications
/// made after <see cref="GetEnumerator"/> was called.
/// </remarks>
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
/// <summary>
/// Options for LRU (Least Recently Used) implementations of <see cref="RCache{TKey, TValue}"/>.
/// </summary>
/// <typeparam name="TKey">Type of keys stored in the cache.</typeparam>
public class RCacheLruOptions<TKey>
where TKey : notnull
{
/// <summary>
/// Gets or sets the maximum number of items that can be stored in the cache.
/// </summary>
/// <value>
/// Defaults to 1024.
/// </value>
[Range(3, int.MaxValue - 1)]
public int Capacity { get; set; } = 1024;
/// <summary>
/// Gets or sets the default time to evict individual items from the cache.
/// </summary>
/// <remarks>
/// This value is used by methods which do not accept an explicit time to evict parameter.
/// If you don't want your items to be ever evicted due to time, set this value to <see cref="TimeSpan.MaxValue"/>.
/// </remarks>
/// <value>
/// Defaults to 5 minutes.
/// </value>
[TimeSpan(minMs: 1)]
public TimeSpan DefaultTimeToEvict { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Gets or sets the amount of time by which an items's eviction time is extended upon a cache hit.
/// </summary>
/// <remarks>
/// This value is ignored when <see cref="ExtendTimeToEvictAfterHit"/> is <see langword="false"/>.
/// </remarks>
/// <value>
/// Defaults to 5 minutes.
/// </value>
[TimeSpan(minMs: 1)]
public TimeSpan ExtendedTimeToEvictAfterHit { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Gets or sets a value indicating whether an item's time to evict should be extended upon a cache hit.
/// </summary>
/// <value>
/// Defaults to <see langword="false"/>.
/// </value>
public bool ExtendTimeToEvictAfterHit { get; set; }
/// <summary>
/// Gets or sets the cache's level of concurrency.
/// </summary>
/// <remarks>
/// Increase this value if you observe lock contention.
/// </remarks>
/// <value>
/// Defaults to <see cref="Environment.ProcessorCount"/>.
/// </value>
[Range(1, int.MaxValue)]
public int ConcurrencyLevel { get; set; } = Environment.ProcessorCount;
/// <summary>
/// Gets or sets the custom time provider used for timestamp generation in the cache.
/// </summary>
/// <remarks>
/// If this value is <see langword="null"/>, the cache will default to using <see cref="Environment.TickCount64"/>
/// for timestamp generation to optimize performance. If a <see cref="TimeProvider"/> is set,
/// the cache will call the <see cref="TimeProvider.GetTimestamp"/> method.
/// The <see cref="TimeProvider"/> should primarily be used for testing purposes, where custom time manipulation is required.
/// </remarks>
/// <value>
/// Defaults to <see langword="null"/>.
/// </value>
public TimeProvider? TimeProvider { get; set; }
/// <summary>
/// Gets or sets the comparer used to evaluate keys.
/// </summary>
/// <value>
/// Defaults to <see cref="EqualityComparer{T}.Default"/>,
/// except for string keys where the default is <see cref="StringComparer.Ordinal"/>.
/// </value>
public IEqualityComparer<TKey> KeyComparer { get; set; }
= typeof(TKey) == typeof(string) ? (IEqualityComparer<TKey>)StringComparer.Ordinal : EqualityComparer<TKey>.Default;
/// <summary>
/// Gets or sets a value indicating how often cache metrics are refreshed.
/// </summary>
/// <remarks>
/// Setting this value too low can lead to poor performance due to the overhead involved in
/// collecting and publish metrics.
/// </remarks>
/// <value>
/// Defaults to 30 seconds.
/// </value>
[TimeSpan(min: "00:00:05")]
public TimeSpan MetricPublicationInterval { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Gets or sets a value indicating whether metrics are published or not.
/// </summary>
/// <value>
/// Defaults to <see langword="true"/>.
/// </value>
public bool PublishMetrics { get; set; } = true;
}
/// <summary>
/// Builder for creating <see cref="RCache{TKey, TValue}"/> instances.
/// </summary>
/// <typeparam name="TKey">Type of keys stored in the cache.</typeparam>
/// <typeparam name="TValue">Type of values stored in the cache.</typeparam>
public class RCacheLruBuilder<TKey, TValue>
where TKey : notnull
{
/// <summary>
/// Initializes a new instance of the <see cref="RCacheLruBuilder{TKey,TValue}"/> class.
/// </summary>
/// <param name="name">Name of the cache, used in telemetry.</param>
/// <exception cref="ArgumentNullException">Thrown when the <paramref name="name"/> is null.</exception>
public RCacheLruBuilder(string name);
/// <summary>
/// Sets the options for the cache.
/// </summary>
/// <param name="options">Cache options.</param>
/// <returns>The current instance of the <see cref="RCacheLruBuilder{TKey,TValue}"/>.</returns>
/// <exception cref="ArgumentNullException">Thrown when the <paramref name="options"/> is null.</exception>
public RCacheLruBuilder<TKey, TValue> WithOptions(RCacheLruOptions<TKey> options);
/// <summary>
/// Sets the meter factory for the cache.
/// </summary>
/// <param name="meterFactory">Meter factory for telemetry.</param>
/// <returns>The current instance of the <see cref="RCacheLruBuilder{TKey,TValue}"/>.</returns>
public RCacheLruBuilder<TKey, TValue> WithMeterFactory(IMeterFactory? meterFactory);
/// <summary>
/// Builds the <see cref="RCache{TKey, TValue}"/> instance with the specified configurations.
/// </summary>
/// <returns>A ready-to-use <see cref="RCache{TKey, TValue}"/> instance.</returns>
/// <exception cref="OptionsValidationException">Thrown when the validation of options fails.</exception>
/// <exception cref="InvalidOperationException">Thrown when the meter factory is null but metrics publishing is enabled.</exception>
public RCache<TKey, TValue> Build();
}
/// <summary>
/// Extension methods for caching.
/// </summary>
public static class RCacheExtensions
{
/// <summary>
/// Adds an LRU (Least Recently Used) cache to the dependency injection container.
/// </summary>
/// <typeparam name="TKey">Type of keys stored in the cache.</typeparam>
/// <typeparam name="TValue">Type of values stored in the cache.</typeparam>
/// <param name="services">Dependency injection container to add the cache to.</param>
/// <returns>The value of<paramref name="services"/>.</returns>
/// <exception cref="ArgumentNullException">When passed <paramref name="services"/> are <see langword="null"/>.</exception>
public static IServiceCollection AddRCacheLru<TKey, TValue>(this IServiceCollection services)
where TKey : notnull;
/// <summary>
/// Adds a named LRU (Least Recently Used) cache to the dependency injection container.
/// </summary>
/// <typeparam name="TKey">Type of keys stored in the cache.</typeparam>
/// <typeparam name="TValue">Type of values stored in the cache.</typeparam>
/// <param name="services">Dependency injection container to add the cache to.</param>
/// <param name="name">Name of the cache, used in telemetry.</param>
/// <returns>The value of<paramref name="services"/>.</returns>
/// <exception cref="ArgumentNullException">When passed <paramref name="services"/> are <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">When passed <paramref name="name"/> is <see langword="null"/> or empty.</exception>
public static IServiceCollection AddRCacheLru<TKey, TValue>(this IServiceCollection services, string name)
where TKey : notnull;
/// <summary>
/// Adds an LRU (Least Recently Used) cache to the dependency injection container.
/// </summary>
/// <typeparam name="TKey">Type of keys stored in the cache.</typeparam>
/// <typeparam name="TValue">Type of values stored in the cache.</typeparam>
/// <param name="services">Dependency injection container to add the cache to.</param>
/// <param name="configure">A function used to configure cache options.</param>
/// <returns>The value of<paramref name="services"/>.</returns>
/// <exception cref="ArgumentNullException">When passed <paramref name="services"/> or <paramref name="configure"/> are <see langword="null"/>.</exception>
public static IServiceCollection AddRCacheLru<TKey, TValue>(this IServiceCollection services, Action<RCacheLruOptions<TKey>> configure)
where TKey : notnull;
/// <summary>
/// Adds an LRU (Least Recently Used) cache to the dependency injection container.
/// </summary>
/// <typeparam name="TKey">Type of keys stored in the cache.</typeparam>
/// <typeparam name="TValue">Type of values stored in the cache.</typeparam>
/// <param name="services">Dependency injection container to add the cache to.</param>
/// <param name="section">Configuration part that defines cache options.</param>
/// <returns>The value of<paramref name="services"/>.</returns>
/// <exception cref="ArgumentNullException">When passed <paramref name="services"/> or <paramref name="section"/> are <see langword="null"/>.</exception>
public static IServiceCollection AddRCacheLru<TKey, TValue>(this IServiceCollection services, IConfigurationSection section)
where TKey : notnull;
/// <summary>
/// Adds a named LRU (Least Recently Used) cache to the dependency injection container.
/// </summary>
/// <typeparam name="TKey">Type of keys stored in the cache.</typeparam>
/// <typeparam name="TValue">Type of values stored in the cache.</typeparam>
/// <param name="services">Dependency injection container to add the cache to.</param>
/// <param name="name">Name of the cache, used in telemetry.</param>
/// <param name="section">Configuration part that defines cache options.</param>
/// <returns>The value of<paramref name="services"/>.</returns>
/// <exception cref="ArgumentNullException">When passed <paramref name="services"/> or <paramref name="section"/> are <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">When passed <paramref name="name"/> is <see langword="null"/> or empty.</exception>
public static IServiceCollection AddRCacheLru<TKey, TValue>(this IServiceCollection services, string name,
IConfigurationSection section)
where TKey : notnull;
/// <summary>
/// Adds a named LRU (Least Recently Used) cache to the dependency injection container.
/// </summary>
/// <typeparam name="TKey">Type of keys stored in the cache.</typeparam>
/// <typeparam name="TValue">Type of values stored in the cache.</typeparam>
/// <param name="services">Dependency injection container to add the cache to.</param>
/// <param name="name">Name of the cache, used in telemetry.</param>
/// <param name="configure">A function used to configure cache options.</param>
/// <returns>The value of<paramref name="services"/>.</returns>
/// <exception cref="ArgumentNullException">When passed <paramref name="services"/> or <paramref name="configure"/> are <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">When passed <paramref name="name"/> is <see langword="null"/> or empty.</exception>
public static IServiceCollection AddRCacheLru<TKey, TValue>(this IServiceCollection services, string name,
Action<RCacheLruOptions<TKey>> configure)
where TKey : notnull;
}
/// <summary>
/// Metrics published by <see cref="RCache{TKey, TValue}"/>.
/// </summary>
public static class RCacheMetrics
{
/// <summary>
/// Name of the <see cref="Meter"/> to listen to is the name of the cache.
/// </summary>
/// <example>
/// RCache with name "test" will publish metrics with tag "cache-name" equal to "test".
/// </example>
public const string LruCacheMeterName = "Microsoft.Extensions.Cache.Memory";
/// <summary>
/// Gets the total number of cache queries that were successful.
/// </summary>
/// <remarks>
/// Metric is exposed as <see cref="ObservableCounter{T}"/> with value being <see cref="long"/>.
/// </remarks>
public const string Hits = "rcache.hits";
/// <summary>
/// Gets the total number of unsuccessful cache queries.
/// </summary>
/// <remarks>
/// Metric is exposed as <see cref="ObservableCounter{T}"/> with value being <see cref="long"/>.
/// </remarks>
public const string Misses = "rcache.misses";
/// <summary>
/// Gets the total number of expired values.
/// </summary>
/// <remarks>
/// Metric is exposed as <see cref="ObservableCounter{T}"/> with value being <see cref="long"/>.
/// Expired values are one removed from cache because they were too old.
/// </remarks>
public const string Expirations = "rcache.expirations";
/// <summary>
/// Gets the total number of values added to cache.
/// </summary>
/// <remarks>
/// This value refers to total calls to set method, and does not include updates.
/// Metric is exposed as <see cref="ObservableCounter{T}"/> with value being <see cref="long"/>.
/// </remarks>
public const string Adds = "rcache.adds";
/// <summary>
/// Gets the total number of cache removals.
/// </summary>
/// <remarks>
/// This value refers to total calls to remove method, and does not include evictions.
/// If you are interested in metric of total values being removed from the cache, add <see cref="Evictions"/> and <see cref="Expirations"/>.
/// Metric is exposed as <see cref="ObservableCounter{T}"/> with value being <see cref="long"/>.
/// </remarks>
public const string Removals = "rcache.removals";
/// <summary>
/// Gets the total number of evicted values.
/// </summary>
/// <remarks>
/// Metric is exposed as <see cref="ObservableCounter{T}"/> with value being <see cref="long"/>.
/// Evicted values are those removed based on the implementation's eviction policy and not time expiration or intentional removal.
/// </remarks>
public const string Evictions = "rcache.evictions";
/// <summary>
/// Gets the total number of updated values.
/// </summary>
/// <remarks>
/// Metric is exposed as <see cref="ObservableCounter{T}"/> with value being <see cref="long"/>.
/// </remarks>
public const string Updates = "rcache.updates";
/// <summary>
/// Gets the total number of values in the cache over time.
/// </summary>
/// <remarks>
/// Metric is exposed as <see cref="ObservableGauge{T}"/> with value being <see cref="long"/>.
/// </remarks>
public const string Count = "rcache.entries";
/// <summary>
/// Gets the gauge of elements compacted in the cache.
/// </summary>
/// <remarks>
/// Metric is exposed as <see cref="Histogram{T}"/> with value being <see cref="long"/>.
/// </remarks>
public const string Compacted = "rcache.compacted_entries";
} |
API Review recording: https://youtu.be/H5srJ2tz1vM?t=64 |
Background and motivation
We have two available memory cache libraries in .NET that are popular and well known -
System.Runtime.Caching
andMicrosoft.Extensions.Memory.Cache
. As already described in this issue there is a room for improvement in their public APIs. There are also efficiency problems that hit at high scale. Thus, our goal was to implement as efficient memory cache as possible for high concurrency and high traffic servers. During the process we've found a few additional opportunities to do slightly better than existing libraries.System.Runtime.Caching
andMicrosoft.Extensions.Caching.Memory
use mechanisms that may involve background threads or timers for managing expiration. In contrast, our implementation maintains state directly within set and get methods, avoiding thread pool overhead. Since it doesn't implementIDisposable
, there's also no risk of memory leaks from incorrect disposal, unlikeSystem.Runtime.Caching
, which allocates timers on construction.RCache implementation is based on open source library BitFaster.
Storing pair of <int, int> (this is the best situation for RCache since no boxing on key and value).
The latency includes optional cost of maintaining telemetry state on RCache side.
Feature comparison table:
** Algorithm we use has a notion of three priorities (hot, warm, cold) and respect them while rotating items. Though, we don't allow to define priorities by customer or have direct control over it.
API Proposal
API Usage
Add default cache to DI.
Get default cache from DI.
Create cache using builder.
Alternative Designs
Callbacks and notifications
We could introduce a feature from
Microsoft.Extensions.Caching.Memory
that allows to track if value was changed or implement eviction callbacks. Instead of storing a callback with each entry or some change tokens we could use System.Threading.Channels library. Through exposed ChannelReader, consumer could react on events like eviction, mutation and so on. I expect this design to be more memory/cpu efficient than existing one.Size check
We could implement item size limit through interface implemented on stored type by library client.
This design would allow us to implement size check without boxing TValue or introducing CPU branches on hot-path.
Asynchronous interface
We wanted to be explicit that everything in RCache should be sync. This approach allows us not to go into distributed systems problems, since async caches requires much richer semantics. We are going to propose another interface in the future that covers asynchronous caches scenarios and more.
Risks
Another cache
This is yet another memory cache implementation which may confuse .NET community. We should be clear in framing the purpose of this interface, and make sure design is extendable enough so we don't need to repeat the same process in the future.
Layering
Is dotnet/extensions the right place for this code? For me it looks like a general purpose data structure that should be available for the whole stack. Should we introduce it to runtime repository?
Deferred removal
Cache item removal is deferred which can keep alive objects for longer than they need to be.
The text was updated successfully, but these errors were encountered: