Skip to content

Commit c4f3aa8

Browse files
authored
HybridCache: ensure that Size is always specified in L1 (#5420)
2 parents d0eb80b + f0da5af commit c4f3aa8

File tree

14 files changed

+270
-76
lines changed

14 files changed

+270
-76
lines changed

src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.CacheItem.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ protected virtual void OnFinalRelease() // any required release semantics
8787

8888
internal abstract class CacheItem<T> : CacheItem
8989
{
90+
public abstract bool TryGetSize(out long size);
91+
9092
// attempt to get a value that was *not* previously reserved
9193
public abstract bool TryGetValue(out T value);
9294

src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.ImmutableCacheItem.cs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ internal partial class DefaultHybridCache
1313

1414
private T _value = default!; // deferred until SetValue
1515

16+
public long Size { get; private set; } = -1;
17+
1618
public override bool DebugIsImmutable => true;
1719

1820
// get a shared instance that passes as "reserved"; doesn't need to be 100% singleton,
@@ -30,14 +32,24 @@ public static ImmutableCacheItem<T> GetReservedShared()
3032
return obj;
3133
}
3234

33-
public void SetValue(T value) => _value = value;
35+
public void SetValue(T value, long size)
36+
{
37+
_value = value;
38+
Size = size;
39+
}
3440

3541
public override bool TryGetValue(out T value)
3642
{
3743
value = _value;
3844
return true; // always available
3945
}
4046

47+
public override bool TryGetSize(out long size)
48+
{
49+
size = Size;
50+
return size >= 0;
51+
}
52+
4153
public override bool TryReserveBuffer(out BufferChunk buffer)
4254
{
4355
buffer = default;

src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.L2.cs

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ internal ValueTask<BufferChunk> GetFromL2Async(string key, CancellationToken tok
3434
return new(GetValidPayloadSegment(pendingLegacy.Result)); // already complete
3535

3636
case CacheFeatures.BackendCache | CacheFeatures.BackendBuffers: // IBufferWriter<byte>-based
37-
var writer = RecyclableArrayBufferWriter<byte>.Create(MaximumPayloadBytes);
37+
RecyclableArrayBufferWriter<byte> writer = RecyclableArrayBufferWriter<byte>.Create(MaximumPayloadBytes);
3838
var cache = Unsafe.As<IBufferDistributedCache>(_backendCache!); // type-checked already
3939
var pendingBuffers = cache.TryGetAsync(key, writer, token);
4040
if (!pendingBuffers.IsCompletedSuccessfully)
@@ -95,13 +95,26 @@ internal void SetL1<T>(string key, CacheItem<T> value, HybridCacheEntryOptions?
9595
if (value.TryReserve())
9696
{
9797
// based on CacheExtensions.Set<TItem>, but with post-eviction recycling
98-
using var cacheEntry = _localCache.CreateEntry(key);
98+
99+
// intentionally use manual Dispose rather than "using"; confusingly, it is Dispose()
100+
// that actually commits the add - so: if we fault, we don't want to try
101+
// committing a partially configured cache entry
102+
ICacheEntry cacheEntry = _localCache.CreateEntry(key);
99103
cacheEntry.AbsoluteExpirationRelativeToNow = options?.LocalCacheExpiration ?? _defaultLocalCacheExpiration;
100104
cacheEntry.Value = value;
105+
106+
if (value.TryGetSize(out var size))
107+
{
108+
cacheEntry = cacheEntry.SetSize(size);
109+
}
110+
101111
if (value.NeedsEvictionCallback)
102112
{
103-
_ = cacheEntry.RegisterPostEvictionCallback(CacheItem.SharedOnEviction);
113+
cacheEntry = cacheEntry.RegisterPostEvictionCallback(CacheItem.SharedOnEviction);
104114
}
115+
116+
// commit
117+
cacheEntry.Dispose();
105118
}
106119
}
107120

src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.MutableCacheItem.cs

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -21,34 +21,38 @@ public void SetValue(ref BufferChunk buffer, IHybridCacheSerializer<T> serialize
2121
buffer = default; // we're taking over the lifetime; the caller no longer has it!
2222
}
2323

24-
public void SetValue(T value, IHybridCacheSerializer<T> serializer, int maxLength)
25-
{
26-
_serializer = serializer;
27-
var writer = RecyclableArrayBufferWriter<byte>.Create(maxLength);
28-
serializer.Serialize(value, writer);
29-
30-
_buffer = new(writer.DetachCommitted(out var length), length, returnToPool: true);
31-
writer.Dispose(); // no buffers left (we just detached them), but just in case of other logic
32-
}
33-
3424
public override bool TryGetValue(out T value)
3525
{
3626
// only if we haven't already burned
37-
if (!TryReserve())
27+
if (TryReserve())
3828
{
39-
value = default!;
40-
return false;
29+
try
30+
{
31+
value = _serializer.Deserialize(_buffer.AsSequence());
32+
return true;
33+
}
34+
finally
35+
{
36+
_ = Release();
37+
}
4138
}
4239

43-
try
44-
{
45-
value = _serializer.Deserialize(_buffer.AsSequence());
46-
return true;
47-
}
48-
finally
40+
value = default!;
41+
return false;
42+
}
43+
44+
public override bool TryGetSize(out long size)
45+
{
46+
// only if we haven't already burned
47+
if (TryReserve())
4948
{
49+
size = _buffer.Length;
5050
_ = Release();
51+
return true;
5152
}
53+
54+
size = 0;
55+
return false;
5256
}
5357

5458
public override bool TryReserveBuffer(out BufferChunk buffer)

src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs

Lines changed: 96 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,15 @@
66
using System.Diagnostics.CodeAnalysis;
77
using System.Threading;
88
using System.Threading.Tasks;
9+
using static Microsoft.Extensions.Caching.Hybrid.Internal.DefaultHybridCache;
910

1011
namespace Microsoft.Extensions.Caching.Hybrid.Internal;
1112

1213
internal partial class DefaultHybridCache
1314
{
1415
internal sealed class StampedeState<TState, T> : StampedeState
1516
{
16-
[DoesNotReturn]
17-
private static CacheItem<T> ThrowUnexpectedCacheItem() => throw new InvalidOperationException("Unexpected cache item");
17+
private const HybridCacheEntryFlags FlagsDisableL1AndL2 = HybridCacheEntryFlags.DisableLocalCacheWrite | HybridCacheEntryFlags.DisableDistributedCacheWrite;
1818

1919
private readonly TaskCompletionSource<CacheItem<T>>? _result;
2020
private TState? _state;
@@ -154,6 +154,9 @@ static async Task<T> AwaitedAsync(Task<CacheItem<T>> task)
154154
=> (await task.ConfigureAwait(false)).GetReservedValue();
155155
}
156156

157+
[DoesNotReturn]
158+
private static CacheItem<T> ThrowUnexpectedCacheItem() => throw new InvalidOperationException("Unexpected cache item");
159+
157160
[SuppressMessage("Resilience", "EA0014:The async method doesn't support cancellation", Justification = "In this case the cancellation token is provided internally via SharedToken")]
158161
[SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Exception is passed through to faulted task result")]
159162
private async Task BackgroundFetchAsync()
@@ -175,29 +178,72 @@ private async Task BackgroundFetchAsync()
175178
// nothing from L2; invoke the underlying data store
176179
if ((Key.Flags & HybridCacheEntryFlags.DisableUnderlyingData) == 0)
177180
{
178-
var cacheItem = SetResult(await _underlying!(_state!, SharedToken).ConfigureAwait(false));
179-
180-
// note that at this point we've already released most or all of the waiting callers; everything
181-
// else here is background
182-
183-
// write to L2 if appropriate
184-
if ((Key.Flags & HybridCacheEntryFlags.DisableDistributedCacheWrite) == 0)
181+
// invoke the callback supplied by the caller
182+
T newValue = await _underlying!(_state!, SharedToken).ConfigureAwait(false);
183+
184+
// If we're writing this value *anywhere*, we're going to need to serialize; this is obvious
185+
// in the case of L2, but we also need it for L1, because MemoryCache might be enforcing
186+
// SizeLimit (we can't know - it is an abstraction), and for *that* we need to know the item size.
187+
// Likewise, if we're writing to a MutableCacheItem, we'll be serializing *anyway* for the payload.
188+
//
189+
// Rephrasing that: the only scenario in which we *do not* need to serialize is if:
190+
// - it is an ImmutableCacheItem
191+
// - we're writing neither to L1 nor L2
192+
193+
CacheItem cacheItem = CacheItem;
194+
bool skipSerialize = cacheItem is ImmutableCacheItem<T> && (Key.Flags & FlagsDisableL1AndL2) == FlagsDisableL1AndL2;
195+
196+
if (skipSerialize)
185197
{
186-
if (cacheItem.TryReserveBuffer(out var buffer))
187-
{
188-
// mutable: we've already serialized it for the shared cache item
189-
await Cache.SetL2Async(Key.Key, in buffer, _options, SharedToken).ConfigureAwait(false);
190-
_ = cacheItem.Release(); // because we reserved
191-
}
192-
else if (cacheItem.TryGetValue(out var value))
198+
SetImmutableResultWithoutSerialize(newValue);
199+
}
200+
else if (cacheItem.TryReserve())
201+
{
202+
// ^^^ The first thing we need to do is make sure we're not getting into a thread race over buffer disposal.
203+
// In particular, if this cache item is somehow so short-lived that the buffers would be released *before* we're
204+
// done writing them to L2, which happens *after* we've provided the value to consumers.
205+
RecyclableArrayBufferWriter<byte> writer = RecyclableArrayBufferWriter<byte>.Create(MaximumPayloadBytes); // note this lifetime spans the SetL2Async
206+
IHybridCacheSerializer<T> serializer = Cache.GetSerializer<T>();
207+
serializer.Serialize(newValue, writer);
208+
BufferChunk buffer = new(writer.DetachCommitted(out var length), length, returnToPool: true); // remove buffer ownership from the writer
209+
writer.Dispose(); // we're done with the writer
210+
211+
// protect "buffer" (this is why we "reserved") for writing to L2 if needed; SetResultPreSerialized
212+
// *may* (depending on context) claim this buffer, in which case "bufferToRelease" gets reset, and
213+
// the final RecycleIfAppropriate() is a no-op; however, the buffer is valid in either event,
214+
// (with TryReserve above guaranteeing that we aren't in a race condition).
215+
BufferChunk bufferToRelease = buffer;
216+
217+
// and since "bufferToRelease" is the thing that will be returned at some point, we can make it explicit
218+
// that we do not need or want "buffer" to do any recycling (they're the same memory)
219+
buffer = buffer.DoNotReturnToPool();
220+
221+
// set the underlying result for this operation (includes L1 write if appropriate)
222+
SetResultPreSerialized(newValue, ref bufferToRelease, serializer);
223+
224+
// Note that at this point we've already released most or all of the waiting callers. Everything
225+
// from this point onwards happens in the background, from the perspective of the calling code.
226+
227+
// Write to L2 if appropriate.
228+
if ((Key.Flags & HybridCacheEntryFlags.DisableDistributedCacheWrite) == 0)
193229
{
194-
// immutable: we'll need to do the serialize ourselves
195-
var writer = RecyclableArrayBufferWriter<byte>.Create(MaximumPayloadBytes); // note this lifetime spans the SetL2Async
196-
Cache.GetSerializer<T>().Serialize(value, writer);
197-
buffer = new(writer.GetBuffer(out var length), length, returnToPool: false); // writer still owns the buffer
230+
// We already have the payload serialized, so this is trivial to do.
198231
await Cache.SetL2Async(Key.Key, in buffer, _options, SharedToken).ConfigureAwait(false);
199-
writer.Dispose(); // recycle on success
200232
}
233+
234+
// Release our hook on the CacheItem (only really important for "mutable").
235+
_ = cacheItem.Release();
236+
237+
// Finally, recycle whatever was left over from SetResultPreSerialized; using "bufferToRelease"
238+
// here is NOT a typo; if SetResultPreSerialized left this value alone (immutable), then
239+
// this is our recycle step; if SetResultPreSerialized transferred ownership to the (mutable)
240+
// CacheItem, then this becomes a no-op, and the buffer only gets fully recycled when the
241+
// CacheItem itself is fully clear.
242+
bufferToRelease.RecycleIfAppropriate();
243+
}
244+
else
245+
{
246+
throw new InvalidOperationException("Internal HybridCache failure: unable to reserve cache item to assign result");
201247
}
202248
}
203249
else
@@ -237,13 +283,13 @@ private void SetResultAndRecycleIfAppropriate(ref BufferChunk value)
237283
// set a result from L2 cache
238284
Debug.Assert(value.Array is not null, "expected buffer");
239285

240-
var serializer = Cache.GetSerializer<T>();
286+
IHybridCacheSerializer<T> serializer = Cache.GetSerializer<T>();
241287
CacheItem<T> cacheItem;
242288
switch (CacheItem)
243289
{
244290
case ImmutableCacheItem<T> immutable:
245291
// deserialize; and store object; buffer can be recycled now
246-
immutable.SetValue(serializer.Deserialize(new(value.Array!, 0, value.Length)));
292+
immutable.SetValue(serializer.Deserialize(new(value.Array!, 0, value.Length)), value.Length);
247293
value.RecycleIfAppropriate();
248294
cacheItem = immutable;
249295
break;
@@ -261,20 +307,43 @@ private void SetResultAndRecycleIfAppropriate(ref BufferChunk value)
261307
SetResult(cacheItem);
262308
}
263309

264-
private CacheItem<T> SetResult(T value)
310+
private void SetImmutableResultWithoutSerialize(T value)
265311
{
312+
Debug.Assert((Key.Flags & FlagsDisableL1AndL2) == FlagsDisableL1AndL2, "Only expected if L1+L2 disabled");
313+
266314
// set a result from a value we calculated directly
267315
CacheItem<T> cacheItem;
268316
switch (CacheItem)
269317
{
270318
case ImmutableCacheItem<T> immutable:
271319
// no serialize needed
272-
immutable.SetValue(value);
320+
immutable.SetValue(value, size: -1);
273321
cacheItem = immutable;
274322
break;
323+
default:
324+
cacheItem = ThrowUnexpectedCacheItem();
325+
break;
326+
}
327+
328+
SetResult(cacheItem);
329+
}
330+
331+
private void SetResultPreSerialized(T value, ref BufferChunk buffer, IHybridCacheSerializer<T> serializer)
332+
{
333+
// set a result from a value we calculated directly that
334+
// has ALREADY BEEN SERIALIZED (we can optionally consume this buffer)
335+
CacheItem<T> cacheItem;
336+
switch (CacheItem)
337+
{
338+
case ImmutableCacheItem<T> immutable:
339+
// no serialize needed
340+
immutable.SetValue(value, size: buffer.Length);
341+
cacheItem = immutable;
342+
343+
// (but leave the buffer alone)
344+
break;
275345
case MutableCacheItem<T> mutable:
276-
// serialization happens here
277-
mutable.SetValue(value, Cache.GetSerializer<T>(), MaximumPayloadBytes);
346+
mutable.SetValue(ref buffer, serializer);
278347
mutable.DebugOnlyTrackBuffer(Cache);
279348
cacheItem = mutable;
280349
break;
@@ -284,7 +353,6 @@ private CacheItem<T> SetResult(T value)
284353
}
285354

286355
SetResult(cacheItem);
287-
return cacheItem;
288356
}
289357

290358
private void SetResult(CacheItem<T> value)

test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/BufferReleaseTests.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ private static ServiceProvider GetDefaultCache(out DefaultHybridCache cache, Act
1919
var services = new ServiceCollection();
2020
config?.Invoke(services);
2121
services.AddHybridCache();
22-
var provider = services.BuildServiceProvider();
22+
ServiceProvider provider = services.BuildServiceProvider();
2323
cache = Assert.IsType<DefaultHybridCache>(provider.GetRequiredService<HybridCache>());
2424
return provider;
2525
}
@@ -117,8 +117,8 @@ private static bool Write(IBufferWriter<byte> destination, byte[]? buffer)
117117
// prep the backend with our data
118118
var key = Me();
119119
Assert.NotNull(cache.BackendCache);
120-
var serializer = cache.GetSerializer<Customer>();
121-
using (var writer = RecyclableArrayBufferWriter<byte>.Create(int.MaxValue))
120+
IHybridCacheSerializer<Customer> serializer = cache.GetSerializer<Customer>();
121+
using (RecyclableArrayBufferWriter<byte> writer = RecyclableArrayBufferWriter<byte>.Create(int.MaxValue))
122122
{
123123
serializer.Serialize(await GetAsync(), writer);
124124
cache.BackendCache.Set(key, writer.ToArray());
@@ -176,8 +176,8 @@ private static bool Write(IBufferWriter<byte> destination, byte[]? buffer)
176176
// prep the backend with our data
177177
var key = Me();
178178
Assert.NotNull(cache.BackendCache);
179-
var serializer = cache.GetSerializer<Customer>();
180-
using (var writer = RecyclableArrayBufferWriter<byte>.Create(int.MaxValue))
179+
IHybridCacheSerializer<Customer> serializer = cache.GetSerializer<Customer>();
180+
using (RecyclableArrayBufferWriter<byte> writer = RecyclableArrayBufferWriter<byte>.Create(int.MaxValue))
181181
{
182182
serializer.Serialize(await GetAsync(), writer);
183183
cache.BackendCache.Set(key, writer.ToArray());

test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/DistributedCacheTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ public async Task ReadOnlySequenceBufferRoundtrip(int size, SequenceKind kind)
185185
Assert.Equal(size, expected.Length);
186186
cache.Set(key, payload, _fiveMinutes);
187187

188-
var writer = RecyclableArrayBufferWriter<byte>.Create(int.MaxValue);
188+
RecyclableArrayBufferWriter<byte> writer = RecyclableArrayBufferWriter<byte>.Create(int.MaxValue);
189189
Assert.True(cache.TryGet(key, writer));
190190
Assert.True(expected.Span.SequenceEqual(writer.GetCommittedMemory().Span));
191191
writer.ResetInPlace();
@@ -247,7 +247,7 @@ public async Task ReadOnlySequenceBufferRoundtripAsync(int size, SequenceKind ki
247247
Assert.Equal(size, expected.Length);
248248
await cache.SetAsync(key, payload, _fiveMinutes);
249249

250-
var writer = RecyclableArrayBufferWriter<byte>.Create(int.MaxValue);
250+
RecyclableArrayBufferWriter<byte> writer = RecyclableArrayBufferWriter<byte>.Create(int.MaxValue);
251251
Assert.True(await cache.TryGetAsync(key, writer));
252252
Assert.True(expected.Span.SequenceEqual(writer.GetCommittedMemory().Span));
253253
writer.ResetInPlace();

test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/FunctionalTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ private static ServiceProvider GetDefaultCache(out DefaultHybridCache cache, Act
1313
var services = new ServiceCollection();
1414
config?.Invoke(services);
1515
services.AddHybridCache();
16-
var provider = services.BuildServiceProvider();
16+
ServiceProvider provider = services.BuildServiceProvider();
1717
cache = Assert.IsType<DefaultHybridCache>(provider.GetRequiredService<HybridCache>());
1818
return provider;
1919
}

0 commit comments

Comments
 (0)