-
Notifications
You must be signed in to change notification settings - Fork 10.2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Implement minimal implementation of HybridCache #55147
Changes from 59 commits
35e9cea
12b3a0e
c5d28d4
20e1cb2
4379647
d382a7a
e8bc9a9
7edddb1
dc622a8
4920182
8841e97
6cbe02e
0c0d589
ef0ad56
e28f316
fbedef4
b49151e
c2326fd
29dcc2e
4ddba7f
427601c
cef964f
6f572f3
a41175c
82a34e3
e3a9173
373aaa8
8c1cc9e
40e2c47
cede0e9
ad88ef1
e427ad9
b9bc68a
1d548bf
18ae584
e60099b
5421398
c3adf74
0ac4dae
1db2758
2945ce0
94d9fe1
a13e1d3
2a77920
cee8ddc
7989f16
49a477f
5f8bcb8
7b32e0f
62b4e6f
c6cdeae
d624726
e831198
83b7a1d
59bc62a
0e36776
ee15beb
eb7a294
ffa0e0a
1b12e1a
3e42524
8c0e1b5
6c10b8d
552676f
bb1f856
6a25601
17383c4
14bb5fc
e6b2cca
96d75fa
366bd82
256581b
45d31b3
3de225e
c796f52
ce7ec84
0489dce
e5a080f
de2a4b0
d4133d1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
// Licensed to the .NET Foundation under one or more agreements. | ||
// The .NET Foundation licenses this file to you under the MIT license. | ||
|
||
namespace Microsoft.Extensions.Caching.Hybrid.Internal; | ||
|
||
partial class DefaultHybridCache | ||
{ | ||
internal abstract class CacheItem<T> | ||
{ | ||
public abstract T GetValue(); | ||
|
||
public abstract byte[]? TryGetBytes(out int length); | ||
mgravell marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
// Licensed to the .NET Foundation under one or more agreements. | ||
// The .NET Foundation licenses this file to you under the MIT license. | ||
|
||
namespace Microsoft.Extensions.Caching.Hybrid.Internal; | ||
|
||
partial class DefaultHybridCache | ||
{ | ||
private sealed class ImmutableCacheItem<T>(T value) : CacheItem<T> // used to hold types that do not require defensive copies | ||
{ | ||
private static ImmutableCacheItem<T>? sharedDefault; | ||
public static ImmutableCacheItem<T> Default => sharedDefault ??= new(default!); // this is only used when the underlying store is disabled | ||
|
||
public override T GetValue() => value; | ||
|
||
public override byte[]? TryGetBytes(out int length) | ||
{ | ||
length = 0; | ||
return null; | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,110 @@ | ||
using System; | ||
mgravell marked this conversation as resolved.
Show resolved
Hide resolved
|
||
using System.Diagnostics; | ||
using System.Runtime.CompilerServices; | ||
using System.Threading; | ||
using System.Threading.Tasks; | ||
using Microsoft.Extensions.Caching.Distributed; | ||
using Microsoft.Extensions.Caching.Memory; | ||
|
||
namespace Microsoft.Extensions.Caching.Hybrid.Internal; | ||
|
||
partial class DefaultHybridCache | ||
{ | ||
internal ValueTask<ArraySegment<byte>> GetFromL2Async(string key, CancellationToken token) | ||
{ | ||
switch (GetFeatures(CacheFeatures.BackendCache | CacheFeatures.BackendBuffers)) | ||
{ | ||
case CacheFeatures.BackendCache: // legacy byte[]-based | ||
var pendingLegacy = _backendCache!.GetAsync(key, token); | ||
#if NETCOREAPP2_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER | ||
if (!pendingLegacy.IsCompletedSuccessfully) | ||
#else | ||
if (pendingLegacy.Status != TaskStatus.RanToCompletion) | ||
#endif | ||
{ | ||
return new(AwaitedLegacy(pendingLegacy, MaximumPayloadBytes)); | ||
} | ||
var bytes = pendingLegacy.Result; // already complete | ||
if (bytes is not null) | ||
{ | ||
if (bytes.Length > MaximumPayloadBytes) | ||
{ | ||
ThrowQuota(); | ||
} | ||
return new(new ArraySegment<byte>(bytes)); | ||
} | ||
break; | ||
case CacheFeatures.BackendCache | CacheFeatures.BackendBuffers: // IBufferWriter<byte>-based | ||
var writer = RecyclableArrayBufferWriter<byte>.Create(MaximumPayloadBytes); | ||
var cache = Unsafe.As<IBufferDistributedCache>(_backendCache!); // type-checked already | ||
var pendingBuffers = cache.TryGetAsync(key, writer, token); | ||
if (!pendingBuffers.IsCompletedSuccessfully) | ||
{ | ||
return new(AwaitedBuffers(pendingBuffers, writer)); | ||
} | ||
ArraySegment<byte> result = pendingBuffers.GetAwaiter().GetResult() | ||
? new(writer.DetachCommitted(out var length), 0, length) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Isn't this starving the array pool? The caller isn't returning the buffers to the pool. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. fair; fixed by adding a little extra (but necessary) complexity - we're now hooking the eviction notification on relevant cache entries, to add a "you don't need this buffer any more" signal; this has to be combined (interlocked) with people currently reading the buffer in-situ for defensive deserialize; long story short: this now works, including buffer-counting tests (256581b) |
||
: default; | ||
writer.Dispose(); // it is not accidental that this isn't "using"; avoid recycling if not 100% sure what happened | ||
return new(result); | ||
} | ||
return default; | ||
|
||
static async Task<ArraySegment<byte>> AwaitedLegacy(Task<byte[]?> pending, int maximumPayloadBytes) | ||
{ | ||
var bytes = await pending.ConfigureAwait(false); | ||
if (bytes is not null) | ||
{ | ||
if (bytes.Length > maximumPayloadBytes) | ||
{ | ||
ThrowQuota(); | ||
} | ||
return new(bytes); | ||
} | ||
return default; | ||
} | ||
|
||
static async Task<ArraySegment<byte>> AwaitedBuffers(ValueTask<bool> pending, RecyclableArrayBufferWriter<byte> writer) | ||
{ | ||
ArraySegment<byte> result = await pending.ConfigureAwait(false) | ||
? new(writer.DetachCommitted(out var length), 0, length) | ||
: default; | ||
writer.Dispose(); // it is not accidental that this isn't "using"; avoid recycling if not 100% sure what happened | ||
return result; | ||
} | ||
|
||
static void ThrowQuota() => throw new InvalidOperationException("Maximum cache length exceeded"); | ||
mgravell marked this conversation as resolved.
Show resolved
Hide resolved
mgravell marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
internal ValueTask SetL2Async(string key, byte[] value, int length, HybridCacheEntryOptions? options, CancellationToken token) | ||
{ | ||
Debug.Assert(value.Length >= length); | ||
mgravell marked this conversation as resolved.
Show resolved
Hide resolved
|
||
switch (GetFeatures(CacheFeatures.BackendCache | CacheFeatures.BackendBuffers)) | ||
{ | ||
case CacheFeatures.BackendCache: // legacy byte[]-based | ||
if (value.Length > length) | ||
{ | ||
Array.Resize(ref value, length); | ||
} | ||
Debug.Assert(value.Length == length); | ||
return new(_backendCache!.SetAsync(key, value, GetOptions(options), token)); | ||
case CacheFeatures.BackendCache | CacheFeatures.BackendBuffers: // ReadOnlySequence<byte>-based | ||
var cache = Unsafe.As<IBufferDistributedCache>(_backendCache!); // type-checked already | ||
return cache.SetAsync(key, new(value, 0, length), GetOptions(options), token); | ||
} | ||
return default; | ||
} | ||
|
||
private DistributedCacheEntryOptions GetOptions(HybridCacheEntryOptions? options) | ||
{ | ||
DistributedCacheEntryOptions? result = null; | ||
if (options is not null && options.Expiration.HasValue && options.Expiration.GetValueOrDefault() != _defaultExpiration) | ||
{ | ||
result = options.ToDistributedCacheEntryOptions(); | ||
} | ||
return result ?? _defaultDistributedCacheExpiration; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Super nit: I like the use of partial classes but perhaps defaults like these should be centralized into a constants class so it's easier to grok their values? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. which constant(s) do you mean? the only constant I can see there is |
||
} | ||
|
||
internal void SetL1<T>(string key, CacheItem<T> value, HybridCacheEntryOptions? options) | ||
=> _localCache.Set(key, value, options?.LocalCacheExpiration ?? _defaultLocalCacheExpiration); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
// Licensed to the .NET Foundation under one or more agreements. | ||
// The .NET Foundation licenses this file to you under the MIT license. | ||
|
||
using System.Buffers; | ||
|
||
namespace Microsoft.Extensions.Caching.Hybrid.Internal; | ||
|
||
partial class DefaultHybridCache | ||
{ | ||
private sealed class MutableCacheItem<T> : CacheItem<T> // used to hold types that require defensive copies | ||
{ | ||
public MutableCacheItem(byte[] bytes, int length, IHybridCacheSerializer<T> serializer) | ||
{ | ||
_serializer = serializer; | ||
_bytes = bytes; | ||
_length = length; | ||
} | ||
|
||
public MutableCacheItem(T value, IHybridCacheSerializer<T> serializer, int maxLength) | ||
{ | ||
_serializer = serializer; | ||
var writer = RecyclableArrayBufferWriter<byte>.Create(maxLength); | ||
serializer.Serialize(value, writer); | ||
_bytes = writer.DetachCommitted(out _length); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. More array pool starvation? |
||
writer.Dispose(); // only recycle on success | ||
} | ||
|
||
private readonly IHybridCacheSerializer<T> _serializer; | ||
mgravell marked this conversation as resolved.
Show resolved
Hide resolved
|
||
private readonly byte[] _bytes; | ||
private readonly int _length; | ||
|
||
public override T GetValue() => _serializer.Deserialize(new ReadOnlySequence<byte>(_bytes, 0, _length)); | ||
|
||
public override byte[]? TryGetBytes(out int length) | ||
{ | ||
length = _length; | ||
return _bytes; | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,108 @@ | ||
// Licensed to the .NET Foundation under one or more agreements. | ||
// The .NET Foundation licenses this file to you under the MIT license. | ||
|
||
using System; | ||
using System.Collections.Concurrent; | ||
using System.ComponentModel; | ||
using System.Linq; | ||
using System.Reflection; | ||
using System.Runtime.CompilerServices; | ||
using System.Runtime.InteropServices; | ||
using System.Runtime.Serialization; | ||
using Microsoft.Extensions.DependencyInjection; | ||
|
||
namespace Microsoft.Extensions.Caching.Hybrid.Internal; | ||
partial class DefaultHybridCache | ||
{ | ||
private readonly ConcurrentDictionary<Type, object> _serializers = new(); // per instance cache of typed serializers | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the serializer API itself is typed, i.e. + public interface IHybridCacheSerializer {}
+ public interface IHybridCacheSerializer<T> : IHybridCacheSerializer
- public interface IHybridCacheSerializer<T>
{ ... } but... if that is only for this, it seems artificial; added 8c0e1b5 instead |
||
|
||
internal int MaximumPayloadBytes { get; } | ||
|
||
internal IHybridCacheSerializer<T> GetSerializer<T>() | ||
{ | ||
return _serializers.TryGetValue(typeof(T), out var serializer) | ||
? Unsafe.As<IHybridCacheSerializer<T>>(serializer) : ResolveAndAddSerializer(this); | ||
|
||
static IHybridCacheSerializer<T> ResolveAndAddSerializer(DefaultHybridCache @this) | ||
{ | ||
// it isn't critical that we get only one serializer instance during start-up; what matters | ||
// is that we don't get a new serializer instance *every time* | ||
var serializer = @this._services.GetService<IHybridCacheSerializer<T>>(); | ||
if (serializer is null) | ||
{ | ||
foreach (var factory in @this._serializerFactories) | ||
{ | ||
if (factory.TryCreateSerializer<T>(out var current)) | ||
{ | ||
serializer = current; | ||
break; // we've already reversed the factories, so: the first hit is what we want | ||
} | ||
} | ||
} | ||
if (serializer is null) | ||
{ | ||
throw new InvalidOperationException($"No {nameof(IHybridCacheSerializer<T>)} configured for type '{typeof(T).Name}'"); | ||
} | ||
// store the result so we don't repeat this in future | ||
@this._serializers[typeof(T)] = serializer; | ||
return serializer; | ||
} | ||
} | ||
|
||
internal static class ImmutableTypeCache<T> // lazy memoize; T doesn't change per cache instance | ||
{ | ||
// note for blittable types: a pure struct will be a full copy every time - nothing shared to mutate | ||
public static readonly bool IsImmutable = (typeof(T).IsValueType && IsBlittable<T>()) || IsImmutable(typeof(T)); | ||
} | ||
|
||
private static bool IsBlittable<T>() // minimize the generic portion | ||
{ | ||
#if NETCOREAPP2_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER | ||
return !RuntimeHelpers.IsReferenceOrContainsReferences<T>(); | ||
#else | ||
try // down-level: only blittable types can be pinned | ||
{ | ||
// get a typed, zeroed, non-null boxed instance of the appropriate type | ||
// (can't use (object)default(T), as that would box to null for nullable types) | ||
var obj = FormatterServices.GetUninitializedObject(Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T)); | ||
GCHandle.Alloc(obj, GCHandleType.Pinned).Free(); | ||
return true; | ||
} | ||
catch | ||
{ | ||
return false; | ||
} | ||
#endif | ||
} | ||
|
||
private static bool IsImmutable(Type type) | ||
{ | ||
// check for known types | ||
if (type == typeof(string)) | ||
{ | ||
return true; | ||
} | ||
|
||
if (type.IsValueType) | ||
{ | ||
// switch from Foo? to Foo if necessary | ||
if (Nullable.GetUnderlyingType(type) is { } nullable) | ||
{ | ||
type = nullable; | ||
} | ||
} | ||
|
||
if (type.IsValueType || (type.IsClass & type.IsSealed)) | ||
{ | ||
// check for [ImmutableObject(true)]; note we're looking at this as a statement about | ||
// the overall nullability; for example, a type could contain a private int[] field, | ||
// where the field is mutable and the list is mutable; but if the type is annotated: | ||
// we're trusting that the API and use-case is such that the type is immutable | ||
return type.GetCustomAttribute<ImmutableObjectAttribute>() is { Immutable: true }; | ||
} | ||
// don't trust interfaces and non-sealed types; we might have any concrete | ||
// type that has different behaviour | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Gallingly, Microsoft's standard spelling doesn't include a 'u'. It doesn't matter in this private context, but I do it anyway to try to make it habitual. |
||
return false; | ||
|
||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why? Technically API review needed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
great question; this was mostly a reaction to comparisons to the related ones I found while working on this:
I assumed, therefore, that this was intentional and desirable; if I'm missing something, I'm all ears. It makes no difference to me whether we have this - I was simply trying to be consistent with convention
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@BrennanConroy disabled for now (d4133d1) - any opinions on whether we should implement this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't know what it adds. Like you pointed out we've done it in some places. But I wouldn't call it the convention, there are a lot more options objects without it. We should probably figure out what it does before adding it.