Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 82 additions & 1 deletion src/Immediate.Cache.Shared/ApplicationCacheBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,35 @@ public void SetValue(TRequest request, TResponse value) =>
public void RemoveValue(TRequest request) =>
GetCacheValue(request).RemoveValue();

/// <summary>
/// Transforms the cached value, returning the newly transformed value.
/// </summary>
/// <param name="request">
/// The request payload to be cached.
/// </param>
/// <param name="transformer">
/// A method which will transformed the cached value into a new value.
/// </param>
/// <param name="token">
/// The <see cref="CancellationToken"/> to monitor for a cancellation request.
/// </param>
/// <returns>
/// The transformed value.
/// </returns>
/// <remarks>
/// The <paramref name="transformer"/> method may be called multiple times. <see cref="TransformValue(TRequest,
/// Func{TResponse, CancellationToken, ValueTask{TResponse}}, CancellationToken)"/> 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.
/// </remarks>
protected ValueTask<TResponse> TransformValue(
TRequest request,
Func<TResponse, CancellationToken, ValueTask<TResponse>> 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,
Expand Down Expand Up @@ -218,7 +247,7 @@ public void SetValue(TResponse response)
lock (_lock)
{
if (_responseSource is null or { Task.IsCompleted: true })
_responseSource = new TaskCompletionSource<TResponse>();
_responseSource = new();

_responseSource.SetResult(response);

Expand All @@ -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<TResponse> Transform(
Func<TResponse, CancellationToken, ValueTask<TResponse>> 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<TResponse, CancellationToken, ValueTask<TResponse>> 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<TResponse>? GetTask()
{
lock (_lock)
{
if (_responseSource is { Task: { IsCompleted: true } task })
return task;
}

return null;
}
}

public void RemoveValue()
{
lock (_lock)
Expand Down
116 changes: 116 additions & 0 deletions tests/Immediate.Cache.FunctionalTests/ApplicationCacheTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -307,4 +307,120 @@ public async Task ExceptionGetsPropagatedCorrectly()
var ex = await Assert.ThrowsAsync<InvalidOperationException>(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<DelayGetValueCache>();

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<DelayGetValueCache>();

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<DelayGetValueCache>();

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<DelayGetValueCache>();

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);
}
}
25 changes: 25 additions & 0 deletions tests/Immediate.Cache.FunctionalTests/DelayGetValueCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,29 @@ public sealed class DelayGetValueCache(
)]
protected override string TransformKey(DelayGetValue.Query request) =>
$"DelayGetValue(query: {request.Value})";

public ValueTask<DelayGetValue.Response> 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();
}
}