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 0bfe88e..348d305 100644 --- a/src/NatsDistributedCache/NatsCache.cs +++ b/src/NatsDistributedCache/NatsCache.cs @@ -68,6 +68,11 @@ public NatsCache( _keyEncoder = keyEncoder ?? new NatsCacheKeyEncoder(); } + // 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(); @@ -160,9 +165,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), @@ -187,18 +192,13 @@ public async ValueTask TryGetAsync( "The sliding expiration value must be positive."); } - var absoluteExpiration = options.AbsoluteExpiration; - if (options.AbsoluteExpirationRelativeToNow.HasValue) - { - absoluteExpiration = DateTimeOffset.Now.Add(options.AbsoluteExpirationRelativeToNow.Value); - } - + var absoluteExpiration = ResolveAbsoluteExpiration(options); if (!absoluteExpiration.HasValue) { 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,23 +211,26 @@ public async ValueTask TryGetAsync( : ttl; } - private static CacheEntry CreateCacheEntry(byte[] value, DistributedCacheEntryOptions options) - { - var absoluteExpiration = options.AbsoluteExpiration; - if (options.AbsoluteExpirationRelativeToNow.HasValue) - { - absoluteExpiration = DateTimeOffset.Now.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; - } + // 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. + private DateTimeOffset? ResolveAbsoluteExpiration(DistributedCacheEntryOptions options) => + options.AbsoluteExpirationRelativeToNow.HasValue + ? TimeProvider.GetUtcNow().Add(options.AbsoluteExpirationRelativeToNow.Value) + : options.AbsoluteExpiration; private string GetEncodedKey(string key) => string.IsNullOrEmpty(_keyPrefix) @@ -277,7 +280,7 @@ private Lazy> CreateLazyKvStore() => } // Check absolute expiration - if (kvEntry.Value.AbsoluteExpiration != null && DateTimeOffset.Now > kvEntry.Value.AbsoluteExpiration) + if (IsAbsolutelyExpired(kvEntry.Value)) { // NatsKVWrongLastRevisionException is caught below var natsDeleteOpts = new NatsKVDeleteOpts { Revision = kvEntry.Revision }; @@ -313,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 - 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..293d2be 100644 --- a/src/NatsDistributedCache/NatsDistributedCacheExtensions.cs +++ b/src/NatsDistributedCache/NatsDistributedCacheExtensions.cs @@ -35,8 +35,12 @@ 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 ?? TimeProvider.System, + }; }); 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..25c3cd1 --- /dev/null +++ b/test/UnitTests/Cache/TimeProviderExpirationUnitTests.cs @@ -0,0 +1,98 @@ +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.IsAbsolutelyExpired(entry)); + + TimeProvider.Advance(TimeSpan.FromSeconds(31)); + + 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] + 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.IsAbsolutelyExpired(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 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); + } + + [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..abcb730 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(); + + // Act - the expiration is computed from the registered (frozen) provider's clock + var entry = cache.CreateCacheEntry( + new byte[1], + new DistributedCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromMinutes(5))); + + // 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] + 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..c137300 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,24 @@ 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, )",