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();
+ }
}