Conversation
…d each cache item expires independently
…is added or updated not removed,
…mpact due to max item count.
| int limit = Math.Min(_maxItems.GetValueOrDefault(values.Count), values.Count); | ||
| if (values.Count > limit) | ||
| { | ||
| _logger.LogWarning( | ||
| "Received {TotalCount} items but max items is {MaxItems}: processing only the first {Limit}", | ||
| values.Count, _maxItems, limit); | ||
| } | ||
|
|
||
| // NOTE: Would be great to target Parallel.ForEachAsync but we need .NET 6+; | ||
|
|
||
| // Use the whole dictionary when possible, otherwise copy just the slice we need. | ||
| var work = limit == values.Count | ||
| ? (IReadOnlyDictionary<string, T>)values | ||
| : values.Take(limit).ToDictionary(kv => kv.Key, kv => kv.Value); | ||
|
|
||
| bool[] results = await Task.WhenAll(tasks).AnyContext(); | ||
| return results.Count(r => r); | ||
| const int batchSize = 1_024; | ||
|
|
||
| // Local function satisfies the ValueTask-returning delegate | ||
| async ValueTask ProcessSliceAsync(ReadOnlyMemory<KeyValuePair<string, T>> slice) | ||
| { | ||
| for (int i = 0; i < slice.Span.Length; i++) | ||
| { | ||
| var pair = slice.Span[i]; | ||
| await SetAsync(pair.Key, pair.Value, expiresIn).AnyContext(); | ||
| } | ||
| } | ||
|
|
||
| await work.BatchAsync(batchSize, ProcessSliceAsync).AnyContext(); |
There was a problem hiding this comment.
This added some complexity... But seemed bad taking max item count of tasks wide and waiting on them....
TL;DR
For an in‑process async cache, spawning a Task for every item is all overhead and no benefit.
Using the BatchAsync helper with a simple for loop:
-
Cuts allocations from n
Tasks (e.g. 50 k) to a single rented buffer. -
Eliminates thread‑pool thrash and lock contention on
MemoryCache. -
Achieves essentially the same write throughput because each
SetAsyncfinishes immediately.
Why the batched, serial approach wins
| Aspect | Task‑per‑item fan‑out | BatchAsync + serial loop |
|---|---|---|
| Live Task objects (50 k items) | 50 000 | 1 (handler) |
| Extra memory | ≈ 7–10 MB | ≈ 8–16 KB (buffer) |
| Thread‑pool work items | 50 000 | ~50 (one per slice) |
| Dictionary lock contention | Very high | Negligible |
| Peak parallelism | Unbounded | 1 (or ≤ cores if you add a semaphore) |
When not to stay serial
Add a semaphore‑throttled fan‑out inside the slice only if:
-
SetAsyncstarts doing real I/O (remote Redis, file writes, HTTP). -
Each call performs heavy CPU work (e.g., compression, JSON serialisation).
Set Parallelism = Environment.ProcessorCount to keep memory bounded while still overlapping latency.
// Simple, memory‑lean batching (good for local cache)
await work.BatchAsync(1024, async slice =>
{
var span = slice.Span;
for (int i = 0; i < span.Length; i++)
{
var pair = span[i];
await SetAsync(pair.Key, pair.Value, expiresIn)
.ConfigureAwait(false);
}
});
You’ll stay fast for 5‑item inputs and 50 000‑item inputs—without flooding the GC or the thread‑pool.
There was a problem hiding this comment.
Pull Request Overview
This pull request refactors the cache client’s list handling so that each cache item now expires independently rather than using a sliding expiration, and it also removes the "expires in" parameter on ListRemoveAsync. Key changes include new test methods for enhanced list management, a switch from HashSet to a dictionary‐based approach for caching list items with explicit expiration timestamps, and the introduction of DictionaryExtensions.BatchAsync to streamline processing of large dictionaries.
Reviewed Changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/Foundatio.Tests/Caching/InMemoryCacheClientTests.cs | Added tests for string lists, paging, and expiration scenarios to verify the new independent expiration behavior. |
| src/Foundatio/Extensions/DictionaryExtensions.cs | Introduced BatchAsync to process dictionaries in fixed‐size batches for improved memory use. |
| src/Foundatio/Caching/InMemoryCacheClient.cs | Modified list add/remove logic to use dictionaries for expiration handling and reorganized maintenance tasks. |
| src/Foundatio.TestHarness/Caching/HybridCacheClientTests.cs | Added new tests mirroring the updated list management behavior for the hybrid cache client. |
| src/Foundatio.TestHarness/Caching/CacheClientTestsBase.cs | Updated test cases to use new language syntax and cover additional expiration-related scenarios. |
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
ejsmith
left a comment
There was a problem hiding this comment.
Looks like some good changes. Will be really nice for list items to expire individually. I guess the cache contract didn't end up changing so we don't really need to do a major for this, eh?
| /// <summary> | ||
| /// Used by the maintenance task to remove expired keys. | ||
| /// </summary> | ||
| private long RemoveKeyIfExpired(string key, bool sendNotification = true) |
There was a problem hiding this comment.
So now we have a duplicate method that was doing the exact same thing as the old one? I'm confused why we need both of these.
There was a problem hiding this comment.
See previous comment. This one is called from the maintenance worker which happens in the background as values are possibly changing. Notice this one only updates it if the entry is marked as expired. Previously it wasn't working how we thought it was where the passed in expires was expired but the cache entry wasn't was leading to it not being removed.
| var work = limit >= values.Count | ||
| ? (IReadOnlyDictionary<string, T>)values | ||
| : values.Take(limit).ToDictionary(kv => kv.Key, kv => kv.Value); |
There was a problem hiding this comment.
I wouldn't even do this. Just warn like you are and process all of them and let SetAsync do it's job of maintaining max items limit.
There was a problem hiding this comment.
It's more complicated to move this into setAsync because this is in SetAll and easier to truncate. If you move it in, it has to do a lot more lock contention and work, it's easier to just say replace all these with the max count.
There was a problem hiding this comment.
This is what I'm saying to remove because I don't think it's correct to just drop some of the items you passed in and told it to set.
There was a problem hiding this comment.
You are changing the behavior at a minimum where previously it would've kept the last X items as it passed them all into SetAsync. I just think in general, this isn't a real-world thing that we should be changing code for.
There was a problem hiding this comment.
I can take the last x items instead of the first x items. But I think it's realistic that it's going to happen in some capacity especially if you set an in memory cache really low. Our tests were doing this..
There was a problem hiding this comment.
Yes, that is test code. It's pretty useless to set a really small max items limit in the real world. So the chances that you'd pass in more items to SetAll than the max items is just not going to be a real world thing.
There was a problem hiding this comment.
I don't see why it's not a bad thing to prevent any amount of work, why would you want to take on any extra locking or batching?
There was a problem hiding this comment.
To me, it just seems like an extremely unlikely scenario that we are adding code for and I don't like that. That's all I'm saying. It's not the end of the world to have the code if it's still doing the correct thing, but you could definitely argue that the correct thing is to set all of the cache entries like the caller of the method would expect to happen. Right now you already changed the behavior by not using the last X items, you are also affecting things like various cache counts.
There was a problem hiding this comment.
Just updated it to take the last limit of max items (last in wins).
Yeah only thing we are missing is probably incrementing some counters like _writes but we have a warning logged out..


Uh oh!
There was an error while loading. Please reload this page.