Skip to content

Commit 12375b1

Browse files
committed
Implement CreationTimeProvider to allow for further control of when a cache items factory is invoked
+semver: minor
1 parent 9271970 commit 12375b1

File tree

9 files changed

+244
-14
lines changed

9 files changed

+244
-14
lines changed

AsyncMemoryCache.Tests/AsyncMemoryCache.Tests.csproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,15 @@
3636
<PrivateAssets>all</PrivateAssets>
3737
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
3838
</PackageReference>
39-
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.0-pre.49">
39+
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
4040
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
4141
<PrivateAssets>all</PrivateAssets>
4242
</PackageReference>
4343
<PackageReference Include="coverlet.collector" Version="6.0.4">
4444
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
4545
<PrivateAssets>all</PrivateAssets>
4646
</PackageReference>
47-
<PackageReference Include="xunit.v3" Version="0.6.0-pre.7" />
47+
<PackageReference Include="xunit.v3" Version="1.1.0" />
4848
</ItemGroup>
4949

5050
<ItemGroup>

AsyncMemoryCache.Tests/AsyncMemoryCacheTests.cs

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,32 +5,36 @@
55
using NSubstitute;
66
using System;
77
using System.Collections.Generic;
8-
using System.Threading;
98
using System.Threading.Tasks;
109
using Xunit;
1110

1211
namespace AsyncMemoryCache.Tests;
1312

