Skip to content

Commit 2a7a688

Browse files
committed
Switch to Microsoft distributed cache extensions
1 parent e07aa88 commit 2a7a688

File tree

2 files changed

+171
-121
lines changed

2 files changed

+171
-121
lines changed
Lines changed: 171 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
namespace Boxed.AspNetCore;
22

33
using System;
4+
using System.Buffers;
45
using System.Text.Json;
56
using System.Threading;
67
using System.Threading.Tasks;
@@ -9,71 +10,200 @@ namespace Boxed.AspNetCore;
910
/// <summary>
1011
/// <see cref="IDistributedCache"/> extension methods.
1112
/// </summary>
13+
/// <summary>
14+
/// Provides a simple convenience wrapper around <see cref="IDistributedCache"/>; note that this implementation
15+
/// does not attempt to avoid problems with multiple callers all invoking the "get" method at once when
16+
/// data becomes evicted for cache ("stampeding"), or any other concerns such as returning stale data while
17+
/// refresh occurs in the background - these are future considerations for the cache implementation.
18+
/// </summary>
19+
/// <remarks>The overloads taking <c>TState</c> are useful when used with <c>static</c> get methods, to avoid
20+
/// "capture" overheads, but in most everyday scenarios, it may be more convenient to use the simpler stateless
21+
/// version.</remarks>
1222
public static class DistributedCacheExtensions
1323
{
1424
/// <summary>
15-
/// Gets the value of type <typeparamref name="T" /> with the specified key from the cache asynchronously by
16-
/// deserializing it from JSON format or returns <c>default(T)</c> if the key was not found.
25+
/// Gets a value from cache, with a caller-supplied <paramref name="getMethod" /> (async, stateless) that is used if the value is not yet available.
1726
/// </summary>
1827
/// <typeparam name="T">The type of the value.</typeparam>
1928
/// <param name="cache">The distributed cache.</param>
2029
/// <param name="key">The cache item key.</param>
21-
/// <param name="jsonSerializerOptions">The JSON serializer options or <c>null</c> to use the default.</param>
22-
/// <param name="cancellationToken">The cancellation token.</param>
23-
/// <returns>The value of type <typeparamref name="T" /> or <c>null</c> if the key was not found.</returns>
24-
/// <exception cref="ArgumentNullException"><paramref name="cache"/> or <paramref name="key"/> is
25-
/// <c>null</c>.</exception>
26-
public static async Task<T?> GetAsJsonAsync<T>(
30+
/// <param name="getMethod">The method used to retrieve the item.</param>
31+
/// <param name="options">The cache options.</param>
32+
/// <param name="cancellation">The cancellation token.</param>
33+
/// <returns>The cached value.</returns>
34+
public static ValueTask<T> GetAsync<T>(
2735
this IDistributedCache cache,
2836
string key,
29-
JsonSerializerOptions? jsonSerializerOptions = null,
30-
CancellationToken cancellationToken = default)
31-
{
32-
ArgumentNullException.ThrowIfNull(cache);
33-
ArgumentNullException.ThrowIfNull(key);
37+
Func<CancellationToken, ValueTask<T>> getMethod,
38+
DistributedCacheEntryOptions? options = null,
39+
CancellationToken cancellation = default) =>
40+
GetAsyncSharedAsync<int, T>(cache, key, state: 0, getMethod, options, cancellation); // use dummy state
3441

35-
var bytes = await cache.GetAsync(key, cancellationToken).ConfigureAwait(false);
36-
if (bytes is null)
37-
{
38-
return default;
39-
}
42+
/// <summary>
43+
/// Gets a value from cache, with a caller-supplied <paramref name="getMethod"/> (sync, stateless) that is used if the value is not yet available.
44+
/// </summary>
45+
/// <typeparam name="T">The type of the value.</typeparam>
46+
/// <param name="cache">The distributed cache.</param>
47+
/// <param name="key">The cache item key.</param>
48+
/// <param name="getMethod">The method used to retrieve the item.</param>
49+
/// <param name="options">The cache options.</param>
50+
/// <param name="cancellation">The cancellation token.</param>
51+
/// <returns>The cached value.</returns>
52+
public static ValueTask<T> GetAsync<T>(
53+
this IDistributedCache cache,
54+
string key,
55+
Func<T> getMethod,
56+
DistributedCacheEntryOptions? options = null,
57+
CancellationToken cancellation = default) =>
58+
GetAsyncSharedAsync<int, T>(cache, key, state: 0, getMethod, options, cancellation); // use dummy state
4059

41-
return Deserialize<T>(bytes, jsonSerializerOptions);
42-
}
60+
/// <summary>
61+
/// Gets a value from cache, with a caller-supplied <paramref name="getMethod" /> (async, stateful) that is used if the value is not yet available.
62+
/// </summary>
63+
/// <typeparam name="TState">The type of the state.</typeparam>
64+
/// <typeparam name="T">The type of the value.</typeparam>
65+
/// <param name="cache">The distributed cache.</param>
66+
/// <param name="key">The cache item key.</param>
67+
/// <param name="state">The state.</param>
68+
/// <param name="getMethod">The method used to retrieve the item.</param>
69+
/// <param name="options">The cache options.</param>
70+
/// <param name="cancellation">The cancellation token.</param>
71+
/// <returns>
72+
/// The cached value.
73+
/// </returns>
74+
public static ValueTask<T> GetAsync<TState, T>(
75+
this IDistributedCache cache,
76+
string key,
77+
TState state,
78+
Func<TState, CancellationToken, ValueTask<T>> getMethod,
79+
DistributedCacheEntryOptions? options = null,
80+
CancellationToken cancellation = default) =>
81+
GetAsyncSharedAsync<TState, T>(cache, key, state, getMethod, options, cancellation);
4382

4483
/// <summary>
45-
/// Sets the value of type <typeparamref name="T" /> with the specified key in the cache asynchronously by
46-
/// serializing it to JSON format.
84+
/// Gets a value from cache, with a caller-supplied <paramref name="getMethod"/> (sync, stateful) that is used if the value is not yet available.
4785
/// </summary>
86+
/// <typeparam name="TState">The type of the state.</typeparam>
4887
/// <typeparam name="T">The type of the value.</typeparam>
4988
/// <param name="cache">The distributed cache.</param>
5089
/// <param name="key">The cache item key.</param>
51-
/// <param name="value">The value to cache.</param>
52-
/// <param name="options">The cache options or <c>null</c> to use the default cache options.</param>
53-
/// <param name="jsonSerializerOptions">The JSON serializer options or <c>null</c> to use the default.</param>
54-
/// <param name="cancellationToken">The cancellation token.</param>
55-
/// <returns>The value of type <typeparamref name="T" /> or <c>null</c> if the key was not found.</returns>
56-
/// <exception cref="ArgumentNullException"><paramref name="cache"/> or <paramref name="key"/> is
57-
/// <c>null</c>.</exception>
58-
public static Task SetAsJsonAsync<T>(
90+
/// <param name="state">The state.</param>
91+
/// <param name="getMethod">The method used to retrieve the item.</param>
92+
/// <param name="options">The cache options.</param>
93+
/// <param name="cancellation">The cancellation token.</param>
94+
/// <returns>
95+
/// The cached value.
96+
/// </returns>
97+
public static ValueTask<T> GetAsync<TState, T>(
5998
this IDistributedCache cache,
6099
string key,
61-
T value,
62-
DistributedCacheEntryOptions options,
63-
JsonSerializerOptions? jsonSerializerOptions = null,
64-
CancellationToken cancellationToken = default)
65-
where T : class
100+
TState state,
101+
Func<TState, T> getMethod,
102+
DistributedCacheEntryOptions? options = null,
103+
CancellationToken cancellation = default) =>
104+
GetAsyncSharedAsync<TState, T>(cache, key, state, getMethod, options, cancellation);
105+
106+
/// <summary>
107+
/// Provides a common implementation for the public-facing API, to avoid duplication.
108+
/// </summary>
109+
private static ValueTask<T> GetAsyncSharedAsync<TState, T>(
110+
IDistributedCache cache,
111+
string key,
112+
TState state,
113+
Delegate getMethod,
114+
DistributedCacheEntryOptions? options,
115+
CancellationToken cancellation)
66116
{
67117
ArgumentNullException.ThrowIfNull(cache);
68-
ArgumentNullException.ThrowIfNull(key);
69118

70-
var bytes = JsonSerializer.SerializeToUtf8Bytes(value, jsonSerializerOptions);
71-
return cache.SetAsync(key, bytes, options, cancellationToken);
119+
var pending = cache.GetAsync(key, cancellation);
120+
if (!pending.IsCompletedSuccessfully)
121+
{
122+
// async-result was not available immediately; go full-async
123+
return Awaited(cache, key, pending, state, getMethod, options, cancellation);
124+
}
125+
126+
// GetAwaiter().GetResult() here is *not* "sync-over-async" - we've already
127+
// validated that this data was available synchronously, and we're eliding
128+
// the state machine overheads in the (hopefully high-hit-rate) success case
129+
#pragma warning disable VSTHRD103 // Call async methods when in an async method
130+
var bytes = pending.GetAwaiter().GetResult();
131+
#pragma warning restore VSTHRD103 // Call async methods when in an async method
132+
if (bytes is null)
133+
{
134+
// async-result was available but data is missing; go async for everything else
135+
return Awaited(cache, key, null, state, getMethod, options, cancellation);
136+
}
137+
138+
// data was available synchronously; deserialize
139+
return new(Deserialize<T>(bytes));
140+
141+
static async ValueTask<T> Awaited(
142+
IDistributedCache cache, // the underlying cache
143+
string key, // the key on the cache
144+
Task<byte[]?>? pending, // incomplete "get bytes" operation, if any
145+
TState state, // state possibly used by the get-method
146+
Delegate getMethod, // the get-method supplied by the caller
147+
DistributedCacheEntryOptions? options, // cache expiration, etc
148+
CancellationToken cancellation)
149+
{
150+
byte[]? bytes;
151+
if (pending is not null)
152+
{
153+
bytes = await pending.ConfigureAwait(false);
154+
if (bytes is not null)
155+
{
156+
// data was available asynchronously
157+
return Deserialize<T>(bytes);
158+
}
159+
}
160+
161+
var result = getMethod switch
162+
{
163+
// we expect 4 use-cases; sync/async, with/without state
164+
Func<TState, CancellationToken, ValueTask<T>> get => await get(state, cancellation).ConfigureAwait(false),
165+
Func<TState, T> get => get(state),
166+
Func<CancellationToken, ValueTask<T>> get => await get(cancellation).ConfigureAwait(false),
167+
Func<T> get => get(),
168+
_ => throw new ArgumentException("Unexpected get method found.", nameof(getMethod)),
169+
};
170+
bytes = Serialize(result);
171+
172+
if (options is null)
173+
{
174+
// not recommended; cache expiration should be considered
175+
// important, usually
176+
await cache.SetAsync(key, bytes, cancellation).ConfigureAwait(false);
177+
}
178+
else
179+
{
180+
await cache.SetAsync(key, bytes, options, cancellation).ConfigureAwait(false);
181+
}
182+
183+
return result;
184+
}
72185
}
73186

74-
private static T? Deserialize<T>(byte[] bytes, JsonSerializerOptions? jsonSerializerOptions)
187+
// The current cache API is byte[]-based, but a wide range of
188+
// serializer choices are possible; here we use the inbuilt
189+
// System.Text.Json.JsonSerializer, which is a fair compromise
190+
// between being easy to configure and use on general types,
191+
// versus raw performance. Alternative (non-byte[]) storage
192+
// mechanisms are under consideration.
193+
//
194+
// If it is likely that you will change serializers during
195+
// upgrades (and you are using out-of-process storage), then
196+
// you may wish to use a sentinel prefix before the payload,
197+
// to allow you to safely switch between serializers;
198+
// alternatively, you may choose to use a key-prefix so that
199+
// the old data is simply not found (and expires naturally)
200+
private static T Deserialize<T>(byte[] bytes) => JsonSerializer.Deserialize<T>(bytes)!;
201+
202+
private static byte[] Serialize<T>(T value)
75203
{
76-
var utf8JsonReader = new Utf8JsonReader(bytes);
77-
return JsonSerializer.Deserialize<T>(ref utf8JsonReader, jsonSerializerOptions);
204+
var buffer = new ArrayBufferWriter<byte>();
205+
using var writer = new Utf8JsonWriter(buffer);
206+
JsonSerializer.Serialize(writer, value);
207+
return buffer.WrittenSpan.ToArray();
78208
}
79209
}

Tests/Boxed.AspNetCore.Test/DistributedCacheExtensionsTest.cs

Lines changed: 0 additions & 80 deletions
This file was deleted.

0 commit comments

Comments
 (0)