Skip to content

[BREAKING]: Cache Client lists no longer have a sliding expiration and each cache item expires independently#376

Merged
niemyjski merged 11 commits intomainfrom
bugfix/cache-client-list-item-sliding-cache-expiration
Apr 22, 2025
Merged

[BREAKING]: Cache Client lists no longer have a sliding expiration and each cache item expires independently#376
niemyjski merged 11 commits intomainfrom
bugfix/cache-client-list-item-sliding-cache-expiration

Conversation

@niemyjski
Copy link
Copy Markdown
Member

@niemyjski niemyjski commented Apr 17, 2025

  • We should also seriously consider removing the expires in param on ListRemoveAsync.
  • We should look for any race conditions
  • Does ordering matter between the clients? We might be able to use some sorted data structure for in memory.

@niemyjski niemyjski added the bug label Apr 17, 2025
@niemyjski niemyjski requested review from Copilot and ejsmith April 17, 2025 20:34
@niemyjski niemyjski self-assigned this Apr 17, 2025
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

@niemyjski
Copy link
Copy Markdown
Member Author

main

image

pr

image

Comment on lines +735 to +762
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();
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 SetAsync finishes 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:

  • SetAsync starts 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.

@niemyjski niemyjski requested a review from Copilot April 18, 2025 21:09
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Copy link
Copy Markdown
Contributor

@ejsmith ejsmith left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +746 to +748
var work = limit >= values.Count
? (IReadOnlyDictionary<string, T>)values
: values.Take(limit).ToDictionary(kv => kv.Key, kv => kv.Value);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

@niemyjski niemyjski Apr 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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..

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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..

@niemyjski niemyjski requested a review from ejsmith April 21, 2025 18:14
@niemyjski niemyjski merged commit eca4559 into main Apr 22, 2025
3 checks passed
@niemyjski niemyjski deleted the bugfix/cache-client-list-item-sliding-cache-expiration branch April 22, 2025 16:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants