Add AtomicGetOrCreate2Async as the concept improvement that fixes the issue.
MODIFY
WalletWasabi.Tests/UnitTests/MemoryTests.cs
WalletWasabi.Tests/UnitTests/MemoryTests.cs
@@ -24,52 +24,37 @@ namespace WalletWasabi.Tests.UnitTests
var cache1 = new MemoryCache(new MemoryCacheOptions());
var cache2 = new MemoryCache(new MemoryCacheOptions());
- var result0 = await cache1.AtomicGetOrCreateAsync(
+ var result0 = await cache1.AtomicGetOrCreate2Async(
"the-same-key",
- (entry) =>
- {
- entry.SetAbsoluteExpiration(TimeSpan.FromHours(10));
- return Task.FromResult(ExpensiveComputation("World!"));
- });
+ new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(10) },
+ () => Task.FromResult(ExpensiveComputation("World!")));
- var result1 = await cache2.AtomicGetOrCreateAsync(
+ var result1 = await cache2.AtomicGetOrCreate2Async(
"the-same-other-key",
- (entry) =>
- {
- entry.SetAbsoluteExpiration(TimeSpan.FromHours(10));
- return Task.FromResult(ExpensiveComputation("Lurking Wife!"));
- });
+ new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(10) },
+ () => Task.FromResult(ExpensiveComputation("Lurking Wife!")));
- var result2 = await cache1.AtomicGetOrCreateAsync(
+ var result2 = await cache1.AtomicGetOrCreate2Async(
"the-same-key",
- (entry) =>
- {
- entry.SetAbsoluteExpiration(TimeSpan.FromHours(10));
- return Task.FromResult(ExpensiveComputation("World!"));
- });
+ new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(10) },
+ () => Task.FromResult(ExpensiveComputation("World!")));
Assert.Equal(result0, result2);
Assert.Equal(2, invoked);
// Make sure AtomicGetOrCreateAsync doesn't fail because another cache is disposed.
cache2.Dispose();
- var result3 = await cache1.AtomicGetOrCreateAsync(
+ var result3 = await cache1.AtomicGetOrCreate2Async(
"the-same-key",
- (entry) =>
- {
- entry.SetAbsoluteExpiration(TimeSpan.FromHours(10));
- return Task.FromResult(ExpensiveComputation("Foo!"));
- });
+ new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(10) },
+ () => Task.FromResult(ExpensiveComputation("Foo!")));
Assert.Equal("Hello World!", result3);
Assert.Equal(2, invoked);
// Make sure cache2 call will fail.
- await Assert.ThrowsAsync<ObjectDisposedException>(async () => await cache2.AtomicGetOrCreateAsync(
+ await Assert.ThrowsAsync<ObjectDisposedException>(async () => await cache2.AtomicGetOrCreate2Async(
"the-same-key",
- (entry) =>
- {
- entry.SetAbsoluteExpiration(TimeSpan.FromHours(10));
- return Task.FromResult(ExpensiveComputation("Foo!"));
- }));
+ new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(10) },
+ () => Task.FromResult(ExpensiveComputation("Foo!"))));
Assert.Equal(2, invoked);
}
@@ -86,30 +71,22 @@ namespace WalletWasabi.Tests.UnitTests
var cache = new MemoryCache(new MemoryCacheOptions());
var expireKey1 = new CancellationTokenSource();
- var result0 = await cache.AtomicGetOrCreateAsync(
+ var options = new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(10) };
+ options.AddExpirationToken(new CancellationChangeToken(expireKey1.Token));
+ var result0 = await cache.AtomicGetOrCreate2Async(
"key1",
- (entry) =>
- {
- entry.SetAbsoluteExpiration(TimeSpan.FromHours(10));
- entry.AddExpirationToken(new CancellationChangeToken(expireKey1.Token));
- return Task.FromResult(ExpensiveComputation("World!"));
- });
+ options,
+ () => Task.FromResult(ExpensiveComputation("World!")));
- var result1 = await cache.AtomicGetOrCreateAsync(
+ var result1 = await cache.AtomicGetOrCreate2Async(
"key2",
- (entry) =>
- {
- entry.SetAbsoluteExpiration(TimeSpan.FromHours(10));
- return Task.FromResult(ExpensiveComputation("Lurking Wife!"));
- });
+ new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(10) },
+ () => Task.FromResult(ExpensiveComputation("Lurking Wife!")));
- var result2 = await cache.AtomicGetOrCreateAsync(
+ var result2 = await cache.AtomicGetOrCreate2Async(
"key1",
- (entry) =>
- {
- entry.SetAbsoluteExpiration(TimeSpan.FromHours(10));
- return Task.FromResult(ExpensiveComputation("World!"));
- });
+ new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(10) },
+ () => Task.FromResult(ExpensiveComputation("World!")));
Assert.Equal(result0, result2);
Assert.NotEqual(result0, result1);
@@ -117,20 +94,16 @@ namespace WalletWasabi.Tests.UnitTests
// Make sure key1 expired.
expireKey1.Cancel();
- var result3 = await cache.AtomicGetOrCreateAsync(
+ var result3 = await cache.AtomicGetOrCreate2Async(
"key1",
- (entry) =>
- {
- entry.SetAbsoluteExpiration(TimeSpan.FromHours(10));
- return Task.FromResult(ExpensiveComputation("Foo!"));
- });
- var result4 = await cache.AtomicGetOrCreateAsync(
+ new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(10) },
+ () => Task.FromResult(ExpensiveComputation("Foo!")));
+
+ var result4 = await cache.AtomicGetOrCreate2Async(
"key2",
- (entry) =>
- {
- entry.SetAbsoluteExpiration(TimeSpan.FromHours(10));
- return Task.FromResult(ExpensiveComputation("Bar!"));
- });
+ new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(10) },
+ () => Task.FromResult(ExpensiveComputation("Bar!")));
+
Assert.Equal(result1, result4);
Assert.NotEqual(result0, result3);
Assert.Equal(3, invoked);
@@ -155,46 +128,30 @@ namespace WalletWasabi.Tests.UnitTests
var cache = new MemoryCache(new MemoryCacheOptions());
- var task0 = cache.AtomicGetOrCreateAsync(
+ var task0 = cache.AtomicGetOrCreate2Async(
"key1",
- (entry) =>
- {
- entry.SetAbsoluteExpiration(TimeSpan.FromSeconds(60));
- return WaitUntilTrigger("World!");
- }
- );
+ new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(60) },
+ () => WaitUntilTrigger("World!"));
if (!await signal.WaitAsync(timeout))
{
throw new TimeoutException();
}
- var task1 = cache.AtomicGetOrCreateAsync(
+ var task1 = cache.AtomicGetOrCreate2Async(
"key1",
- (entry) =>
- {
- entry.SetAbsoluteExpiration(TimeSpan.FromMilliseconds(60));
- return Task.FromResult("Should not change to this");
- }
- );
+ new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMilliseconds(60) },
+ () => Task.FromResult("Should not change to this"));
- var task2 = cache.AtomicGetOrCreateAsync(
+ var task2 = cache.AtomicGetOrCreate2Async(
"key1",
- (entry) =>
- {
- entry.SetAbsoluteExpiration(TimeSpan.FromMilliseconds(60));
- return Task.FromResult("Should not change to this either");
- }
- );
+ new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMilliseconds(60) },
+ () => Task.FromResult("Should not change to this either"));
- var task3 = cache.AtomicGetOrCreateAsync(
+ var task3 = cache.AtomicGetOrCreate2Async(
"key2",
- (entry) =>
- {
- entry.SetAbsoluteExpiration(TimeSpan.FromMilliseconds(60));
- return Task.FromResult("Key2");
- }
- );
+ new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMilliseconds(60) },
+ () => Task.FromResult("Key2"));
// Different key should immediately added.
await task3.WithAwaitCancellationAsync(timeout);
@@ -222,34 +179,22 @@ namespace WalletWasabi.Tests.UnitTests
{
var cache = new MemoryCache(new MemoryCacheOptions());
- var result0 = await cache.AtomicGetOrCreateAsync(
+ var result0 = await cache.AtomicGetOrCreate2Async(
"key1",
- (entry) =>
- {
- entry.SetAbsoluteExpiration(TimeSpan.FromMilliseconds(1));
- return Task.FromResult("This will be expired");
- }
- );
+ new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMilliseconds(1) },
+ () => Task.FromResult("This will be expired"));
await Task.Delay(1);
- var result1 = await cache.AtomicGetOrCreateAsync(
+ var result1 = await cache.AtomicGetOrCreate2Async(
"key1",
- (entry) =>
- {
- entry.SetAbsoluteExpiration(TimeSpan.FromSeconds(60));
- return Task.FromResult("Foo");
- }
- );
+ new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(60) },
+ () => Task.FromResult("Foo"));
- var result2 = await cache.AtomicGetOrCreateAsync(
+ var result2 = await cache.AtomicGetOrCreate2Async(
"key1",
- (entry) =>
- {
- entry.SetAbsoluteExpiration(TimeSpan.FromSeconds(60));
- return Task.FromResult("Should not change to this");
- }
- );
+ new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(60) },
+ () => Task.FromResult("Should not change to this"));
Assert.Equal("Foo", result2);
}
@@ -258,35 +203,36 @@ namespace WalletWasabi.Tests.UnitTests
public async Task CacheTaskTestAsync()
{
var cache = new MemoryCache(new MemoryCacheOptions());
- var called = 0;
+ var greatCalled = 0;
+ var leeCalled = 0;
async Task<string> Greet(string who) =>
- await cache.AtomicGetOrCreateAsync(
+ await cache.AtomicGetOrCreate2Async(
$"{nameof(Greet)}{who}",
- (entry) =>
+ new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromTicks(1) }, // expires really soon
+ () =>
{
- entry.SetAbsoluteExpiration(TimeSpan.FromTicks(1)); // expires really soon
- called++;
+ greatCalled++;
return Task.FromResult($"Hello Mr. {who}");
- }
- );
+ });
async Task<string> GreetMrLee() =>
- await cache.AtomicGetOrCreateAsync(
+ await cache.AtomicGetOrCreate2Async(
"key1",
- (entry) =>
+ new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1) },
+ () =>
{
- entry.SetAbsoluteExpiration(TimeSpan.FromHours(10));
+ leeCalled++;
return Greet("Lee");
- }
- );
+ });
for (var i = 0; i < 10; i++)
{
await GreetMrLee();
}
- Assert.Equal(1, called);
+ Assert.Equal(1, greatCalled);
+ Assert.Equal(1, leeCalled);
}
}
}

