Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<TimeProvider>(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)
Expand Down
49 changes: 26 additions & 23 deletions src/NatsDistributedCache/NatsCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// <inheritdoc />
public void Set(string key, byte[] value, DistributedCacheEntryOptions options) =>
SetAsync(key, value, options).GetAwaiter().GetResult();
Expand Down Expand Up @@ -160,9 +165,9 @@ public async ValueTask<bool> 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),
Expand All @@ -187,18 +192,13 @@ public async ValueTask<bool> 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
Expand All @@ -211,23 +211,26 @@ public async ValueTask<bool> 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)
Expand Down Expand Up @@ -277,7 +280,7 @@ private Lazy<Task<INatsKVStore>> 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 };
Expand Down Expand Up @@ -313,7 +316,7 @@ async Task UpdateEntryExpirationAsync(NatsKVEntry<CacheEntry> 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)
Expand Down
10 changes: 10 additions & 0 deletions src/NatsDistributedCache/NatsDistributedCache.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,14 @@
<None Include="..\..\README.md" Pack="true" PackagePath="\" />
</ItemGroup>

<!-- Expose internals to the unit tests for white-box time/expiration testing.
A signed (strong-named) assembly cannot grant InternalsVisibleTo to the unsigned UnitTests
assembly, so this is gated to unsigned builds. That covers every build that actually compiles
the tests: CI (build.yml) and local dev never write the .snk, and the signed publish job
(publish.yml) runs `dotnet pack`, which does not build test projects. Caveat: dropping the
.snk into keys/ locally flips the build to signed and will break the UnitTests compile. -->
<ItemGroup Condition="'$(SignAssembly)' != 'true'">
<InternalsVisibleTo Include="UnitTests" />
</ItemGroup>

</Project>
6 changes: 5 additions & 1 deletion src/NatsDistributedCache/NatsDistributedCacheExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,12 @@ public static IServiceCollection AddNatsDistributedCache(
: sp.GetRequiredKeyedService<INatsConnection>(connectionServiceKey);
var logger = sp.GetService<ILogger<NatsCache>>();
var keyEncoder = sp.GetService<INatsCacheKeyEncoder>();
var timeProvider = sp.GetService<TimeProvider>();

return new NatsCache(optionsAccessor, natsConnection, logger: logger, keyEncoder: keyEncoder);
return new NatsCache(optionsAccessor, natsConnection, logger: logger, keyEncoder: keyEncoder)
{
TimeProvider = timeProvider ?? TimeProvider.System,
};
});

return services;
Expand Down
2 changes: 1 addition & 1 deletion test/UnitTests/Cache/TimeExpirationAsyncUnitTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 () =>
{
Expand Down
2 changes: 1 addition & 1 deletion test/UnitTests/Cache/TimeExpirationUnitTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
() =>
{
Expand Down
98 changes: 98 additions & 0 deletions test/UnitTests/Cache/TimeProviderExpirationUnitTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
using Microsoft.Extensions.Caching.Distributed;

namespace CodeCargo.Nats.DistributedCache.UnitTests.Cache;

/// <summary>
/// Verifies that expiration logic reads the clock from the injected <see cref="System.TimeProvider"/>,
/// enabling deterministic, instant time-expiration tests without real delays.
/// </summary>
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);
}
}
44 changes: 44 additions & 0 deletions test/UnitTests/Extensions/NatsDistributedCacheExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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>(timeProvider);
services.AddNatsDistributedCache(options => options.BucketName = "cache");

var cache = (NatsCache)services.BuildServiceProvider().GetRequiredService<IDistributedCache>();

// 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<IDistributedCache>();

// 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()
{
Expand Down
15 changes: 12 additions & 3 deletions test/UnitTests/TestBase.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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,
};
}

/// <summary>
/// Gets the fake time provider driving the cache's clock
/// </summary>
protected FakeTimeProvider TimeProvider { get; }

/// <summary>
/// Gets the cache
/// </summary>
protected IDistributedCache Cache { get; }
protected NatsCache Cache { get; }

/// <summary>
/// Gets the key for the current test method
Expand Down
1 change: 1 addition & 0 deletions test/UnitTests/UnitTests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.1.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit.v3" Version="3.2.2" />
Expand Down
12 changes: 12 additions & 0 deletions test/UnitTests/packages.linux-arm64.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -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, )",
Expand Down Expand Up @@ -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, )",
Expand Down
Loading
Loading