From 3b966c1a3d56a109689fb35514f36f8288b37939 Mon Sep 17 00:00:00 2001 From: Stuart Turner Date: Mon, 17 Feb 2025 16:34:19 -0600 Subject: [PATCH] feature: Ability to transform the cached value in a safe way --- .../ApplicationCacheBase.cs | 83 ++++++++++++- .../ApplicationCacheTests.cs | 116 ++++++++++++++++++ .../DelayGetValueCache.cs | 25 ++++ 3 files changed, 223 insertions(+), 1 deletion(-) diff --git a/src/Immediate.Cache.Shared/ApplicationCacheBase.cs b/src/Immediate.Cache.Shared/ApplicationCacheBase.cs index 4947967..faf7edb 100644 --- a/src/Immediate.Cache.Shared/ApplicationCacheBase.cs +++ b/src/Immediate.Cache.Shared/ApplicationCacheBase.cs @@ -117,6 +117,35 @@ public void SetValue(TRequest request, TResponse value) => public void RemoveValue(TRequest request) => GetCacheValue(request).RemoveValue(); + /// + /// Transforms the cached value, returning the newly transformed value. + /// + /// + /// The request payload to be cached. + /// + /// + /// A method which will transformed the cached value into a new value. + /// + /// + /// The to monitor for a cancellation request. + /// + /// + /// The transformed value. + /// + /// + /// The method may be called multiple times. is implemented by retrieving + /// the value from cache, modifying it, and attempting to store the new value into the cache. Since the update + /// cannot be done inside of a critical section, the cached value may have changed between query and storage. If + /// this happens, the transformation process will be restarted. + /// + protected ValueTask TransformValue( + TRequest request, + Func> transformer, + CancellationToken token = default + ) => + GetCacheValue(request).Transform(transformer, token); + [SuppressMessage("Design", "CA1001:Types that own disposable fields should be disposable", Justification = "CancellationTokenSource does not need to be disposed here.")] private sealed class CacheValue( TRequest request, @@ -218,7 +247,7 @@ public void SetValue(TResponse response) lock (_lock) { if (_responseSource is null or { Task.IsCompleted: true }) - _responseSource = new TaskCompletionSource(); + _responseSource = new(); _responseSource.SetResult(response); @@ -227,6 +256,58 @@ public void SetValue(TResponse response) } } + [SuppressMessage("Performance", "CA1849:Call async methods when in an async method", Justification = "Inside a `lock`, and testing `IsCompleted` first.")] + public async ValueTask Transform( + Func> transformer, + CancellationToken token + ) + { + while (true) + { + if (await Core(transformer, token).ConfigureAwait(false) is (true, var response)) + return response; + + _ = await GetHandlerTask().WaitAsync(token).ConfigureAwait(false); + } + + async ValueTask<(bool, TResponse)> Core( + Func> transformer, + CancellationToken token + ) + { + if (GetTask() is not { } task) + return default; + + var response = await task.ConfigureAwait(false); + var result = await transformer(response, token).ConfigureAwait(false); + + lock (_lock) + { + if (!ReferenceEquals(_responseSource?.Task, task)) + return default; + + (_responseSource = new()).SetResult(result); + return (true, result); + } + } + + [SuppressMessage( + "Design", + "MA0022:Return Task.FromResult instead of returning null", + Justification = "`null` is actually desired here" + )] + Task? GetTask() + { + lock (_lock) + { + if (_responseSource is { Task: { IsCompleted: true } task }) + return task; + } + + return null; + } + } + public void RemoveValue() { lock (_lock) diff --git a/tests/Immediate.Cache.FunctionalTests/ApplicationCacheTests.cs b/tests/Immediate.Cache.FunctionalTests/ApplicationCacheTests.cs index ed03253..c5dc751 100644 --- a/tests/Immediate.Cache.FunctionalTests/ApplicationCacheTests.cs +++ b/tests/Immediate.Cache.FunctionalTests/ApplicationCacheTests.cs @@ -307,4 +307,120 @@ public async Task ExceptionGetsPropagatedCorrectly() var ex = await Assert.ThrowsAsync(async () => await responseTask); Assert.Equal("Test Exception 1", ex.Message); } + + [Test] + public async Task TransformWorksWhenNoValueCachedInitially() + { + var request = new DelayGetValue.Query() + { + Value = 1, + Name = "Request1", + }; + request.WaitForTestToContinueOperation.SetResult(); + + var cache = _serviceProvider.GetRequiredService(); + + var transformation = new DelayGetValueCache.TransformParameters { Adder = 5 }; + transformation.WaitForTestToContinueOperation.SetResult(); + var transformedResponse = await cache.TransformResult(request, transformation); + + Assert.Equal(6, transformedResponse.Value); + Assert.Equal(1, transformation.TimesExecuted); + + var cachedResponse = await cache.GetValue(request); + Assert.Equal(6, cachedResponse.Value); + + Assert.True(cachedResponse.RandomValue == transformedResponse.RandomValue); + } + + [Test] + public async Task TransformWorksWhenValueCachedInitially() + { + var request = new DelayGetValue.Query() + { + Value = 1, + Name = "Request1", + }; + request.WaitForTestToContinueOperation.SetResult(); + + var cache = _serviceProvider.GetRequiredService(); + + var cachedResponse = await cache.GetValue(request); + Assert.Equal(1, cachedResponse.Value); + + var transformation = new DelayGetValueCache.TransformParameters { Adder = 5 }; + transformation.WaitForTestToContinueOperation.SetResult(); + var transformedResponse = await cache.TransformResult(request, transformation); + + Assert.Equal(6, transformedResponse.Value); + Assert.Equal(1, transformation.TimesExecuted); + + cachedResponse = await cache.GetValue(request); + Assert.Equal(6, cachedResponse.Value); + + Assert.True(cachedResponse.RandomValue == transformedResponse.RandomValue); + } + + [Test] + public async Task TransformWorksWhenValueChanges() + { + var request = new DelayGetValue.Query() + { + Value = 1, + Name = "Request1", + }; + request.WaitForTestToContinueOperation.SetResult(); + + var cache = _serviceProvider.GetRequiredService(); + + var cachedResponse = await cache.GetValue(request); + Assert.Equal(1, cachedResponse.Value); + + var transformation = new DelayGetValueCache.TransformParameters { Adder = 5 }; + var transformedResponseTask = cache.TransformResult(request, transformation); + await transformation.WaitForTestToStartExecuting.Task; + + cache.SetValue(request, new(4, ExecutedHandler: false, Guid.NewGuid())); + transformation.WaitForTestToContinueOperation.SetResult(); + + var transformedResponse = await transformedResponseTask; + Assert.Equal(9, transformedResponse.Value); + Assert.Equal(2, transformation.TimesExecuted); + + cachedResponse = await cache.GetValue(request); + Assert.Equal(9, cachedResponse.Value); + + Assert.True(cachedResponse.RandomValue == transformedResponse.RandomValue); + } + + [Test] + public async Task TransformWorksWhenMultipleSimultaneous() + { + var request = new DelayGetValue.Query() + { + Value = 1, + Name = "Request1", + }; + request.WaitForTestToContinueOperation.SetResult(); + + var cache = _serviceProvider.GetRequiredService(); + + var transformation1 = new DelayGetValueCache.TransformParameters { Adder = 5 }; + var transformedResponseTask1 = cache.TransformResult(request, transformation1); + await transformation1.WaitForTestToStartExecuting.Task; + + var transformation2 = new DelayGetValueCache.TransformParameters { Adder = 6 }; + var transformedResponseTask2 = cache.TransformResult(request, transformation2); + await transformation2.WaitForTestToStartExecuting.Task; + + transformation1.WaitForTestToContinueOperation.SetResult(); + var transformedResponse1 = await transformedResponseTask1; + Assert.Equal(6, transformedResponse1.Value); + Assert.Equal(1, transformation1.TimesExecuted); + + transformation2.WaitForTestToContinueOperation.SetResult(); + var transformedResponse2 = await transformedResponseTask2; + Assert.Equal(12, transformedResponse2.Value); + Assert.Equal(2, transformation2.TimesExecuted); + } } diff --git a/tests/Immediate.Cache.FunctionalTests/DelayGetValueCache.cs b/tests/Immediate.Cache.FunctionalTests/DelayGetValueCache.cs index 9e66a99..a3555ca 100644 --- a/tests/Immediate.Cache.FunctionalTests/DelayGetValueCache.cs +++ b/tests/Immediate.Cache.FunctionalTests/DelayGetValueCache.cs @@ -19,4 +19,29 @@ public sealed class DelayGetValueCache( )] protected override string TransformKey(DelayGetValue.Query request) => $"DelayGetValue(query: {request.Value})"; + + public ValueTask TransformResult(DelayGetValue.Query query, TransformParameters transformation) + { + return TransformValue( + query, + async (r, ct) => + { + _ = transformation.WaitForTestToStartExecuting.TrySetResult(); + await transformation.WaitForTestToContinueOperation.Task; + + transformation.TimesExecuted++; + + return r with { Value = r.Value + transformation.Adder }; + }, + default + ); + } + + public sealed class TransformParameters + { + public required int Adder { get; init; } + public int TimesExecuted { get; set; } + public TaskCompletionSource WaitForTestToStartExecuting { get; } = new(); + public TaskCompletionSource WaitForTestToContinueOperation { get; } = new(); + } }