Skip to content

Commit 918a3e5

Browse files
feature: Ability to transform the cached value in a safe way (#40)
1 parent a8a0636 commit 918a3e5

File tree

3 files changed

+223
-1
lines changed

3 files changed

+223
-1
lines changed

src/Immediate.Cache.Shared/ApplicationCacheBase.cs

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,35 @@ public void SetValue(TRequest request, TResponse value) =>
117117
public void RemoveValue(TRequest request) =>
118118
GetCacheValue(request).RemoveValue();
119119

120+
/// <summary>
121+
/// Transforms the cached value, returning the newly transformed value.
122+
/// </summary>
123+
/// <param name="request">
124+
/// The request payload to be cached.
125+
/// </param>
126+
/// <param name="transformer">
127+
/// A method which will transformed the cached value into a new value.
128+
/// </param>
129+
/// <param name="token">
130+
/// The <see cref="CancellationToken"/> to monitor for a cancellation request.
131+
/// </param>
132+
/// <returns>
133+
/// The transformed value.
134+
/// </returns>
135+
/// <remarks>
136+
/// The <paramref name="transformer"/> method may be called multiple times. <see cref="TransformValue(TRequest,
137+
/// Func{TResponse, CancellationToken, ValueTask{TResponse}}, CancellationToken)"/> is implemented by retrieving
138+
/// the value from cache, modifying it, and attempting to store the new value into the cache. Since the update
139+
/// cannot be done inside of a critical section, the cached value may have changed between query and storage. If
140+
/// this happens, the transformation process will be restarted.
141+
/// </remarks>
142+
protected ValueTask<TResponse> TransformValue(
143+
TRequest request,
144+
Func<TResponse, CancellationToken, ValueTask<TResponse>> transformer,
145+
CancellationToken token = default
146+
) =>
147+
GetCacheValue(request).Transform(transformer, token);
148+
120149
[SuppressMessage("Design", "CA1001:Types that own disposable fields should be disposable", Justification = "CancellationTokenSource does not need to be disposed here.")]
121150
private sealed class CacheValue(
122151
TRequest request,
@@ -218,7 +247,7 @@ public void SetValue(TResponse response)
218247
lock (_lock)
219248
{
220249
if (_responseSource is null or { Task.IsCompleted: true })
221-
_responseSource = new TaskCompletionSource<TResponse>();
250+
_responseSource = new();
222251

223252
_responseSource.SetResult(response);
224253

@@ -227,6 +256,58 @@ public void SetValue(TResponse response)
227256
}
228257
}
229258

259+
[SuppressMessage("Performance", "CA1849:Call async methods when in an async method", Justification = "Inside a `lock`, and testing `IsCompleted` first.")]
260+
public async ValueTask<TResponse> Transform(
261+
Func<TResponse, CancellationToken, ValueTask<TResponse>> transformer,
262+
CancellationToken token
263+
)
264+
{
265+
while (true)
266+
{
267+
if (await Core(transformer, token).ConfigureAwait(false) is (true, var response))
268+
return response;
269+
270+
_ = await GetHandlerTask().WaitAsync(token).ConfigureAwait(false);
271+
}
272+
273+
async ValueTask<(bool, TResponse)> Core(
274+
Func<TResponse, CancellationToken, ValueTask<TResponse>> transformer,
275+
CancellationToken token
276+
)
277+
{
278+
if (GetTask() is not { } task)
279+
return default;
280+
281+
var response = await task.ConfigureAwait(false);
282+
var result = await transformer(response, token).ConfigureAwait(false);
283+
284+
lock (_lock)
285+
{
286+
if (!ReferenceEquals(_responseSource?.Task, task))
287+
return default;
288+
289+
(_responseSource = new()).SetResult(result);
290+
return (true, result);
291+
}
292+
}
293+
294+
[SuppressMessage(
295+
"Design",
296+
"MA0022:Return Task.FromResult instead of returning null",
297+
Justification = "`null` is actually desired here"
298+
)]
299+
Task<TResponse>? GetTask()
300+
{
301+
lock (_lock)
302+
{
303+
if (_responseSource is { Task: { IsCompleted: true } task })
304+
return task;
305+
}
306+
307+
return null;
308+
}
309+
}
310+
230311
public void RemoveValue()
231312
{
232313
lock (_lock)

tests/Immediate.Cache.FunctionalTests/ApplicationCacheTests.cs

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,4 +307,120 @@ public async Task ExceptionGetsPropagatedCorrectly()
307307
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await responseTask);
308308
Assert.Equal("Test Exception 1", ex.Message);
309309
}
310+
311+
[Test]
312+
public async Task TransformWorksWhenNoValueCachedInitially()
313+
{
314+
var request = new DelayGetValue.Query()
315+
{
316+
Value = 1,
317+
Name = "Request1",
318+
};
319+
request.WaitForTestToContinueOperation.SetResult();
320+
321+
var cache = _serviceProvider.GetRequiredService<DelayGetValueCache>();
322+
323+
var transformation = new DelayGetValueCache.TransformParameters { Adder = 5 };
324+
transformation.WaitForTestToContinueOperation.SetResult();
325+
var transformedResponse = await cache.TransformResult(request, transformation);
326+
327+
Assert.Equal(6, transformedResponse.Value);
328+
Assert.Equal(1, transformation.TimesExecuted);
329+
330+
var cachedResponse = await cache.GetValue(request);
331+
Assert.Equal(6, cachedResponse.Value);
332+
333+
Assert.True(cachedResponse.RandomValue == transformedResponse.RandomValue);
334+
}
335+
336+
[Test]
337+
public async Task TransformWorksWhenValueCachedInitially()
338+
{
339+
var request = new DelayGetValue.Query()
340+
{
341+
Value = 1,
342+
Name = "Request1",
343+
};
344+
request.WaitForTestToContinueOperation.SetResult();
345+
346+
var cache = _serviceProvider.GetRequiredService<DelayGetValueCache>();
347+
348+
var cachedResponse = await cache.GetValue(request);
349+
Assert.Equal(1, cachedResponse.Value);
350+
351+
var transformation = new DelayGetValueCache.TransformParameters { Adder = 5 };
352+
transformation.WaitForTestToContinueOperation.SetResult();
353+
var transformedResponse = await cache.TransformResult(request, transformation);
354+
355+
Assert.Equal(6, transformedResponse.Value);
356+
Assert.Equal(1, transformation.TimesExecuted);
357+
358+
cachedResponse = await cache.GetValue(request);
359+
Assert.Equal(6, cachedResponse.Value);
360+
361+
Assert.True(cachedResponse.RandomValue == transformedResponse.RandomValue);
362+
}
363+
364+
[Test]
365+
public async Task TransformWorksWhenValueChanges()
366+
{
367+
var request = new DelayGetValue.Query()
368+
{
369+
Value = 1,
370+
Name = "Request1",
371+
};
372+
request.WaitForTestToContinueOperation.SetResult();
373+
374+
var cache = _serviceProvider.GetRequiredService<DelayGetValueCache>();
375+
376+
var cachedResponse = await cache.GetValue(request);
377+
Assert.Equal(1, cachedResponse.Value);
378+
379+
var transformation = new DelayGetValueCache.TransformParameters { Adder = 5 };
380+
var transformedResponseTask = cache.TransformResult(request, transformation);
381+
await transformation.WaitForTestToStartExecuting.Task;
382+
383+
cache.SetValue(request, new(4, ExecutedHandler: false, Guid.NewGuid()));
384+
transformation.WaitForTestToContinueOperation.SetResult();
385+
386+
var transformedResponse = await transformedResponseTask;
387+
Assert.Equal(9, transformedResponse.Value);
388+
Assert.Equal(2, transformation.TimesExecuted);
389+
390+
cachedResponse = await cache.GetValue(request);
391+
Assert.Equal(9, cachedResponse.Value);
392+
393+
Assert.True(cachedResponse.RandomValue == transformedResponse.RandomValue);
394+
}
395+
396+
[Test]
397+
public async Task TransformWorksWhenMultipleSimultaneous()
398+
{
399+
var request = new DelayGetValue.Query()
400+
{
401+
Value = 1,
402+
Name = "Request1",
403+
};
404+
request.WaitForTestToContinueOperation.SetResult();
405+
406+
var cache = _serviceProvider.GetRequiredService<DelayGetValueCache>();
407+
408+
var transformation1 = new DelayGetValueCache.TransformParameters { Adder = 5 };
409+
var transformedResponseTask1 = cache.TransformResult(request, transformation1);
410+
await transformation1.WaitForTestToStartExecuting.Task;
411+
412+
var transformation2 = new DelayGetValueCache.TransformParameters { Adder = 6 };
413+
var transformedResponseTask2 = cache.TransformResult(request, transformation2);
414+
await transformation2.WaitForTestToStartExecuting.Task;
415+
416+
transformation1.WaitForTestToContinueOperation.SetResult();
417+
var transformedResponse1 = await transformedResponseTask1;
418+
Assert.Equal(6, transformedResponse1.Value);
419+
Assert.Equal(1, transformation1.TimesExecuted);
420+
421+
transformation2.WaitForTestToContinueOperation.SetResult();
422+
var transformedResponse2 = await transformedResponseTask2;
423+
Assert.Equal(12, transformedResponse2.Value);
424+
Assert.Equal(2, transformation2.TimesExecuted);
425+
}
310426
}

tests/Immediate.Cache.FunctionalTests/DelayGetValueCache.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,29 @@ public sealed class DelayGetValueCache(
1919
)]
2020
protected override string TransformKey(DelayGetValue.Query request) =>
2121
$"DelayGetValue(query: {request.Value})";
22+
23+
public ValueTask<DelayGetValue.Response> TransformResult(DelayGetValue.Query query, TransformParameters transformation)
24+
{
25+
return TransformValue(
26+
query,
27+
async (r, ct) =>
28+
{
29+
_ = transformation.WaitForTestToStartExecuting.TrySetResult();
30+
await transformation.WaitForTestToContinueOperation.Task;
31+
32+
transformation.TimesExecuted++;
33+
34+
return r with { Value = r.Value + transformation.Adder };
35+
},
36+
default
37+
);
38+
}
39+
40+
public sealed class TransformParameters
41+
{
42+
public required int Adder { get; init; }
43+
public int TimesExecuted { get; set; }
44+
public TaskCompletionSource WaitForTestToStartExecuting { get; } = new();
45+
public TaskCompletionSource WaitForTestToContinueOperation { get; } = new();
46+
}
2247
}

0 commit comments

Comments
 (0)