MODIFY
WalletWasabi/Extensions/MemoryExtensions.cs
WalletWasabi/Extensions/MemoryExtensions.cs
@@ -58,5 +58,52 @@ namespace Microsoft.Extensions.Caching.Memory
return value;
}
}
+
+ public static async Task<TItem> AtomicGetOrCreate2Async<TItem>(this IMemoryCache cache, object key, MemoryCacheEntryOptions options, Func<Task<TItem>> factory)
+ {
+ if (cache.TryGetValue(key, out TItem value))
+ {
+ return value;
+ }
+
+ AsyncLock asyncLock;
+ lock (AsyncLocksLock)
+ {
+ // If we have no dic for the cache yet then create one.
+ if (!AsyncLocks.TryGetValue(cache, out Dictionary<object, AsyncLock> cacheDic))
+ {
+ cacheDic = new Dictionary<object, AsyncLock>();
+ AsyncLocks.Add(cache, cacheDic);
+ }
+
+ if (!cacheDic.TryGetValue(key, out asyncLock))
+ {
+ asyncLock = new AsyncLock();
+ cacheDic.Add(key, asyncLock);
+ }
+ }
+
+ using (await asyncLock.LockAsync().ConfigureAwait(false))
+ {
+ if (!cache.TryGetValue(key, out value))
+ {
+ value = await factory.Invoke();
+ cache.Set(key, value, options);
+ lock (AsyncLocksLock)
+ {
+ var cacheDic = AsyncLocks[cache];
+
+ // Note that if a cache is disposed, then the cleanup will never happen. This should not cause normally issues, but keep in mind.
+ // Cleanup the evicted asynclocks.
+ foreach (var toRemove in cacheDic.Keys.Where(x => !cache.TryGetValue(x, out _)).ToList())
+ {
+ cacheDic.Remove(toRemove);
+ }
+ }
+ }
+
+ return value;
+ }
+ }
}
}

Add AtomicGetOrCreate2Async as the concept improvement that fixes the issue.