From e69ac4b1116c050a4c34d4907ccccba0bdbef4fc Mon Sep 17 00:00:00 2001 From: Matthew DeVenny Date: Tue, 30 Jun 2026 12:37:22 -0700 Subject: [PATCH 1/5] #40 Inject TimeProvider Signed-off-by: Matthew DeVenny --- src/NatsDistributedCache/NatsCache.cs | 24 ++++-- .../NatsDistributedCache.csproj | 10 +++ .../NatsDistributedCacheExtensions.cs | 8 +- .../Cache/TimeExpirationAsyncUnitTests.cs | 2 +- .../Cache/TimeExpirationUnitTests.cs | 2 +- .../Cache/TimeProviderExpirationUnitTests.cs | 84 +++++++++++++++++++ .../NatsDistributedCacheExtensionsTests.cs | 44 ++++++++++ test/UnitTests/TestBase.cs | 13 ++- test/UnitTests/UnitTests.csproj | 1 + test/UnitTests/packages.linux-arm64.lock.json | 12 +++ test/UnitTests/packages.linux-x64.lock.json | 12 +++ test/UnitTests/packages.osx-arm64.lock.json | 12 +++ test/UnitTests/packages.win-x64.lock.json | 12 +++ 13 files changed, 221 insertions(+), 15 deletions(-) create mode 100644 test/UnitTests/Cache/TimeProviderExpirationUnitTests.cs diff --git a/src/NatsDistributedCache/NatsCache.cs b/src/NatsDistributedCache/NatsCache.cs index 0bfe88e..9b67454 100644 --- a/src/NatsDistributedCache/NatsCache.cs +++ b/src/NatsDistributedCache/NatsCache.cs @@ -47,13 +47,15 @@ public partial class NatsCache : IBufferDistributedCache private readonly string _keyPrefix; private readonly ILogger _logger; private readonly INatsConnection _natsConnection; + private readonly TimeProvider _timeProvider; private Lazy> _lazyKvStore; public NatsCache( IOptions optionsAccessor, INatsConnection natsConnection, ILogger? logger = null, - INatsCacheKeyEncoder? keyEncoder = null) + INatsCacheKeyEncoder? keyEncoder = null, + TimeProvider? timeProvider = null) { var options = optionsAccessor.Value; _bucketName = !string.IsNullOrWhiteSpace(options.BucketName) @@ -66,6 +68,7 @@ public NatsCache( _natsConnection = natsConnection; _logger = logger ?? NullLogger.Instance; _keyEncoder = keyEncoder ?? new NatsCacheKeyEncoder(); + _timeProvider = timeProvider ?? TimeProvider.System; } /// @@ -160,9 +163,9 @@ public async ValueTask TryGetAsync( return false; } - private static TimeSpan? GetTtl(DistributedCacheEntryOptions options) + internal TimeSpan? GetTtl(DistributedCacheEntryOptions options) { - if (options.AbsoluteExpiration.HasValue && options.AbsoluteExpiration.Value <= DateTimeOffset.Now) + if (options.AbsoluteExpiration.HasValue && options.AbsoluteExpiration.Value <= _timeProvider.GetUtcNow()) { throw new ArgumentOutOfRangeException( nameof(DistributedCacheEntryOptions.AbsoluteExpiration), @@ -190,7 +193,7 @@ public async ValueTask TryGetAsync( var absoluteExpiration = options.AbsoluteExpiration; if (options.AbsoluteExpirationRelativeToNow.HasValue) { - absoluteExpiration = DateTimeOffset.Now.Add(options.AbsoluteExpirationRelativeToNow.Value); + absoluteExpiration = _timeProvider.GetUtcNow().Add(options.AbsoluteExpirationRelativeToNow.Value); } if (!absoluteExpiration.HasValue) @@ -198,7 +201,7 @@ public async ValueTask TryGetAsync( return options.SlidingExpiration; } - var ttl = absoluteExpiration.Value - DateTimeOffset.Now; + var ttl = absoluteExpiration.Value - _timeProvider.GetUtcNow(); if (ttl.TotalMilliseconds <= 0) { // Value is in the past, remove it @@ -211,12 +214,12 @@ public async ValueTask TryGetAsync( : ttl; } - private static CacheEntry CreateCacheEntry(byte[] value, DistributedCacheEntryOptions options) + internal CacheEntry CreateCacheEntry(byte[] value, DistributedCacheEntryOptions options) { var absoluteExpiration = options.AbsoluteExpiration; if (options.AbsoluteExpirationRelativeToNow.HasValue) { - absoluteExpiration = DateTimeOffset.Now.Add(options.AbsoluteExpirationRelativeToNow.Value); + absoluteExpiration = _timeProvider.GetUtcNow().Add(options.AbsoluteExpirationRelativeToNow.Value); } var cacheEntry = new CacheEntry @@ -229,6 +232,9 @@ private static CacheEntry CreateCacheEntry(byte[] value, DistributedCacheEntryOp return cacheEntry; } + internal bool IsExpired(CacheEntry entry) => + entry.AbsoluteExpiration.HasValue && _timeProvider.GetUtcNow() > entry.AbsoluteExpiration.Value; + private string GetEncodedKey(string key) => string.IsNullOrEmpty(_keyPrefix) ? _keyEncoder.Encode(key) @@ -277,7 +283,7 @@ private Lazy> CreateLazyKvStore() => } // Check absolute expiration - if (kvEntry.Value.AbsoluteExpiration != null && DateTimeOffset.Now > kvEntry.Value.AbsoluteExpiration) + if (IsExpired(kvEntry.Value)) { // NatsKVWrongLastRevisionException is caught below var natsDeleteOpts = new NatsKVDeleteOpts { Revision = kvEntry.Revision }; @@ -313,7 +319,7 @@ async Task UpdateEntryExpirationAsync(NatsKVEntry kvEntry) // If we also have an absolute expiration, make sure we don't exceed it if (kvEntry.Value.AbsoluteExpiration != null) { - var remainingTime = kvEntry.Value.AbsoluteExpiration.Value - DateTimeOffset.Now; + var remainingTime = kvEntry.Value.AbsoluteExpiration.Value - _timeProvider.GetUtcNow(); // Use the minimum of sliding window or remaining absolute time if (remainingTime > TimeSpan.Zero && remainingTime < ttl) diff --git a/src/NatsDistributedCache/NatsDistributedCache.csproj b/src/NatsDistributedCache/NatsDistributedCache.csproj index d710e6d..c2af08b 100644 --- a/src/NatsDistributedCache/NatsDistributedCache.csproj +++ b/src/NatsDistributedCache/NatsDistributedCache.csproj @@ -39,4 +39,14 @@ + + + + + diff --git a/src/NatsDistributedCache/NatsDistributedCacheExtensions.cs b/src/NatsDistributedCache/NatsDistributedCacheExtensions.cs index 4aca911..642e87e 100644 --- a/src/NatsDistributedCache/NatsDistributedCacheExtensions.cs +++ b/src/NatsDistributedCache/NatsDistributedCacheExtensions.cs @@ -35,8 +35,14 @@ public static IServiceCollection AddNatsDistributedCache( : sp.GetRequiredKeyedService(connectionServiceKey); var logger = sp.GetService>(); var keyEncoder = sp.GetService(); + var timeProvider = sp.GetService(); - return new NatsCache(optionsAccessor, natsConnection, logger: logger, keyEncoder: keyEncoder); + return new NatsCache( + optionsAccessor, + natsConnection, + logger: logger, + keyEncoder: keyEncoder, + timeProvider: timeProvider); }); return services; diff --git a/test/UnitTests/Cache/TimeExpirationAsyncUnitTests.cs b/test/UnitTests/Cache/TimeExpirationAsyncUnitTests.cs index 2285175..58a9f9a 100644 --- a/test/UnitTests/Cache/TimeExpirationAsyncUnitTests.cs +++ b/test/UnitTests/Cache/TimeExpirationAsyncUnitTests.cs @@ -11,7 +11,7 @@ public async Task AbsoluteExpirationInThePastThrowsAsync() var key = MethodKey(); var value = new byte[1]; - var expected = DateTimeOffset.Now - TimeSpan.FromMinutes(1); + var expected = TimeProvider.GetUtcNow() - TimeSpan.FromMinutes(1); await ExceptionAssert.ThrowsArgumentOutOfRangeAsync( async () => { diff --git a/test/UnitTests/Cache/TimeExpirationUnitTests.cs b/test/UnitTests/Cache/TimeExpirationUnitTests.cs index 689be07..eae3313 100644 --- a/test/UnitTests/Cache/TimeExpirationUnitTests.cs +++ b/test/UnitTests/Cache/TimeExpirationUnitTests.cs @@ -11,7 +11,7 @@ public void AbsoluteExpirationInThePastThrows() var key = MethodKey(); var value = new byte[1]; - var expected = DateTimeOffset.Now - TimeSpan.FromMinutes(1); + var expected = TimeProvider.GetUtcNow() - TimeSpan.FromMinutes(1); ExceptionAssert.ThrowsArgumentOutOfRange( () => { diff --git a/test/UnitTests/Cache/TimeProviderExpirationUnitTests.cs b/test/UnitTests/Cache/TimeProviderExpirationUnitTests.cs new file mode 100644 index 0000000..426b153 --- /dev/null +++ b/test/UnitTests/Cache/TimeProviderExpirationUnitTests.cs @@ -0,0 +1,84 @@ +using Microsoft.Extensions.Caching.Distributed; + +namespace CodeCargo.Nats.DistributedCache.UnitTests.Cache; + +/// +/// Verifies that expiration logic reads the clock from the injected , +/// enabling deterministic, instant time-expiration tests without real delays. +/// +public class TimeProviderExpirationUnitTests : TestBase +{ + [Fact] + public void AbsoluteExpirationExpiresWhenTimeAdvances() + { + var entry = Cache.CreateCacheEntry( + new byte[1], + new DistributedCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromSeconds(30))); + + Assert.False(Cache.IsExpired(entry)); + + TimeProvider.Advance(TimeSpan.FromSeconds(31)); + + Assert.True(Cache.IsExpired(entry)); + } + + [Fact] + public void SlidingOnlyEntryIsNeverAbsolutelyExpired() + { + var entry = Cache.CreateCacheEntry( + new byte[1], + new DistributedCacheEntryOptions().SetSlidingExpiration(TimeSpan.FromSeconds(30))); + + TimeProvider.Advance(TimeSpan.FromHours(1)); + + // Absolute expiration only applies to entries with an absolute expiration set. + Assert.False(Cache.IsExpired(entry)); + } + + [Fact] + public void CreateCacheEntryComputesAbsoluteExpirationFromProviderTime() + { + var entry = Cache.CreateCacheEntry( + new byte[1], + new DistributedCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromMinutes(5))); + + Assert.Equal(TimeProvider.GetUtcNow().AddMinutes(5), entry.AbsoluteExpiration); + Assert.Null(entry.SlidingExpirationTicks); + } + + [Fact] + public void GetTtlComputesRelativeExpirationFromProviderTime() + { + var ttl = Cache.GetTtl(new DistributedCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromMinutes(5))); + + Assert.Equal(TimeSpan.FromMinutes(5), ttl); + } + + [Fact] + public void GetTtlComputesAbsoluteExpirationFromProviderTime() + { + var absolute = TimeProvider.GetUtcNow().AddMinutes(10); + + var ttl = Cache.GetTtl(new DistributedCacheEntryOptions().SetAbsoluteExpiration(absolute)); + + Assert.Equal(TimeSpan.FromMinutes(10), ttl); + } + + [Fact] + public void GetTtlReturnsSlidingExpirationWhenNoAbsolute() + { + var ttl = Cache.GetTtl(new DistributedCacheEntryOptions().SetSlidingExpiration(TimeSpan.FromMinutes(3))); + + Assert.Equal(TimeSpan.FromMinutes(3), ttl); + } + + [Fact] + public void GetTtlReturnsMinimumOfSlidingAndAbsolute() + { + var ttl = Cache.GetTtl(new DistributedCacheEntryOptions() + .SetSlidingExpiration(TimeSpan.FromMinutes(2)) + .SetAbsoluteExpiration(TimeSpan.FromMinutes(10))); + + Assert.Equal(TimeSpan.FromMinutes(2), ttl); + } +} diff --git a/test/UnitTests/Extensions/NatsDistributedCacheExtensionsTests.cs b/test/UnitTests/Extensions/NatsDistributedCacheExtensionsTests.cs index f70bfc4..fe9d996 100644 --- a/test/UnitTests/Extensions/NatsDistributedCacheExtensionsTests.cs +++ b/test/UnitTests/Extensions/NatsDistributedCacheExtensionsTests.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using Microsoft.Extensions.Time.Testing; using Moq; using NATS.Client.Core; @@ -131,6 +132,49 @@ public void AddNatsCache_AcceptsConnectionServiceKey_Parameter() Assert.NotNull(optionsRegistration); } + [Fact] + public void AddNatsCache_UsesRegisteredTimeProvider() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(_mockNatsConnection.Object); + var timeProvider = new FakeTimeProvider(); + services.AddSingleton(timeProvider); + services.AddNatsDistributedCache(options => options.BucketName = "cache"); + + var cache = (NatsCache)services.BuildServiceProvider().GetRequiredService(); + var entry = cache.CreateCacheEntry( + new byte[1], + new DistributedCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromSeconds(30))); + + // Act / Assert - advancing the registered provider expires the entry, proving it is wired in + Assert.False(cache.IsExpired(entry)); + timeProvider.Advance(TimeSpan.FromSeconds(31)); + Assert.True(cache.IsExpired(entry)); + } + + [Fact] + public void AddNatsCache_DefaultsToSystemTimeProviderWhenNoneRegistered() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(_mockNatsConnection.Object); + services.AddNatsDistributedCache(options => options.BucketName = "cache"); + + var cache = (NatsCache)services.BuildServiceProvider().GetRequiredService(); + + // Act - with no registered TimeProvider the cache must fall back to TimeProvider.System (real UTC clock) + var before = DateTimeOffset.UtcNow; + var entry = cache.CreateCacheEntry( + new byte[1], + new DistributedCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromMinutes(5))); + var after = DateTimeOffset.UtcNow; + + // Assert - the absolute expiration is derived from the system clock, proving the default is wired in + Assert.NotNull(entry.AbsoluteExpiration); + Assert.InRange(entry.AbsoluteExpiration!.Value, before.AddMinutes(5), after.AddMinutes(5)); + } + [Fact] public void AddNatsCache_ReturnsServiceCollection() { diff --git a/test/UnitTests/TestBase.cs b/test/UnitTests/TestBase.cs index 99dc103..e1278f2 100644 --- a/test/UnitTests/TestBase.cs +++ b/test/UnitTests/TestBase.cs @@ -1,7 +1,7 @@ using System.Runtime.CompilerServices; -using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Microsoft.Extensions.Time.Testing; using Moq; using NATS.Client.Core; @@ -16,15 +16,22 @@ protected TestBase() mockNatsConnection.SetupGet(m => m.Opts).Returns(opts); var connection = new NatsConnection(opts); mockNatsConnection.SetupGet(m => m.Connection).Returns(connection); + TimeProvider = new FakeTimeProvider(); Cache = new NatsCache( Options.Create(new NatsCacheOptions { BucketName = "cache" }), - mockNatsConnection.Object); + mockNatsConnection.Object, + timeProvider: TimeProvider); } + /// + /// Gets the fake time provider driving the cache's clock + /// + protected FakeTimeProvider TimeProvider { get; } + /// /// Gets the cache /// - protected IDistributedCache Cache { get; } + protected NatsCache Cache { get; } /// /// Gets the key for the current test method diff --git a/test/UnitTests/UnitTests.csproj b/test/UnitTests/UnitTests.csproj index c1d5596..6889719 100644 --- a/test/UnitTests/UnitTests.csproj +++ b/test/UnitTests/UnitTests.csproj @@ -9,6 +9,7 @@ + diff --git a/test/UnitTests/packages.linux-arm64.lock.json b/test/UnitTests/packages.linux-arm64.lock.json index 08f371b..90126b0 100644 --- a/test/UnitTests/packages.linux-arm64.lock.json +++ b/test/UnitTests/packages.linux-arm64.lock.json @@ -2,6 +2,12 @@ "version": 1, "dependencies": { "net10.0": { + "Microsoft.Extensions.TimeProvider.Testing": { + "type": "Direct", + "requested": "[10.1.0, )", + "resolved": "10.1.0", + "contentHash": "wH/CohN2yvYlpyPptNwWVZxlIQ8CkFvcv1eOEZTi/y8FhqVeISLbrraaW0qvVtXRKEeO0EyapNCZzj4HtdRGRw==" + }, "Microsoft.NET.Test.Sdk": { "type": "Direct", "requested": "[18.7.0, )", @@ -434,6 +440,12 @@ } }, "net8.0": { + "Microsoft.Extensions.TimeProvider.Testing": { + "type": "Direct", + "requested": "[10.1.0, )", + "resolved": "10.1.0", + "contentHash": "wH/CohN2yvYlpyPptNwWVZxlIQ8CkFvcv1eOEZTi/y8FhqVeISLbrraaW0qvVtXRKEeO0EyapNCZzj4HtdRGRw==" + }, "Microsoft.NET.Test.Sdk": { "type": "Direct", "requested": "[18.7.0, )", diff --git a/test/UnitTests/packages.linux-x64.lock.json b/test/UnitTests/packages.linux-x64.lock.json index 08f371b..90126b0 100644 --- a/test/UnitTests/packages.linux-x64.lock.json +++ b/test/UnitTests/packages.linux-x64.lock.json @@ -2,6 +2,12 @@ "version": 1, "dependencies": { "net10.0": { + "Microsoft.Extensions.TimeProvider.Testing": { + "type": "Direct", + "requested": "[10.1.0, )", + "resolved": "10.1.0", + "contentHash": "wH/CohN2yvYlpyPptNwWVZxlIQ8CkFvcv1eOEZTi/y8FhqVeISLbrraaW0qvVtXRKEeO0EyapNCZzj4HtdRGRw==" + }, "Microsoft.NET.Test.Sdk": { "type": "Direct", "requested": "[18.7.0, )", @@ -434,6 +440,12 @@ } }, "net8.0": { + "Microsoft.Extensions.TimeProvider.Testing": { + "type": "Direct", + "requested": "[10.1.0, )", + "resolved": "10.1.0", + "contentHash": "wH/CohN2yvYlpyPptNwWVZxlIQ8CkFvcv1eOEZTi/y8FhqVeISLbrraaW0qvVtXRKEeO0EyapNCZzj4HtdRGRw==" + }, "Microsoft.NET.Test.Sdk": { "type": "Direct", "requested": "[18.7.0, )", diff --git a/test/UnitTests/packages.osx-arm64.lock.json b/test/UnitTests/packages.osx-arm64.lock.json index 08f371b..90126b0 100644 --- a/test/UnitTests/packages.osx-arm64.lock.json +++ b/test/UnitTests/packages.osx-arm64.lock.json @@ -2,6 +2,12 @@ "version": 1, "dependencies": { "net10.0": { + "Microsoft.Extensions.TimeProvider.Testing": { + "type": "Direct", + "requested": "[10.1.0, )", + "resolved": "10.1.0", + "contentHash": "wH/CohN2yvYlpyPptNwWVZxlIQ8CkFvcv1eOEZTi/y8FhqVeISLbrraaW0qvVtXRKEeO0EyapNCZzj4HtdRGRw==" + }, "Microsoft.NET.Test.Sdk": { "type": "Direct", "requested": "[18.7.0, )", @@ -434,6 +440,12 @@ } }, "net8.0": { + "Microsoft.Extensions.TimeProvider.Testing": { + "type": "Direct", + "requested": "[10.1.0, )", + "resolved": "10.1.0", + "contentHash": "wH/CohN2yvYlpyPptNwWVZxlIQ8CkFvcv1eOEZTi/y8FhqVeISLbrraaW0qvVtXRKEeO0EyapNCZzj4HtdRGRw==" + }, "Microsoft.NET.Test.Sdk": { "type": "Direct", "requested": "[18.7.0, )", diff --git a/test/UnitTests/packages.win-x64.lock.json b/test/UnitTests/packages.win-x64.lock.json index 08f371b..90126b0 100644 --- a/test/UnitTests/packages.win-x64.lock.json +++ b/test/UnitTests/packages.win-x64.lock.json @@ -2,6 +2,12 @@ "version": 1, "dependencies": { "net10.0": { + "Microsoft.Extensions.TimeProvider.Testing": { + "type": "Direct", + "requested": "[10.1.0, )", + "resolved": "10.1.0", + "contentHash": "wH/CohN2yvYlpyPptNwWVZxlIQ8CkFvcv1eOEZTi/y8FhqVeISLbrraaW0qvVtXRKEeO0EyapNCZzj4HtdRGRw==" + }, "Microsoft.NET.Test.Sdk": { "type": "Direct", "requested": "[18.7.0, )", @@ -434,6 +440,12 @@ } }, "net8.0": { + "Microsoft.Extensions.TimeProvider.Testing": { + "type": "Direct", + "requested": "[10.1.0, )", + "resolved": "10.1.0", + "contentHash": "wH/CohN2yvYlpyPptNwWVZxlIQ8CkFvcv1eOEZTi/y8FhqVeISLbrraaW0qvVtXRKEeO0EyapNCZzj4HtdRGRw==" + }, "Microsoft.NET.Test.Sdk": { "type": "Direct", "requested": "[18.7.0, )", From d536f908ac56449f0677b1b452f1e97c1793f011 Mon Sep 17 00:00:00 2001 From: Matthew DeVenny Date: Tue, 30 Jun 2026 12:49:25 -0700 Subject: [PATCH 2/5] Address review: dedupe expiration resolution, slim DI test Extract the duplicated relative-expiration resolution shared by GetTtl and CreateCacheEntry into a private ResolveAbsoluteExpiration helper so the clock-source logic lives in one place. Slim AddNatsCache_UsesRegisteredTimeProvider to assert that the registered provider drives the computed expiration, instead of re-testing the Advance->expire flip already covered by TimeProviderExpirationUnitTests. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Matthew DeVenny --- src/NatsDistributedCache/NatsCache.cs | 30 +++++++------------ .../NatsDistributedCacheExtensionsTests.cs | 10 +++---- 2 files changed, 16 insertions(+), 24 deletions(-) diff --git a/src/NatsDistributedCache/NatsCache.cs b/src/NatsDistributedCache/NatsCache.cs index 9b67454..4d9846e 100644 --- a/src/NatsDistributedCache/NatsCache.cs +++ b/src/NatsDistributedCache/NatsCache.cs @@ -190,12 +190,7 @@ public async ValueTask TryGetAsync( "The sliding expiration value must be positive."); } - var absoluteExpiration = options.AbsoluteExpiration; - if (options.AbsoluteExpirationRelativeToNow.HasValue) - { - absoluteExpiration = _timeProvider.GetUtcNow().Add(options.AbsoluteExpirationRelativeToNow.Value); - } - + var absoluteExpiration = ResolveAbsoluteExpiration(options); if (!absoluteExpiration.HasValue) { return options.SlidingExpiration; @@ -214,27 +209,24 @@ public async ValueTask TryGetAsync( : ttl; } - internal CacheEntry CreateCacheEntry(byte[] value, DistributedCacheEntryOptions options) - { - var absoluteExpiration = options.AbsoluteExpiration; - if (options.AbsoluteExpirationRelativeToNow.HasValue) - { - absoluteExpiration = _timeProvider.GetUtcNow().Add(options.AbsoluteExpirationRelativeToNow.Value); - } - - var cacheEntry = new CacheEntry + internal CacheEntry CreateCacheEntry(byte[] value, DistributedCacheEntryOptions options) => + new CacheEntry { Data = value, - AbsoluteExpiration = absoluteExpiration, + AbsoluteExpiration = ResolveAbsoluteExpiration(options), SlidingExpirationTicks = options.SlidingExpiration?.Ticks }; - return cacheEntry; - } - internal bool IsExpired(CacheEntry entry) => entry.AbsoluteExpiration.HasValue && _timeProvider.GetUtcNow() > entry.AbsoluteExpiration.Value; + // Resolves the effective absolute expiration instant: a relative expiration (offset from the + // current clock) takes precedence over an explicit absolute expiration when both are set. + private DateTimeOffset? ResolveAbsoluteExpiration(DistributedCacheEntryOptions options) => + options.AbsoluteExpirationRelativeToNow.HasValue + ? _timeProvider.GetUtcNow().Add(options.AbsoluteExpirationRelativeToNow.Value) + : options.AbsoluteExpiration; + private string GetEncodedKey(string key) => string.IsNullOrEmpty(_keyPrefix) ? _keyEncoder.Encode(key) diff --git a/test/UnitTests/Extensions/NatsDistributedCacheExtensionsTests.cs b/test/UnitTests/Extensions/NatsDistributedCacheExtensionsTests.cs index fe9d996..abcb730 100644 --- a/test/UnitTests/Extensions/NatsDistributedCacheExtensionsTests.cs +++ b/test/UnitTests/Extensions/NatsDistributedCacheExtensionsTests.cs @@ -143,14 +143,14 @@ public void AddNatsCache_UsesRegisteredTimeProvider() services.AddNatsDistributedCache(options => options.BucketName = "cache"); var cache = (NatsCache)services.BuildServiceProvider().GetRequiredService(); + + // Act - the expiration is computed from the registered (frozen) provider's clock var entry = cache.CreateCacheEntry( new byte[1], - new DistributedCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromSeconds(30))); + new DistributedCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromMinutes(5))); - // Act / Assert - advancing the registered provider expires the entry, proving it is wired in - Assert.False(cache.IsExpired(entry)); - timeProvider.Advance(TimeSpan.FromSeconds(31)); - Assert.True(cache.IsExpired(entry)); + // Assert - matches the registered fake clock, not the real system clock, proving it is wired in + Assert.Equal(timeProvider.GetUtcNow().AddMinutes(5), entry.AbsoluteExpiration); } [Fact] From 0bfca7650fe0b1b7d2a33d1ab3c35f8a4974cbfb Mon Sep 17 00:00:00 2001 From: Matthew DeVenny Date: Tue, 30 Jun 2026 13:25:52 -0700 Subject: [PATCH 3/5] Address Copilot review: rename IsExpired, make absolute boundary inclusive Rename IsExpired -> IsAbsolutelyExpired to make clear it only checks absolute expiration (sliding expiration is enforced via the NATS entry TTL), and tighten the comparison from > to >= so the absolute-expiration instant is treated as elapsed. This matches GetTtl, which already treats an absolute expiration at "now" as expired, and prevents the sliding refresh path from extending an entry past its absolute expiration at the exact boundary tick. Add AbsoluteExpirationIsExpiredAtExactInstant to lock in the inclusive boundary semantics. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Matthew DeVenny --- src/NatsDistributedCache/NatsCache.cs | 9 ++++++--- .../Cache/TimeProviderExpirationUnitTests.cs | 19 ++++++++++++++++--- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/NatsDistributedCache/NatsCache.cs b/src/NatsDistributedCache/NatsCache.cs index 4d9846e..48b200b 100644 --- a/src/NatsDistributedCache/NatsCache.cs +++ b/src/NatsDistributedCache/NatsCache.cs @@ -217,8 +217,11 @@ internal CacheEntry CreateCacheEntry(byte[] value, DistributedCacheEntryOptions SlidingExpirationTicks = options.SlidingExpiration?.Ticks }; - internal bool IsExpired(CacheEntry entry) => - entry.AbsoluteExpiration.HasValue && _timeProvider.GetUtcNow() > entry.AbsoluteExpiration.Value; + // An entry is absolutely expired once the clock reaches its absolute expiration instant. The + // boundary is inclusive (>=) to match GetTtl, which treats an absolute expiration at "now" as + // already elapsed. Sliding expiration is enforced separately via the NATS entry TTL. + internal bool IsAbsolutelyExpired(CacheEntry entry) => + entry.AbsoluteExpiration.HasValue && _timeProvider.GetUtcNow() >= entry.AbsoluteExpiration.Value; // Resolves the effective absolute expiration instant: a relative expiration (offset from the // current clock) takes precedence over an explicit absolute expiration when both are set. @@ -275,7 +278,7 @@ private Lazy> CreateLazyKvStore() => } // Check absolute expiration - if (IsExpired(kvEntry.Value)) + if (IsAbsolutelyExpired(kvEntry.Value)) { // NatsKVWrongLastRevisionException is caught below var natsDeleteOpts = new NatsKVDeleteOpts { Revision = kvEntry.Revision }; diff --git a/test/UnitTests/Cache/TimeProviderExpirationUnitTests.cs b/test/UnitTests/Cache/TimeProviderExpirationUnitTests.cs index 426b153..9bddc88 100644 --- a/test/UnitTests/Cache/TimeProviderExpirationUnitTests.cs +++ b/test/UnitTests/Cache/TimeProviderExpirationUnitTests.cs @@ -15,11 +15,24 @@ public void AbsoluteExpirationExpiresWhenTimeAdvances() new byte[1], new DistributedCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromSeconds(30))); - Assert.False(Cache.IsExpired(entry)); + Assert.False(Cache.IsAbsolutelyExpired(entry)); TimeProvider.Advance(TimeSpan.FromSeconds(31)); - Assert.True(Cache.IsExpired(entry)); + Assert.True(Cache.IsAbsolutelyExpired(entry)); + } + + [Fact] + public void AbsoluteExpirationIsExpiredAtExactInstant() + { + var entry = Cache.CreateCacheEntry( + new byte[1], + new DistributedCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromSeconds(30))); + + // Advance to exactly the absolute expiration instant: the boundary is inclusive. + TimeProvider.Advance(TimeSpan.FromSeconds(30)); + + Assert.True(Cache.IsAbsolutelyExpired(entry)); } [Fact] @@ -32,7 +45,7 @@ public void SlidingOnlyEntryIsNeverAbsolutelyExpired() TimeProvider.Advance(TimeSpan.FromHours(1)); // Absolute expiration only applies to entries with an absolute expiration set. - Assert.False(Cache.IsExpired(entry)); + Assert.False(Cache.IsAbsolutelyExpired(entry)); } [Fact] From 7f1480b37f712ccf31981c8d2ed10d76da860c5b Mon Sep 17 00:00:00 2001 From: Matthew DeVenny Date: Tue, 30 Jun 2026 13:36:49 -0700 Subject: [PATCH 4/5] Address Copilot review: rename clock-independent relative-TTL test GetTtlComputesRelativeExpirationFromProviderTime -> GetTtlReturnsConfiguredRelativeExpiration. A relative expiration produces a TTL equal to the configured duration regardless of the clock, so the old name overpromised provider-time dependence. Signed-off-by: Matthew DeVenny Co-Authored-By: Claude Opus 4.8 (1M context) --- test/UnitTests/Cache/TimeProviderExpirationUnitTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/UnitTests/Cache/TimeProviderExpirationUnitTests.cs b/test/UnitTests/Cache/TimeProviderExpirationUnitTests.cs index 9bddc88..25c3cd1 100644 --- a/test/UnitTests/Cache/TimeProviderExpirationUnitTests.cs +++ b/test/UnitTests/Cache/TimeProviderExpirationUnitTests.cs @@ -60,8 +60,9 @@ public void CreateCacheEntryComputesAbsoluteExpirationFromProviderTime() } [Fact] - public void GetTtlComputesRelativeExpirationFromProviderTime() + public void GetTtlReturnsConfiguredRelativeExpiration() { + // A relative expiration yields a TTL equal to the configured duration, independent of the clock. var ttl = Cache.GetTtl(new DistributedCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromMinutes(5))); Assert.Equal(TimeSpan.FromMinutes(5), ttl); From aac25391318b650880fc16ec168ccb5f41e8e91e Mon Sep 17 00:00:00 2001 From: Matthew DeVenny Date: Wed, 1 Jul 2026 08:51:21 -0700 Subject: [PATCH 5/5] Inject TimeProvider via init property instead of constructor param Keep the NatsCache constructor at its original 4-parameter signature (no ABI break) and expose the clock as an internal init-only TimeProvider property. The DI registration resolves an optional TimeProvider from the container and sets it via object initializer, defaulting to TimeProvider.System when none is registered. Override the clock by registering a TimeProvider in DI (documented in the README); external consumers no longer set it through the ctor. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Matthew DeVenny --- README.md | 12 +++++++++++ src/NatsDistributedCache/NatsCache.cs | 20 ++++++++++--------- .../NatsDistributedCacheExtensions.cs | 10 ++++------ test/UnitTests/TestBase.cs | 6 ++++-- 4 files changed, 31 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 090536c..81e0795 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,18 @@ await kvContext.CreateOrUpdateStoreAsync(new NatsKVConfig("cache") { LimitMarker await host.RunAsync(); ``` +## Controlling Expiration Timing + +Expiration is computed from a [`TimeProvider`](https://learn.microsoft.com/dotnet/api/system.timeprovider), +defaulting to `TimeProvider.System`. Register a `TimeProvider` in DI to override the clock the cache uses — +for example, to drive expiration deterministically in tests with +[`FakeTimeProvider`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.time.testing.faketimeprovider): + +```csharp +services.AddSingleton(new FakeTimeProvider()); +services.AddNatsDistributedCache(options => options.BucketName = "cache"); +``` + ## Additional Resources * [ASP.NET Core Hybrid Cache Documentation](https://learn.microsoft.com/en-us/aspnet/core/performance/caching/hybrid?view=aspnetcore-10.0) diff --git a/src/NatsDistributedCache/NatsCache.cs b/src/NatsDistributedCache/NatsCache.cs index 48b200b..348d305 100644 --- a/src/NatsDistributedCache/NatsCache.cs +++ b/src/NatsDistributedCache/NatsCache.cs @@ -47,15 +47,13 @@ public partial class NatsCache : IBufferDistributedCache private readonly string _keyPrefix; private readonly ILogger _logger; private readonly INatsConnection _natsConnection; - private readonly TimeProvider _timeProvider; private Lazy> _lazyKvStore; public NatsCache( IOptions optionsAccessor, INatsConnection natsConnection, ILogger? logger = null, - INatsCacheKeyEncoder? keyEncoder = null, - TimeProvider? timeProvider = null) + INatsCacheKeyEncoder? keyEncoder = null) { var options = optionsAccessor.Value; _bucketName = !string.IsNullOrWhiteSpace(options.BucketName) @@ -68,9 +66,13 @@ public NatsCache( _natsConnection = natsConnection; _logger = logger ?? NullLogger.Instance; _keyEncoder = keyEncoder ?? new NatsCacheKeyEncoder(); - _timeProvider = timeProvider ?? TimeProvider.System; } + // The clock used for all expiration calculations. Defaults to TimeProvider.System; the DI + // registration overrides it with a TimeProvider resolved from the container when one is present + // (for example, FakeTimeProvider in tests). Injected via init to keep the constructor unchanged. + internal TimeProvider TimeProvider { get; init; } = TimeProvider.System; + /// public void Set(string key, byte[] value, DistributedCacheEntryOptions options) => SetAsync(key, value, options).GetAwaiter().GetResult(); @@ -165,7 +167,7 @@ public async ValueTask TryGetAsync( internal TimeSpan? GetTtl(DistributedCacheEntryOptions options) { - if (options.AbsoluteExpiration.HasValue && options.AbsoluteExpiration.Value <= _timeProvider.GetUtcNow()) + if (options.AbsoluteExpiration.HasValue && options.AbsoluteExpiration.Value <= TimeProvider.GetUtcNow()) { throw new ArgumentOutOfRangeException( nameof(DistributedCacheEntryOptions.AbsoluteExpiration), @@ -196,7 +198,7 @@ public async ValueTask TryGetAsync( return options.SlidingExpiration; } - var ttl = absoluteExpiration.Value - _timeProvider.GetUtcNow(); + var ttl = absoluteExpiration.Value - TimeProvider.GetUtcNow(); if (ttl.TotalMilliseconds <= 0) { // Value is in the past, remove it @@ -221,13 +223,13 @@ internal CacheEntry CreateCacheEntry(byte[] value, DistributedCacheEntryOptions // boundary is inclusive (>=) to match GetTtl, which treats an absolute expiration at "now" as // already elapsed. Sliding expiration is enforced separately via the NATS entry TTL. internal bool IsAbsolutelyExpired(CacheEntry entry) => - entry.AbsoluteExpiration.HasValue && _timeProvider.GetUtcNow() >= entry.AbsoluteExpiration.Value; + entry.AbsoluteExpiration.HasValue && TimeProvider.GetUtcNow() >= entry.AbsoluteExpiration.Value; // Resolves the effective absolute expiration instant: a relative expiration (offset from the // current clock) takes precedence over an explicit absolute expiration when both are set. private DateTimeOffset? ResolveAbsoluteExpiration(DistributedCacheEntryOptions options) => options.AbsoluteExpirationRelativeToNow.HasValue - ? _timeProvider.GetUtcNow().Add(options.AbsoluteExpirationRelativeToNow.Value) + ? TimeProvider.GetUtcNow().Add(options.AbsoluteExpirationRelativeToNow.Value) : options.AbsoluteExpiration; private string GetEncodedKey(string key) => @@ -314,7 +316,7 @@ async Task UpdateEntryExpirationAsync(NatsKVEntry kvEntry) // If we also have an absolute expiration, make sure we don't exceed it if (kvEntry.Value.AbsoluteExpiration != null) { - var remainingTime = kvEntry.Value.AbsoluteExpiration.Value - _timeProvider.GetUtcNow(); + var remainingTime = kvEntry.Value.AbsoluteExpiration.Value - TimeProvider.GetUtcNow(); // Use the minimum of sliding window or remaining absolute time if (remainingTime > TimeSpan.Zero && remainingTime < ttl) diff --git a/src/NatsDistributedCache/NatsDistributedCacheExtensions.cs b/src/NatsDistributedCache/NatsDistributedCacheExtensions.cs index 642e87e..293d2be 100644 --- a/src/NatsDistributedCache/NatsDistributedCacheExtensions.cs +++ b/src/NatsDistributedCache/NatsDistributedCacheExtensions.cs @@ -37,12 +37,10 @@ public static IServiceCollection AddNatsDistributedCache( var keyEncoder = sp.GetService(); var timeProvider = sp.GetService(); - return new NatsCache( - optionsAccessor, - natsConnection, - logger: logger, - keyEncoder: keyEncoder, - timeProvider: timeProvider); + return new NatsCache(optionsAccessor, natsConnection, logger: logger, keyEncoder: keyEncoder) + { + TimeProvider = timeProvider ?? TimeProvider.System, + }; }); return services; diff --git a/test/UnitTests/TestBase.cs b/test/UnitTests/TestBase.cs index e1278f2..c137300 100644 --- a/test/UnitTests/TestBase.cs +++ b/test/UnitTests/TestBase.cs @@ -19,8 +19,10 @@ protected TestBase() TimeProvider = new FakeTimeProvider(); Cache = new NatsCache( Options.Create(new NatsCacheOptions { BucketName = "cache" }), - mockNatsConnection.Object, - timeProvider: TimeProvider); + mockNatsConnection.Object) + { + TimeProvider = TimeProvider, + }; } ///