1413
public class AsyncMemoryCacheTests
1514
{
16-
[Fact]
15+
[Fact(Timeout = 10000)]
1716
public async Task FactoryIsInvoked_DoesNotBlock()
1817
{
1918
var configuration = CreateConfiguration();
2019
var target = AsyncMemoryCache<string, IAsyncDisposable>.Create(configuration);
2120

22-
var semaphore = new SemaphoreSlim(0, 1);
21+
var taskCompletionSource = new TaskCompletionSource();
22+
var factoryCompletionSource = new TaskCompletionSource();
2323

24-
var factory = () =>
24+
var factory = async () =>
2525
{
26-
semaphore.Wait(TestContext.Current.CancellationToken);
27-
return Task.FromResult(Substitute.For<IAsyncDisposable>());
26+
taskCompletionSource.SetResult();
27+
await factoryCompletionSource.Task;
28+
return Substitute.For<IAsyncDisposable>();
2829
};
2930

3031
CacheEntityReference<string, IAsyncDisposable>? cacheEntityReference = null;
31-
var ex = await Record.ExceptionAsync(() => Task.Run(() => cacheEntityReference = target.GetOrCreate("test", factory)).WaitAsync(TimeSpan.FromMilliseconds(500), TestContext.Current.CancellationToken));
32+
var creationTask = Task.Run(() => cacheEntityReference = target.GetOrCreate("test", factory), TestContext.Current.CancellationToken);
33+
34+
await taskCompletionSource.Task;
35+
36+
await creationTask; // This should not block, intentionally not setting result on factoryCompletionSource
3237

33-
Assert.Null(ex);
3438
Assert.NotNull(cacheEntityReference);
3539
Assert.True(cacheEntityReference.CacheEntity.ObjectFactory.IsStarted);
3640
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
using AsyncMemoryCache.CreationBehaviors;
2+
using Microsoft.Extensions.Time.Testing;
3+
using Nito.AsyncEx;
4+
using NSubstitute;
5+
using System;
6+
using System.Collections.Generic;
7+
using System.Diagnostics;
8+
using System.Threading;
9+
using System.Threading.Tasks;
10+
using Xunit;
11+
12+
namespace AsyncMemoryCache.Tests
13+
{
14+
public class CacheItemFactoryInvokerTests
15+
{
16+
[Fact]
17+
public void InvokeFactory_ShouldInvokeObjectFactoryAfterDelay()
18+
{
19+
using var syncContext = new SingleThreadSynchronizationContext();
20+
21+
var manualResetEvent = new ManualResetEvent(false);
22+
23+
var factory = () =>
24+
{
25+
manualResetEvent.Set();
26+
return Task.FromResult(Substitute.For<IAsyncDisposable>());
27+
};
28+
29+
var cacheEntity = new CacheEntity<string, IAsyncDisposable>("test", factory, AsyncLazyFlags.None);
30+
31+
var creationTimeProvider = Substitute.For<ICreationTimeProvider>();
32+
var timeProvider = new FakeTimeProvider();
33+
var expectedCreationTime = timeProvider.GetUtcNow().AddMilliseconds(100);
34+
creationTimeProvider.GetExpectedCreationTime().Returns(expectedCreationTime);
35+
36+
CacheItemFactoryInvoker.InvokeFactory(cacheEntity, creationTimeProvider, timeProvider);
37+
38+
syncContext.RunOnCurrentThread();
39+
40+
Assert.False(cacheEntity.ObjectFactory.IsStarted);
41+
timeProvider.Advance(TimeSpan.FromMilliseconds(99));
42+
Assert.False(cacheEntity.ObjectFactory.IsStarted);
43+
timeProvider.Advance(TimeSpan.FromMilliseconds(100));
44+
45+
manualResetEvent.WaitOne();
46+
Assert.True(cacheEntity.ObjectFactory.IsStarted);
47+
}
48+
49+
[Fact]
50+
public void InvokeFactory_ShouldInvokeObjectFactoryImmediatelyIfNoDelay()
51+
{
52+
using var syncContext = new SingleThreadSynchronizationContext();
53+
54+
var manualResetEvent = new ManualResetEvent(false);
55+
56+
var factory = () =>
57+
{
58+
manualResetEvent.Set();
59+
return Task.FromResult(Substitute.For<IAsyncDisposable>());
60+
};
61+
62+
var cacheEntity = new CacheEntity<string, IAsyncDisposable>("test", factory, AsyncLazyFlags.None);
63+
64+
var sw = Stopwatch.StartNew();
65+
CacheItemFactoryInvoker.InvokeFactory(cacheEntity, CreationTimeProvider.Default, TimeProvider.System);
66+
67+
syncContext.RunOnCurrentThread();
68+
69+
manualResetEvent.WaitOne();
70+
Assert.InRange(sw.ElapsedMilliseconds, 0, 50);
71+
}
72+
73+
private class SingleThreadSynchronizationContext : SynchronizationContext, IDisposable
74+
{
75+
private readonly Queue<(SendOrPostCallback, object?)> _queue = new();
76+
private readonly SynchronizationContext? _originalContext;
77+
78+
public SingleThreadSynchronizationContext()
79+
{
80+
_originalContext = Current;
81+
SetSynchronizationContext(this);
82+
}
83+
84+
public override void Post(SendOrPostCallback d, object? state)
85+
{
86+
_queue.Enqueue((d, state));
87+
}
88+
89+
public void RunOnCurrentThread()
90+
{
91+
while (_queue.Count > 0)
92+
{
93+
var (callback, state) = _queue.Dequeue();
94+
callback(state);
95+
}
96+
}
97+
98+
public void Dispose()
99+
{
100+
SetSynchronizationContext(_originalContext);
101+
}
102+
}
103+
}
104+
}

AsyncMemoryCache/AsyncMemoryCache.cs

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using Microsoft.Extensions.Logging;
1+
using AsyncMemoryCache.CreationBehaviors;
2+
using Microsoft.Extensions.Logging;
23
using Microsoft.Extensions.Logging.Abstractions;
34
using Nito.AsyncEx;
45
using System;
@@ -38,8 +39,9 @@ public interface IAsyncMemoryCache<TKey, TValue>
3839
/// <param name="key">The cache item key.</param>
3940
/// <param name="objectFactory">The object factory.</param>
4041
/// <param name="lazyFlags">Optional <see cref="AsyncLazyFlags"/> to configure object factory behavior.</param>
42+
/// <param name="creationTimeProvider">Optional <see cref="ICreationTimeProvider"/> to configure the creation time.</param>
4143
/// <returns>A <see cref="CacheEntityReference{TKey, TValue}"/> representing the lifetime of the underlying <see cref="CacheEntity{TKey, TValue}"/> until disposed.</returns>
42-
CacheEntityReference<TKey, TValue> GetOrCreate(TKey key, Func<Task<TValue>> objectFactory, AsyncLazyFlags lazyFlags = AsyncLazyFlags.None);
44+
CacheEntityReference<TKey, TValue> GetOrCreate(TKey key, Func<Task<TValue>> objectFactory, AsyncLazyFlags lazyFlags = AsyncLazyFlags.None, ICreationTimeProvider? creationTimeProvider = default);
4345

4446
/// <summary>
4547
/// Determines whether the <see cref="IAsyncMemoryCache{TKey, TValue}"/> contains an element with the specified key.
@@ -110,14 +112,18 @@ public CacheEntityReference<TKey, TValue> this[TKey key]
110112
}
111113

112114
/// <inheritdoc/>
113-
public CacheEntityReference<TKey, TValue> GetOrCreate(TKey key, Func<Task<TValue>> objectFactory, AsyncLazyFlags lazyFlags = AsyncLazyFlags.None)
115+
public CacheEntityReference<TKey, TValue> GetOrCreate(TKey key, Func<Task<TValue>> objectFactory, AsyncLazyFlags lazyFlags = AsyncLazyFlags.None, ICreationTimeProvider? creationTimeProvider = default)
114116
{
115117
if (TryGetValue(key, out var entity))
116118
return entity;
117119

118120
_logger.LogTrace("Adding item with key: {Key}", key);
119121
var cacheEntity = new CacheEntity<TKey, TValue>(key, objectFactory, lazyFlags);
120-
cacheEntity.ObjectFactory.Start();
122+
#if !NET8_0_OR_GREATER
123+
CacheItemFactoryInvoker.InvokeFactory(cacheEntity, creationTimeProvider ?? CreationTimeProvider.Default);
124+
#else
125+
CacheItemFactoryInvoker.InvokeFactory(cacheEntity, creationTimeProvider ?? CreationTimeProvider.Default, TimeProvider.System);
126+
#endif
121127
_cache[key] = cacheEntity;
122128

123129
_logger.LogTrace("Added item with key: {Key}", key);

AsyncMemoryCache/AsyncMemoryCache.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@
4444
</PackageReference>
4545
</ItemGroup>
4646

47+
<ItemGroup Condition="$(TargetFramework) == 'net462'">
48+
<PackageReference Include="System.ValueTuple" Version="4.4.0" />
49+
</ItemGroup>
50+
4751
<ItemGroup>
4852
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.2" />
4953
<PackageReference Include="Nito.AsyncEx.Coordination" Version="5.1.2" />
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
using System;
2+
using System.Threading;
3+
using System.Threading.Tasks;
4+
5+
namespace AsyncMemoryCache.CreationBehaviors;
6+
7+
internal static class CacheItemFactoryInvoker
8+
{
9+
#if !NET8_0_OR_GREATER
10+
public static void InvokeFactory<TKey, TValue>(CacheEntity<TKey, TValue> item, ICreationTimeProvider creationTimeProvider)
11+
where TKey : notnull
12+
where TValue : IAsyncDisposable
13+
{
14+
_ = Task.Factory.StartNew(static async state =>
15+
{
16+
var (cacheItem, timeToWait) = ((CacheEntity<TKey, TValue>, TimeSpan))state!;
17+
if (timeToWait > TimeSpan.Zero)
18+
{
19+
await Task.Delay(timeToWait).ConfigureAwait(false);
20+
}
21+
22+
cacheItem.ObjectFactory.Start();
23+
},
24+
(item, creationTimeProvider.GetExpectedCreationTime() - DateTimeOffset.UtcNow),
25+
CancellationToken.None,
26+
TaskCreationOptions.None,
27+
SynchronizationContext.Current is null
28+
? TaskScheduler.Default
29+
: TaskScheduler.FromCurrentSynchronizationContext());
30+
}
31+
#else
32+
public static void InvokeFactory<TKey, TValue>(CacheEntity<TKey, TValue> item, ICreationTimeProvider creationTimeProvider, TimeProvider timeProvider)
33+
where TKey : notnull
34+
where TValue : IAsyncDisposable
35+
{
36+
_ = Task.Factory.StartNew(static async state =>
37+
{
38+
var (cacheItem, timeToWait, timeProvider) = ((CacheEntity<TKey, TValue>, TimeSpan, TimeProvider))state!;
39+
if (timeToWait > TimeSpan.Zero)
40+
{
41+
await Task.Delay(timeToWait, timeProvider).ConfigureAwait(false);
42+
}
43+
44+
cacheItem.ObjectFactory.Start();
45+
},
46+
(item, creationTimeProvider.GetExpectedCreationTime() - timeProvider.GetUtcNow(), timeProvider),
47+
CancellationToken.None,
48+
TaskCreationOptions.None,
49+
SynchronizationContext.Current is null
50+
? TaskScheduler.Default
51+
: TaskScheduler.FromCurrentSynchronizationContext());
52+
}
53+
#endif
54+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using System;
2+
3+
namespace AsyncMemoryCache.CreationBehaviors;
4+
5+
/// <summary>
6+
/// A class containing default values for implementations of <see cref="ICreationTimeProvider"/>.
7+
/// </summary>
8+
public static class CreationTimeProvider
9+
{
10+
/// <inheritdoc cref="ImmediateCreationTimeProvider"/>
11+
public static readonly ICreationTimeProvider Default = new ImmediateCreationTimeProvider();
12+
}
13+
14+
/// <summary>
15+
/// An interface that can be used to implement custom creation time providers.
16+
/// See <see cref="CreationTimeProvider"/> for default implementations."/>
17+
/// </summary>
18+
public interface ICreationTimeProvider
19+
{
20+
/// <summary>
21+
/// Gets the expected creation time.
22+
/// </summary>
23+
/// <returns>The expected creation time as a <see cref="DateTimeOffset"/>.</returns>
24+
DateTimeOffset GetExpectedCreationTime();
25+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
using System;
2+
3+
namespace AsyncMemoryCache.CreationBehaviors;
4+
5+
/// <summary>
6+
/// Provides the current time as the expected creation time.
7+
/// </summary>
8+
public class ImmediateCreationTimeProvider : ICreationTimeProvider
9+
{
10+
/// <summary>
11+
/// Gets the expected creation time, which is the current time.
12+
/// </summary>
13+
/// <returns>The current <see cref="DateTimeOffset"/>.</returns>
14+
public DateTimeOffset GetExpectedCreationTime() => DateTimeOffset.Now;
15+
}

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
- Lazy construction of cache object
1616
- Supports asynchronous factories
1717
- Automatic disposal of expired cache entries
18+
- Custom creation time providers
1819
- (Optional) Integration with Microsoft.Extensions.DependencyInjection
1920
- (Optional) Integration with Microsoft.Extensions.Logging
2021

@@ -73,3 +74,20 @@ var cacheEntityReference = cache.GetOrCreate("theKey", async () =>
7374
// The underlying cached object is not immediately disposed here, but is now eligible for disposal later on by eviction behaviors (if enabled)
7475
cacheEntityReference.Dispose();
7576
```
77+
78+
### Custom Creation Time Providers
79+
You can provide custom creation time providers to control the expected creation time of cache entries.
80+
81+
```cs
82+
public class CustomCreationTimeProvider : ICreationTimeProvider
83+
{
84+
public DateTimeOffset GetExpectedCreationTime()
85+
{
86+
// Custom logic to determine the creation time
87+
return DateTimeOffset.UtcNow.AddMinutes(5);
88+
}
89+
}
90+
91+
var customCreationTimeProvider = new CustomCreationTimeProvider();
92+
var cacheEntityReference = cache.GetOrCreate("theKey", factory, creationTimeProvider: customCreationTimeProvider);
93+
```

0 commit comments

Comments
 (0)