IdempotentActionFilterAttributeImpl: Make it thread-safe.
MODIFY
WalletWasabi.Backend/Filters/IdempotentActionFilterAttributeImpl.cs
WalletWasabi.Backend/Filters/IdempotentActionFilterAttributeImpl.cs
-using System;
-using System.Security.Cryptography;
-using System.Text;
-using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Caching.Memory;
using Newtonsoft.Json;
+using System;
+using System.Security.Cryptography;
+using System.Text;
+using System.Threading.Tasks;
using WalletWasabi.WabiSabi.Models.Serialization;
namespace WalletWasabi.Backend.Filters
@@ -15,14 +15,21 @@ namespace WalletWasabi.Backend.Filters
{
private static TimeSpan CacheTimeout { get; } = TimeSpan.FromMinutes(5);
private const string ContextCacheKey = "cached-key";
- private readonly IMemoryCache _responseCache;
public IdempotentActionFilterAttributeImpl(IMemoryCache cache)
: base()
{
- _responseCache = cache;
+ ResponseCache = cache;
}
+ /// <summary>Guards <see cref="ResponseCache"/>.</summary>
+ /// <remarks>Unfortunately, <see cref="CacheExtensions.GetOrCreate{TItem}(IMemoryCache, object, Func{ICacheEntry, TItem})"/> is not atomic.</remarks>
+ /// <seealso href="https://github.com/dotnet/runtime/issues/36499"/>
+ private object ResponseCacheLock { get; } = new();
+
+ /// <remarks>Guarded by <see cref="ResponseCacheLock"/>.</remarks>
+ private IMemoryCache ResponseCache { get; }
+
/// <inheritdoc/>
public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
@@ -34,12 +41,29 @@ namespace WalletWasabi.Backend.Filters
{
string cacheKey = GetCacheEntryKey(request.Path, model);
- if (_responseCache.TryGetValue(cacheKey, out ObjectResult? cachedResponse))
+ TaskCompletionSource<IActionResult>? cachedResponseTcs = null;
+
+ lock (ResponseCacheLock)
+ {
+ if (!ResponseCache.TryGetValue(cacheKey, out cachedResponseTcs))
+ {
+ ResponseCache.Set(cacheKey, new TaskCompletionSource<IActionResult>(), DateTimeOffset.UtcNow.Add(CacheTimeout));
+ }
+ }
+
+ if (cachedResponseTcs is not null)
+ {
+ try
{
- context.Result = cachedResponse;
+ context.Result = await cachedResponseTcs!.Task.WithAwaitCancellationAsync(TimeSpan.FromMinutes(1)).ConfigureAwait(false);
context.HttpContext.Items.Remove(ContextCacheKey);
return;
}
+ catch (OperationCanceledException)
+ {
+ // Failed to get cached response. Continue as if it were a non-cached request.
+ }
+ }
context.HttpContext.Items[ContextCacheKey] = cacheKey;
}
@@ -57,7 +81,13 @@ namespace WalletWasabi.Backend.Filters
{
if (context.HttpContext.Items.TryGetValue(ContextCacheKey, out object? cacheKey) && cacheKey is not null)
{
- _responseCache.Set(cacheKey, context.Result, DateTimeOffset.UtcNow.Add(CacheTimeout));
+ lock (ResponseCacheLock)
+ {
+ if (ResponseCache.TryGetValue(cacheKey, out TaskCompletionSource<IActionResult>? cachedResponseTcs))
+ {
+ cachedResponseTcs!.TrySetResult(context.Result);
+ }
+ }
}
}

IdempotentActionFilterAttributeImpl: Make it thread-safe.