Measuring and Testing Caches: Hit Ratio, Metrics, Memory Usage and Consistency Tests
A cache proves its worth through p95 and database load, not hit ratio: the report cache cut p95 from 245 ms to 1.3 ms and 3,001 queries to 1; the book cache at 87% hit ratio didn't change p95 but cut queries by 87%; the one at 16% should go. In .NET 10 you measure with TrackStatistics, a Meter and HybridCache's EventSource; FakeTimeProvider and Testcontainers tests caught tag invalidation not reaching another server.
Version note: The code in this post was built and run with .NET 10; the examples ran on Microsoft.Extensions.Caching.Hybrid 10.11.0, StackExchange.Redis 3.4.31, OpenTelemetry 1.20.1, Microsoft.Extensions.TimeProvider.Testing 10.11.0, Testcontainers.Redis 4.16.0, xUnit v3 4.0.2, Redis 8.2 and k6 2.3.
Guess First
Bookstore has a separate IMemoryCache for book details: a 10,000-entry limit and a 60-second TTL (Time to Live). The query reads a single row through the unique index on ISBN (International Standard Book Number). We try two traffic distributions: in one, all 50,000 books are equally likely; in the other, 90 percent of requests go to the 1,000 most popular books. At 1,000 requests per second, for 30 seconds.
Three questions. What hit ratio does the uniform distribution produce? If the popular distribution reaches an 87 percent hit ratio, how much does p95 drop? And which of these caches should you remove?
Which Numbers, From Where?
In the first post of the series we said "add the measurement on the day you add the cache". In .NET 10 the pieces of that measurement come from different places, and some aren't ready-made. The table uses the same L1 (level 1, in-process cache) and L2 (level 2, a shared cache such as Redis) split as the earlier posts:
| Number | Source in .NET 10 | Note |
|---|---|---|
| L1 hits, misses, entry count, estimated size | TrackStatistics and GetCurrentStatistics() | Turning them into metrics is up to you |
| Eviction count and reason | PostEvictionCallbacks and your own Counter | Not reachable for HybridCache's L1 |
| L2 hits/misses, factory calls, stampede joins | The Microsoft-Extensions-HybridCache EventSource | In-process, with an EventListener |
| L2 latency, application side | An IDistributedCache decorator and a Histogram | Includes network and client |
| Redis hits/misses, evictions, command time | INFO stats, INFO commandstats, redis-cli --latency | Server side |
| Database load | The Entity Framework Core (EF Core) interceptor | What the cache is really there to protect |
In .NET 11, MemoryCache publishes some of these metrics itself; that's the topic of the tenth post of the series. Here we build what works in .NET 10 today.
Turning IMemoryCache Statistics into Metrics
With MemoryCacheOptions.TrackStatistics on, GetCurrentStatistics() returns total hits, total misses, the current entry count and, when SizeLimit is set, the estimated size; with it off, it returns null. We turn these values into instruments that OpenTelemetry can read, using System.Diagnostics.Metrics:
// turns IMemoryCache statistics and evictions into metrics OpenTelemetry can read
public sealed class CacheMetrics
{
public const string MeterName = "Bookstore.Caching";
private readonly ConcurrentDictionary<string, IMemoryCache> _caches = new();
private readonly Counter<long> _evictions;
private readonly Histogram<double> _l2Duration;
public CacheMetrics(IMeterFactory meterFactory)
{
Meter meter = meterFactory.Create(MeterName);
// hit/miss counters come from the cache's own statistics, computed when the exporter reads them
meter.CreateObservableCounter("bookstore.cache.requests", () => Observe(stats =>
[(stats.TotalHits, "hit"), (stats.TotalMisses, "miss")]), unit: "{request}");
meter.CreateObservableGauge("bookstore.cache.entries", () => Observe(stats =>
[(stats.CurrentEntryCount, null)]), unit: "{entry}");
meter.CreateObservableGauge("bookstore.cache.estimated_size", () => Observe(stats =>
[(stats.CurrentEstimatedSize ?? 0, null)]), unit: "{size_unit}");
_evictions = meter.CreateCounter<long>("bookstore.cache.evictions", unit: "{entry}");
_l2Duration = meter.CreateHistogram<double>("bookstore.cache.l2.duration", unit: "ms");
}
public void Track(string name, IMemoryCache cache) => _caches[name] = cache;
public void RecordEviction(string cache, EvictionReason reason) =>
_evictions.Add(1, new("cache", cache), new("reason", reason.ToString()));
public void RecordL2(string operation, double milliseconds) =>
_l2Duration.Record(milliseconds, new KeyValuePair<string, object?>("operation", operation));
private IEnumerable<Measurement<long>> Observe(Func<MemoryCacheStatistics, (long Value, string? Result)[]> select)
{
foreach ((string name, IMemoryCache cache) in _caches)
{
if (cache.GetCurrentStatistics() is not { } stats) continue;
foreach ((long value, string? result) in select(stats))
{
yield return result is null
? new Measurement<long>(value, new KeyValuePair<string, object?>("cache", name))
: new Measurement<long>(value, new("cache", name), new("result", result));
}
}
}
}
Hit and miss totals are already kept inside the cache, so we use an ObservableCounter: the callback runs only when the exporter reads the metric (every 10 seconds here), with no cost on the request path. Entry count and size are point-in-time values, so they're ObservableGauges. Each cache is separated by a cache tag; never put a key in a tag, since every key becomes its own time series. Evictions aren't in the statistics, so the book cache counts them itself:
// a separate L1 for book details: limited by entry count, statistics and evictions measured
public sealed class BookCache
{
private readonly MemoryCache _cache;
private readonly CacheMetrics _metrics;
private readonly TimeSpan _ttl;
public BookCache(CacheMetrics metrics, IConfiguration configuration)
{
_cache = new MemoryCache(new MemoryCacheOptions
{
SizeLimit = configuration.GetValue("BookCacheEntries", 10_000),
TrackStatistics = true
});
_metrics = metrics;
_ttl = TimeSpan.FromSeconds(configuration.GetValue("BookTtlSeconds", 60));
metrics.Track("books", _cache);
}
public IMemoryCache Inner => _cache;
public Task<BookDto?> GetOrLoadAsync(string isbn, Func<Task<BookDto?>> load) =>
_cache.GetOrCreateAsync($"v1:book:{isbn}", entry =>
{
entry.Size = 1;
entry.AbsoluteExpirationRelativeToNow = _ttl;
entry.RegisterPostEvictionCallback((_, _, reason, _) => _metrics.RecordEviction("books", reason));
return load();
});
}
On the registration side we also turn on statistics for the shared IMemoryCache that HybridCache uses. Its SizeLimit is in bytes, because HybridCache sets an L1 entry's Size to the byte count of the serialized payload. We look at the metrics through the OpenTelemetry console exporter; in production an OTLP (OpenTelemetry Protocol) exporter takes its place:
// L1 statistics on; SizeLimit in bytes (HybridCache sets entry Size to the payload's byte count)
builder.Services.AddMemoryCache(o =>
{
o.TrackStatistics = true;
o.SizeLimit = builder.Configuration.GetValue<long>("L1SizeLimitBytes", 64 * 1024 * 1024);
});
IConnectionMultiplexer redis = await ConnectionMultiplexer.ConnectAsync(builder.Configuration["Redis"]!);
builder.Services.AddSingleton(redis);
builder.Services.AddSingleton<CacheMetrics>();
builder.Services.AddSingleton<BookCache>();
builder.Services.AddSingleton<IDistributedCache>(sp => new TimedDistributedCache(
new RedisCache(Options.Create(new RedisCacheOptions { ConnectionMultiplexerFactory = () => Task.FromResult(redis) })),
sp.GetRequiredService<CacheMetrics>()));
builder.Services.AddHybridCache();
builder.Services.AddOpenTelemetry()
.ConfigureResource(resource => resource.AddService("bookstore-catalog"))
.WithMetrics(metrics => metrics
.AddMeter(CacheMetrics.MeterName)
.AddView("bookstore.cache.l2.duration", new ExplicitBucketHistogramConfiguration { Boundaries = [0.25, 0.5, 1, 2, 5, 10] })
.AddConsoleExporter((_, reader) => reader.PeriodicExportingMetricReaderOptions.ExportIntervalMilliseconds = 10_000));
TimedDistributedCache is a thirty-line decorator that wraps Redis's IDistributedCache and writes the duration of every GetAsync/SetAsync to RecordL2, measured with Stopwatch.GetTimestamp. The book cache lines from the exporter's last output at the end of the uniform k6 run:
Metric Name: bookstore.cache.requests, Unit: {request}, Metric Type: LongSum
(2026-11-08T02:41:50.0450610Z, 2026-11-08T02:42:30.0406080Z] cache: books result: hit
Value: 4813
(2026-11-08T02:41:50.0450610Z, 2026-11-08T02:42:30.0406080Z] cache: books result: miss
Value: 25044
Metric Name: bookstore.cache.entries, Unit: {entry}, Metric Type: LongGauge
(2026-11-08T02:41:50.0456340Z, 2026-11-08T02:42:30.0406130Z] cache: books
Value: 9858
Metric Name: bookstore.cache.evictions, Unit: {entry}, Metric Type: LongSum
(2026-11-08T02:41:50.0456500Z, 2026-11-08T02:42:30.0406170Z] cache: books reason: Replaced
Value: 1
(2026-11-08T02:41:50.0456500Z, 2026-11-08T02:42:30.0406170Z] cache: books reason: Capacity
Value: 15185
The answer to the first guess: 4,813 / (4,813 + 25,044), that is 16.1 percent. The cache is full (9,858 entries against a 10,000 limit), and 15,185 entries were evicted for capacity in 30 seconds: the cache keeps writing and dropping data it can't hold long enough to be useful. Compute the hit ratio from these two counters on the dashboard with PromQL (Prometheus Query Language) or similar; computing it in the application and publishing it as a gauge gives the wrong average when you aggregate across instances.
HybridCache's Own Counters
HybridCache publishes its L2 and factory side through an EventSource called Microsoft-Extensions-HybridCache, with counters such as total-local-cache-hits, total-distributed-cache-misses, total-data-query and total-stampede-joins. These counters only increase while someone is listening. To read them in-process without installing a global tool like dotnet-counters, a small EventListener is enough:
// reads HybridCache's own EventSource counters in-process (no dotnet-counters needed)
public sealed class HybridCacheCounters : EventListener
{
private readonly ConcurrentDictionary<string, double> _values = new();
public IReadOnlyDictionary<string, double> Snapshot => _values;
protected override void OnEventSourceCreated(EventSource source)
{
if (source.Name == "Microsoft-Extensions-HybridCache")
{
EnableEvents(source, EventLevel.Verbose, EventKeywords.All,
new Dictionary<string, string?> { ["EventCounterIntervalSec"] = "1" });
}
}
protected override void OnEventWritten(EventWrittenEventArgs e)
{
if (e.EventName != "EventCounters" || e.Payload is not [IDictionary<string, object> counter]) return;
// a PollingCounter carries the latest value in its "Mean" field
_values[(string)counter["Name"]] = Convert.ToDouble(counter["Mean"]);
}
}
This time we read books through HybridCache, from an endpoint that keeps them 5 seconds in L1 and 5 minutes in L2; popular distribution, 1,000 requests per second, 30 seconds. The /stats endpoint returns the interceptor's query count, the statistics of both IMemoryCaches and the EventSource counters together:
$ curl -s localhost:17545/stats
{"queries":3924,"books":{"hits":0,"misses":0,"hitRatio":0,"entries":0,"size":0},"hybridL1":{"hits":21638,"misses":8213,"hitRatio":0.725,"entries":3924,"size":268697},"hybrid":{"current-data-query":0,"current-distributed-cache-fetches":0,"total-data-query":3924,"total-distributed-cache-hits":4273,"total-distributed-cache-misses":3924,"total-distributed-cache-writes":3924,"total-local-cache-hits":21638,"total-local-cache-misses":8213,"total-local-cache-writes":8197,"total-stampede-joins":16,"total-tag-invalidations":0}}
Here's how to read it: of 29,851 requests, 21,638 were served from L1 (72.5 percent); of the 8,213 that missed L1, 4,273 found the value in Redis and 3,924 went to the database. The overall cache hit rate is 86.9 percent, and the database's query count matches total-data-query exactly. The 3,924 books take 268,697 bytes in L1, 68 bytes per entry; that's the number you derive the memory budget from. total-stampede-joins counts the 16 calls that waited on the factory for the same key and shared its result. One more number matters: the L1 miss count is not the database query count. For the category report L1 saw 29 misses but only 1 query reached the database, because the concurrent misses were collapsed into a single factory call.
The Redis Side: INFO, Command Times and Latency
The same run as Redis saw it, from counters reset with CONFIG RESETSTAT before the run:
$ docker exec chwork-redis-d redis-cli INFO stats | grep -E "keyspace_(hits|misses)|evicted_keys"
evicted_keys:0
keyspace_hits:4273
keyspace_misses:3927
$ docker exec chwork-redis-d redis-cli INFO commandstats | grep -E "hmget|hmset"
cmdstat_hmget:calls=8198,usec=21075,usec_per_call=2.57,rejected_calls=0,failed_calls=0
cmdstat_hmset:calls=3924,usec=17994,usec_per_call=4.59,rejected_calls=0,failed_calls=0
keyspace_hits at 4,273 matches HybridCache's L2 hits exactly; the difference of 3 in misses comes from a few other reads when the connection opens. evicted_keys is zero because this container has no maxmemory; what happens when it fills up is the eviction policy topic of the third post of the series. Redis processes an HMGET in 2.57 microseconds on average. The histogram the application measured for the same calls tells a different story:
Metric Name: bookstore.cache.l2.duration, Unit: ms, Metric Type: Histogram
(2026-11-08T02:50:25.0806260Z, 2026-11-08T02:51:05.0764660Z] operation: get
Value: Sum: 14017.185399999971 Count: 8198 Min: 0.107 Max: 260.0854
(-Infinity,0.25]:3372
(0.25,0.5]:3829
(0.5,1]:817
(1,2]:32
(2,5]:23
(5,10]:1
(10,+Infinity]:124
87.8 percent of the 8,198 reads finished under half a millisecond, but 124 exceeded 10 ms and the slowest took 260 ms; the average is 1.7 ms. redis-cli --latency-history -i 3 from inside the container barely sees the network at all:
min: 0, max: 2, avg: 0.20 (253 samples) -- 3.01 seconds range
min: 0, max: 3, avg: 0.19 (251 samples) -- 3.00 seconds range
The three numbers measure three different layers: the server's command processing time (microseconds), the round trip from the point closest to the server (0.2 ms), and the time the application actually waits (including the network, Docker Desktop's virtual network, the multiplexer queue and the thread pool). If you set an alert on L2 latency, use the third one; a "Redis is slow" complaint often doesn't show up on the server side at all.
Does the Cache Actually Pay Off?
A hit ratio alone doesn't decide anything; the real question is how much the cache changes p95 and database load. Every row is a 30-second constant-arrival-rate run after restarting the application and resetting Redis and the counters. On the k6 side a single function decides the distribution:
function bookId() {
if (__ENV.DIST === 'hot' && Math.random() < 0.9) return 1 + Math.floor(Math.random() * 1000);
return 1 + Math.floor(Math.random() * 50000);
}
| Endpoint and traffic | p95 uncached | p95 cached | Queries uncached | Queries cached | Hit ratio |
|---|---|---|---|---|---|
| Category report, 100 requests/s | 245.3 ms | 1.3 ms | 3,001 | 1 | 99% |
| Book details, uniform, 1,000 requests/s | 1.2 ms | 1.4 ms | 29,873 | 25,044 | 16.1% |
| Book details, popular, 1,000 requests/s | 1.2 ms | 1.1 ms | 29,867 | 3,870 | 87% |
The answer to the second guess: p95 barely moves (from 1.2 ms to 1.1 ms). An indexed single-row read is already cheap; the request time is dominated by the HTTP (Hypertext Transfer Protocol) layer and serialization. The gain from the cache under the popular distribution isn't latency but database load: the query count dropped by 87 percent. If the database is a shared, expensive resource, this cache earns its place, but its justification should read "protects the database", not "makes it faster".
The cache under the uniform distribution should go. Hit ratio is 16 percent, p95 got slightly worse (from 1.2 to 1.4 ms), queries dropped by only 16 percent, and for that 9,858 entries sat in memory while 15,185 evictions happened in 30 seconds. The cache also adds an invalidation duty to every write path. A rule you can use: if the hit ratio is low and the source query is cheap, remove the cache; if the hit ratio is low but the query is expensive, look at key design and TTL first (keys with too many parts, a TTL shorter than needed); if the hit ratio is high but p95 doesn't change, the cache's only job is protecting the database, so measure that and defend it on those terms. The category report wins on all three measures and leaves nothing to argue about.
Testing Expiration with FakeTimeProvider
Writing an expiration test with Thread.Sleep is both slow and fragile. FakeTimeProvider from the Microsoft.Extensions.TimeProvider.Testing package lets you move the clock by hand. But in .NET 10, MemoryCacheOptions.Clock is still of type Microsoft.Extensions.Internal.ISystemClock; there's no option that takes a TimeProvider, so you need a two-line bridge:
// MemoryCacheOptions.Clock still expects ISystemClock; a bridge that connects TimeProvider to it
public sealed class TimeProviderClock(TimeProvider time) : ISystemClock
{
public DateTimeOffset UtcNow => time.GetUtcNow();
}
public sealed class MemoryCacheExpirationTests
{
private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 11, 8, 9, 0, 0, TimeSpan.Zero));
private MemoryCache CreateCache() => new(new MemoryCacheOptions { Clock = new TimeProviderClock(_time) });
[Fact]
public void Absolute_expiration_removes_entry_after_ttl()
{
using MemoryCache cache = CreateCache();
cache.Set("v1:category-menu", "menu", TimeSpan.FromMinutes(5));
_time.Advance(TimeSpan.FromMinutes(4) + TimeSpan.FromSeconds(59));
Assert.True(cache.TryGetValue("v1:category-menu", out _));
_time.Advance(TimeSpan.FromSeconds(2));
Assert.False(cache.TryGetValue("v1:category-menu", out _));
}
[Fact]
public void Sliding_expiration_is_extended_by_reads()
{
using MemoryCache cache = CreateCache();
cache.Set("v1:cart:42", "cart", new MemoryCacheEntryOptions { SlidingExpiration = TimeSpan.FromMinutes(1) });
// an entry read every 50 seconds doesn't die
for (int i = 0; i < 5; i++)
{
_time.Advance(TimeSpan.FromSeconds(50));
Assert.True(cache.TryGetValue("v1:cart:42", out _));
}
_time.Advance(TimeSpan.FromSeconds(61));
Assert.False(cache.TryGetValue("v1:cart:42", out _));
}
}
With HybridCache the picture is a bit more involved. L1 lifetime follows the underlying IMemoryCache's clock, so the bridge above is required there too. Tag invalidation times, on the other hand, come from the TimeProvider in the DI (dependency injection) container (in the source: services.GetService<TimeProvider>() ?? TimeProvider.System). We wrote a "server" builder that ties both to the same fake clock, and a small menu service that uses it:
public static class Instance
{
// one "server": its own L1, an optional shared Redis L2 and a clock the test controls
public static ServiceProvider Create(FakeTimeProvider time, MenuStore store, string? redis = null)
{
ServiceCollection services = new();
services.AddSingleton<TimeProvider>(time);
services.AddMemoryCache(o => o.Clock = new TimeProviderClock(time));
if (redis is not null)
{
services.AddStackExchangeRedisCache(o => o.Configuration = redis);
}
services.AddHybridCache();
services.AddSingleton(store);
services.AddSingleton<CategoryMenu>();
return services.BuildServiceProvider();
}
}
CategoryMenu holds a GetOrCreateAsync with a 1-minute LocalCacheExpiration, a 30-minute Expiration and the category-menu tag, plus two invalidation methods (InvalidateByTagAsync, InvalidateKeyAsync); MenuStore plays the database and counts how many times it's read. The first version of the tag test (named Tag_invalidation_with_a_frozen_clock at the time) had no Advance line, and it failed. The final version and the first version's output:
[Fact]
public async Task Tag_invalidation_needs_a_moving_clock()
{
await using ServiceProvider server = Instance.Create(_time, _store);
CategoryMenu menu = server.GetRequiredService<CategoryMenu>();
await menu.GetAsync();
_store.Name = "Literature";
await menu.InvalidateByTagAsync();
// with a frozen clock, an entry written in the same tick as the invalidation also counts as invalid
_time.Advance(TimeSpan.FromMilliseconds(1));
for (int i = 0; i < 5; i++) Assert.Equal("Literature", await menu.GetAsync());
Assert.Equal(2, _store.Loads);
}
failed Bookstore.Catalog.Tests.HybridCacheTimeTests.Tag_invalidation_with_a_frozen_clock (53ms)
Assert.Equal() Failure: Values differ
Expected: 2
Actual: 6
The value was correct every time, but the factory ran on all five reads. HybridCache treats an entry as invalid if its creation time isn't greater than the tag's invalidation time; on a real clock the few ticks in between take care of that, but on a frozen fake clock every entry written after the invalidation lands on the same instant. In short: IMemoryCache and HybridCache's L1 are fully testable on a fake clock, tag tests need the clock moved after the invalidation, and Redis's TTL isn't affected by the fake clock at all because it runs on server time.
Redis and Consistency Tests with Testcontainers
Testing multi-server behavior needs a real Redis. Testcontainers starts and stops a container per test class; it's the cache-side counterpart of the setup in the ASP.NET Core Web API (Application Programming Interface) series post "Integration Testing an ASP.NET Core Web API: End-to-End Tests with WebApplicationFactory":
// one Redis container per test class: fixed name and port, removed when the tests finish
public sealed class RedisFixture : IAsyncLifetime
{
private readonly RedisContainer _container = new RedisBuilder("redis:8.2")
.WithName("chwork-redis-test-d")
.WithPortBinding(17555, 6379)
.Build();
public string ConnectionString => _container.GetConnectionString();
public async ValueTask InitializeAsync() => await _container.StartAsync();
public async ValueTask DisposeAsync() => await _container.DisposeAsync();
public Task FlushAsync() => _container.ExecAsync(["redis-cli", "FLUSHALL"]);
// HybridCache writes to L2 after returning to the caller; wait for that write in the test
public async Task WaitForL2Async(string key, string expected)
{
await using ConnectionMultiplexer connection = await ConnectionMultiplexer.ConnectAsync(ConnectionString);
IDatabase db = connection.GetDatabase();
for (int i = 0; i < 100; i++)
{
RedisValue data = await db.HashGetAsync(key, "data");
if (data.HasValue && data.ToString().Contains(expected, StringComparison.Ordinal)) return;
await Task.Delay(20);
}
throw new TimeoutException($"{key} never contained {expected}");
}
}
In Testcontainers.Redis 4.16 the parameterless RedisBuilder() is obsolete; if you don't pass the image to the constructor you get a CS0618 warning. WaitForL2Async was born from a test that failed on the first run: when server B read right after server A returned the value, the factory ran twice. The comment in HybridCache's source is clear: the L2 write happens in the background after the waiting callers are released. In production that's a window of a few milliseconds; in a test it's a randomly failing test. Three tests pin down two servers sharing the same Redis:
[Fact]
public async Task Second_server_reads_from_redis_without_running_the_factory()
{
await using ServiceProvider serverA = Instance.Create(_time, _store, redis.ConnectionString);
await using ServiceProvider serverB = Instance.Create(_time, _store, redis.ConnectionString);
Assert.Equal("Fiction", await serverA.GetRequiredService<CategoryMenu>().GetAsync());
await redis.WaitForL2Async(CategoryMenu.Key, "Fiction");
Assert.Equal("Fiction", await serverB.GetRequiredService<CategoryMenu>().GetAsync());
Assert.Equal(1, _store.Loads);
}
[Fact]
public async Task Key_removal_reaches_other_server_after_its_local_expiration()
{
(CategoryMenu menuA, CategoryMenu menuB) = await TwoWarmServersAsync();
_store.Name = "Literature";
await menuA.InvalidateKeyAsync();
// A's L1 and Redis are clean; B's L1 knows nothing about it
Assert.Equal("Literature", await menuA.GetAsync());
Assert.Equal("Fiction", await menuB.GetAsync());
// once B's LocalCacheExpiration (1 min) runs out, it goes to Redis
_time.Advance(TimeSpan.FromMinutes(1) + TimeSpan.FromSeconds(1));
Assert.Equal("Literature", await menuB.GetAsync());
}
[Fact]
public async Task Tag_invalidation_alone_does_not_reach_a_server_that_already_read_the_tag()
{
(CategoryMenu menuA, CategoryMenu menuB) = await TwoWarmServersAsync();
_store.Name = "Literature";
await menuA.InvalidateByTagAsync();
_time.Advance(TimeSpan.FromMinutes(1) + TimeSpan.FromSeconds(1));
// B read the tag's state from Redis earlier and kept it; it treats the old value in Redis as valid
Assert.Equal("Fiction", await menuB.GetAsync());
Assert.Equal(1, _store.Loads);
}
The third test documents a warning. With key removal, the entry in Redis is gone, and once B's L1 expires B runs the factory and gets the new value. With tag removal, the old payload stays in Redis and its validity is decided by the tag's invalidation time. In HybridCache's source on GitHub, B reads that time from Redis the first time it needs it for a tag, keeps it in-process and never asks again; the test shows 10.11.0 behaves the same way. The result: B, having read the menu before, still treats the old menu in Redis as valid after its L1 expires, and that lasts until someone rewrites the key or the L2 lifetime (30 minutes here) runs out. The practical fix is to also delete the keys you know with RemoveAsync on the write path, or to have the new value recomputed right away; the backplane approach from the fourth post of the series solves it at the root. If a future package version changes this behavior, this test will fail and tell you.
With the .NET 10 SDK (Software Development Kit), dotnet test no longer supports VSTest mode for Microsoft.Testing.Platform, which xUnit v3 uses, and fails with "Testing with VSTest target is no longer supported by Microsoft.Testing.Platform on .NET 10 SDK and later". The fix is to add "test": { "runner": "Microsoft.Testing.Platform" } to the global.json above the test project. Since we turned Ryuk off, removing the container is entirely up to DisposeAsync; in CI (Continuous Integration) keep Ryuk on, it cleans up after a crashed test run:
$ DOTNET_CLI_UI_LANGUAGE=en TESTCONTAINERS_RYUK_DISABLED=true dotnet test --no-build --output detailed
Running tests from bin/Debug/net10.0/Ch09.Tests.dll (net10.0|arm64)
passed Bookstore.Catalog.Tests.MemoryCacheExpirationTests.Absolute_expiration_removes_entry_after_ttl (10ms)
passed Bookstore.Catalog.Tests.MemoryCacheExpirationTests.Sliding_expiration_is_extended_by_reads (0ms)
passed Bookstore.Catalog.Tests.HybridCacheTimeTests.Local_entry_expires_on_the_fake_clock (23ms)
passed Bookstore.Catalog.Tests.HybridCacheTimeTests.Tag_invalidation_needs_a_moving_clock (1ms)
passed Bookstore.Catalog.Tests.HybridCacheRedisTests.Key_removal_reaches_other_server_after_its_local_expiration (100ms)
passed Bookstore.Catalog.Tests.HybridCacheRedisTests.Second_server_reads_from_redis_without_running_the_factory (25ms)
passed Bookstore.Catalog.Tests.HybridCacheRedisTests.Tag_invalidation_alone_does_not_reach_a_server_that_already_read_the_tag (30ms)
bin/Debug/net10.0/Ch09.Tests.dll (net10.0|arm64) passed (1s 166ms)
Test run summary: Passed!
total: 7
failed: 0
succeeded: 7
skipped: 0
duration: 1s 658ms
$ docker ps -a --filter name=chwork-redis-test -q | wc -l
0
In Production
Track the hit ratio per cache and don't publish a single global ratio; a 99 percent menu cache hides a 16 percent book cache in the average. Set the alert not on the hit ratio alone but on the hit ratio and the database query count degrading together: right after a deploy the ratio is low on a cold cache, which is normal, but if it's still low 15 minutes later a key scheme has changed or a TTL got shorter.
Keep the cost of metrics low. Observable instruments run only at export time; Counter.Add, however, is called on every eviction, so keep the number of tags small (cache and reason are enough). On the Redis side, the ratio of keyspace_misses to keyspace_hits and growth in evicted_keys show L2's health, and INFO commandstats shows the server's own load; don't confuse them with the L2 time the application sees.
Make the decision to remove a cache with measurements too, and keep it easy to roll back: if you can run the endpoint uncached behind a configuration flag, you can reproduce the table above in production with a one-hour comparison. Sending the metrics to a collector and putting them on a dashboard belongs to the OpenTelemetry side of the upcoming Logging series.
Common Pitfalls
Taking an L1 miss for a database query. Because of coalescing, 29 misses can be a single query; read database load from the interceptor or from total-data-query.
Using the key as a metric tag. Every ISBN opens a separate time series, and you take down your metrics backend before the cache.
Treating the hit ratio as the goal. An 87 percent hit ratio didn't change p95; measure what the cache improves, every time, separately.
Assuming FakeTimeProvider moves everything forward. MemoryCacheOptions.Clock needs a bridge, Redis's TTL never moves, and in tag tests the clock must not stay frozen.
Not waiting for the L2 write in a test. HybridCache writes to L2 in the background; a test that reads from the second server right away fails now and then.
Assuming tag invalidation reaches the other servers. Pin down whether it does with an integration test.
A Small Experiment
★☆☆ Raise BookCacheEntries to 50,000 and repeat the uniform run. How do the hit ratio, eviction count and p95 change? Does your decision to remove the cache change?
★★☆ Add a RenameAsync to CategoryMenu that rereads the menu right after the tag invalidation, and write a copy of the third test with this method. Does B see the new value once its L1 expires? Why?
★★★ Add a tag to TimedDistributedCache that separates hit and miss results and repeat the same run. Do reads that find a value in Redis and reads that don't have different duration distributions? How do you explain the difference?
In this post we built the ways of measuring a cache that work in .NET 10 today, and pinned down two surprises with tests: tag invalidation on a frozen clock, and a tag that doesn't reach the other server. The last post of the series looks at .NET 11: MemoryCache's built-in metrics, setting entry options from inside the factory in HybridCache, and IOutputCachePolicyProvider for output cache.