Skip to content
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

Caching: migrate HybridCache api surface from asp.net into runtime #103103

Merged
merged 13 commits into from
Jul 22, 2024
Merged
Show file tree
Hide file tree
Changes from 8 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
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ public partial interface IDistributedCache
void Set(string key, byte[] value, Microsoft.Extensions.Caching.Distributed.DistributedCacheEntryOptions options);
System.Threading.Tasks.Task SetAsync(string key, byte[] value, Microsoft.Extensions.Caching.Distributed.DistributedCacheEntryOptions options, System.Threading.CancellationToken token = default(System.Threading.CancellationToken));
}
public interface IBufferDistributedCache : IDistributedCache
{
bool TryGet(string key, System.Buffers.IBufferWriter<byte> destination);
System.Threading.Tasks.ValueTask<bool> TryGetAsync(string key, System.Buffers.IBufferWriter<byte> destination, System.Threading.CancellationToken token = default);
void Set(string key, System.Buffers.ReadOnlySequence<byte> value, DistributedCacheEntryOptions options);
System.Threading.Tasks.ValueTask SetAsync(string key, System.Buffers.ReadOnlySequence<byte> value, DistributedCacheEntryOptions options, System.Threading.CancellationToken token = default);
}
}
namespace Microsoft.Extensions.Caching.Memory
{
Expand Down Expand Up @@ -156,3 +163,59 @@ public SystemClock() { }
public System.DateTimeOffset UtcNow { get { throw null; } }
}
}
namespace Microsoft.Extensions.Caching.Hybrid
{
public partial interface IHybridCacheSerializer<T>
{
T Deserialize(System.Buffers.ReadOnlySequence<byte> source);
void Serialize(T value, System.Buffers.IBufferWriter<byte> target);
}
public interface IHybridCacheSerializerFactory
{
bool TryCreateSerializer<T>([System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out IHybridCacheSerializer<T>? serializer);
}
public sealed class HybridCacheEntryOptions
{
public System.TimeSpan? Expiration { get; init; }
public System.TimeSpan? LocalCacheExpiration { get; init; }
public HybridCacheEntryFlags? Flags { get; init; }
}
[System.Flags]
public enum HybridCacheEntryFlags
{
None = 0,
DisableLocalCacheRead = 1 << 0,
DisableLocalCacheWrite = 1 << 1,
DisableLocalCache = DisableLocalCacheRead | DisableLocalCacheWrite,
DisableDistributedCacheRead = 1 << 2,
DisableDistributedCacheWrite = 1 << 3,
DisableDistributedCache = DisableDistributedCacheRead | DisableDistributedCacheWrite,
DisableUnderlyingData = 1 << 4,
DisableCompression = 1 << 5,
}
public abstract class HybridCache
{
[System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Delegate differences make this unambiguous")]
public abstract System.Threading.Tasks.ValueTask<T> GetOrCreateAsync<TState, T>(string key, TState state, System.Func<TState, System.Threading.CancellationToken, System.Threading.Tasks.ValueTask<T>> factory,
HybridCacheEntryOptions? options = null, System.Collections.Generic.IEnumerable<string>? tags = null, System.Threading.CancellationToken cancellationToken = default);
[System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Delegate differences make this unambiguous")]
public System.Threading.Tasks.ValueTask<T> GetOrCreateAsync<T>(string key, System.Func<System.Threading.CancellationToken, System.Threading.Tasks.ValueTask<T>> factory,
HybridCacheEntryOptions? options = null, System.Collections.Generic.IEnumerable<string>? tags = null, System.Threading.CancellationToken cancellationToken = default)
=> throw null;

public abstract System.Threading.Tasks.ValueTask SetAsync<T>(string key, T value, HybridCacheEntryOptions? options = null, System.Collections.Generic.IEnumerable<string>? tags = null, System.Threading.CancellationToken cancellationToken = default);

[System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Not ambiguous in context")]
mgravell marked this conversation as resolved.
Show resolved Hide resolved
public abstract System.Threading.Tasks.ValueTask RemoveAsync(string key, System.Threading.CancellationToken cancellationToken = default);

[System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Not ambiguous in context")]
public virtual System.Threading.Tasks.ValueTask RemoveAsync(System.Collections.Generic.IEnumerable<string> keys, System.Threading.CancellationToken cancellationToken = default)
=> throw null;

[System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Not ambiguous in context")]
public virtual System.Threading.Tasks.ValueTask RemoveByTagAsync(System.Collections.Generic.IEnumerable<string> tags, System.Threading.CancellationToken cancellationToken = default)
stephentoub marked this conversation as resolved.
Show resolved Hide resolved
=> throw null;
public abstract System.Threading.Tasks.ValueTask RemoveByTagAsync(string tag, System.Threading.CancellationToken cancellationToken = default);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' != '.NETCoreApp'">
<Compile Include="$(CoreLibSharedDir)System\Runtime\CompilerServices\IsExternalInit.cs"
Link="Common\System\Runtime\CompilerServices\IsExternalInit.cs" />

<PackageReference Include="System.Threading.Tasks.Extensions" Version="$(SystemThreadingTasksExtensionsVersion)" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// 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;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Distributed;

namespace Microsoft.Extensions.Caching.Hybrid;

/// <summary>
/// Provides multi-tier caching services building on <see cref="IDistributedCache"/> backends.
/// </summary>
public abstract class HybridCache
{
/// <summary>
/// Asynchronously gets the value associated with the key if it exists, or generates a new entry using the provided key and a value from the given factory if the key is not found.
/// </summary>
/// <typeparam name="TState">The type of additional state required by <paramref name="factory"/>.</typeparam>
/// <typeparam name="T">The type of the data being considered.</typeparam>
/// <param name="key">The key of the entry to look for or create.</param>
/// <param name="factory">Provides the underlying data service is the data is not available in the cache.</param>
jozkee marked this conversation as resolved.
Show resolved Hide resolved
/// <param name="state">The state required for <paramref name="factory"/>.</param>
/// <param name="options">Additional options for this cache entry.</param>
/// <param name="tags">The tags to associate with this cache item.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
/// <returns>The data, either from cache or the underlying data service.</returns>
[System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Delegate differences make this unambiguous")]
mgravell marked this conversation as resolved.
Show resolved Hide resolved
public abstract ValueTask<T> GetOrCreateAsync<TState, T>(string key, TState state, Func<TState, CancellationToken, ValueTask<T>> factory,
jozkee marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the order of TState / T be changed so that in all overloads the T is first?

HybridCacheEntryOptions? options = null, IEnumerable<string>? tags = null, CancellationToken cancellationToken = default);

/// <summary>
/// Asynchronously gets the value associated with the key if it exists, or generates a new entry using the provided key and a value from the given factory if the key is not found.
/// </summary>
/// <typeparam name="T">The type of the data being considered.</typeparam>
/// <param name="key">The key of the entry to look for or create.</param>
/// <param name="factory">Provides the underlying data service is the data is not available in the cache.</param>
/// <param name="options">Additional options for this cache entry.</param>
/// <param name="tags">The tags to associate with this cache item.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
/// <returns>The data, either from cache or the underlying data service.</returns>
[System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Delegate differences make this unambiguous")]
public ValueTask<T> GetOrCreateAsync<T>(string key, Func<CancellationToken, ValueTask<T>> factory,
HybridCacheEntryOptions? options = null, IEnumerable<string>? tags = null, CancellationToken cancellationToken = default)
=> GetOrCreateAsync(key, factory, WrappedCallbackCache<T>.Instance, options, tags, cancellationToken);

private static class WrappedCallbackCache<T> // per-T memoized helper that allows GetOrCreateAsync<T> and GetOrCreateAsync<TState, T> to share an implementation
{
// for the simple usage scenario (no TState), pack the original callback as the "state", and use a wrapper function that just unrolls and invokes from the state
public static readonly Func<Func<CancellationToken, ValueTask<T>>, CancellationToken, ValueTask<T>> Instance = static (callback, ct) => callback(ct);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this actually necessary? I seem to remember the C# compiler folding identical lambdas into the same method definition (but maybe I'm misremembering?)


/// <summary>
/// Asynchronously sets or overwrites the value associated with the key.
/// </summary>
/// <typeparam name="T">The type of the data being considered.</typeparam>
/// <param name="key">The key of the entry to create.</param>
/// <param name="value">The value to assign for this cache entry.</param>
/// <param name="options">Additional options for this cache entry.</param>
/// <param name="tags">The tags to associate with this cache entry.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> used to propagate notifications that the operation should be canceled.</param>
public abstract ValueTask SetAsync<T>(string key, T value, HybridCacheEntryOptions? options = null, IEnumerable<string>? tags = null, CancellationToken cancellationToken = default);

/// <summary>
/// Asynchronously removes the value associated with the key if it exists.
/// </summary>
[System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Not ambiguous in context")]
public abstract ValueTask RemoveAsync(string key, CancellationToken cancellationToken = default);

/// <summary>
/// Asynchronously removes the value associated with the key if it exists.
/// </summary>
/// <remarks>Implementors should treat <c>null</c> as empty</remarks>
[System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Not ambiguous in context")]
public virtual ValueTask RemoveAsync(IEnumerable<string> keys, CancellationToken cancellationToken = default)
jozkee marked this conversation as resolved.
Show resolved Hide resolved
{
return keys switch
{
// for consistency with GetOrCreate/Set: interpret null as "none"
null or ICollection<string> { Count: 0 } => default,
ICollection<string> { Count: 1 } => RemoveAsync(keys.Single(), cancellationToken),
mgravell marked this conversation as resolved.
Show resolved Hide resolved
_ => ForEachAsync(this, keys, cancellationToken),
};

// default implementation is to call RemoveAsync for each key in turn
static async ValueTask ForEachAsync(HybridCache @this, IEnumerable<string> keys, CancellationToken cancellationToken)
{
foreach (var key in keys)
{
await @this.RemoveAsync(key, cancellationToken).ConfigureAwait(false);
}
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any tests to add in this repo for the functionality that's here?


/// <summary>
/// Asynchronously removes all values associated with the specified tags.
/// </summary>
/// <remarks>Implementors should treat <c>null</c> as empty</remarks>
[System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Not ambiguous in context")]
public virtual ValueTask RemoveByTagAsync(IEnumerable<string> tags, CancellationToken cancellationToken = default)
jozkee marked this conversation as resolved.
Show resolved Hide resolved
{
return tags switch
{
// for consistency with GetOrCreate/Set: interpret null as "none"
null or ICollection<string> { Count: 0 } => default,
ICollection<string> { Count: 1 } => RemoveByTagAsync(tags.Single(), cancellationToken),
_ => ForEachAsync(this, tags, cancellationToken),
};

// default implementation is to call RemoveByTagAsync for each key in turn
static async ValueTask ForEachAsync(HybridCache @this, IEnumerable<string> keys, CancellationToken cancellationToken)
{
foreach (var key in keys)
{
await @this.RemoveByTagAsync(key, cancellationToken).ConfigureAwait(false);
}
}
}

/// <summary>
/// Asynchronously removes all values associated with the specified tag.
/// </summary>
[System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Not ambiguous in context")]
public abstract ValueTask RemoveByTagAsync(string tag, CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;

namespace Microsoft.Extensions.Caching.Hybrid;

/// <summary>
/// Additional flags that apply to a <see cref="HybridCache"/> operation.
/// </summary>
[Flags]
public enum HybridCacheEntryFlags
{
/// <summary>
/// No additional flags.
/// </summary>
None = 0,
/// <summary>
/// Disables reading from the local in-process cache.
/// </summary>
DisableLocalCacheRead = 1 << 0,
/// <summary>
/// Disables writing to the local in-process cache.
/// </summary>
DisableLocalCacheWrite = 1 << 1,
/// <summary>
/// Disables both reading from and writing to the local in-process cache.
/// </summary>
DisableLocalCache = DisableLocalCacheRead | DisableLocalCacheWrite,
/// <summary>
/// Disables reading from the secondary distributed cache.
/// </summary>
DisableDistributedCacheRead = 1 << 2,
/// <summary>
/// Disables writing to the secondary distributed cache.
/// </summary>
DisableDistributedCacheWrite = 1 << 3,
/// <summary>
/// Disables both reading from and writing to the secondary distributed cache.
/// </summary>
DisableDistributedCache = DisableDistributedCacheRead | DisableDistributedCacheWrite,
/// <summary>
/// Only fetches the value from cache; does not attempt to access the underlying data store.
/// </summary>
DisableUnderlyingData = 1 << 4,
/// <summary>
/// Disables compression for this payload.
/// </summary>
DisableCompression = 1 << 5,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// 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 Microsoft.Extensions.Caching.Distributed;

namespace Microsoft.Extensions.Caching.Hybrid;

/// <summary>
/// Additional options (expiration, etc.) that apply to a <see cref="HybridCache"/> operation. When options
/// can be specified at multiple levels (for example, globally and per-call), the values are composed; the
/// most granular non-null value is used, with null values being inherited. If no value is specified at
/// any level, the implementation may choose a reasonable default.
/// </summary>
public sealed class HybridCacheEntryOptions
{
/// <summary>
/// Overall cache duration of this entry, passed to the backend distributed cache.
mgravell marked this conversation as resolved.
Show resolved Hide resolved
/// </summary>
public TimeSpan? Expiration { get; init; } // overall cache duration
mgravell marked this conversation as resolved.
Show resolved Hide resolved

/// <summary>
/// Cache duration in local cache; when retrieving a cached value
/// from an external cache store, this value will be used to calculate the local
/// cache expiration, not exceeding the remaining overall cache lifetime.
/// </summary>
mgravell marked this conversation as resolved.
Show resolved Hide resolved
public TimeSpan? LocalCacheExpiration { get; init; } // TTL in L1

/// <summary>
/// Additional flags that apply to this usage.
mgravell marked this conversation as resolved.
Show resolved Hide resolved
/// </summary>
public HybridCacheEntryFlags? Flags { get; init; }

// memoize when possible
private DistributedCacheEntryOptions? _dc;
internal DistributedCacheEntryOptions? ToDistributedCacheEntryOptions()
=> Expiration is null ? null : (_dc ??= new() { AbsoluteExpirationRelativeToNow = Expiration });
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// 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;

/// <summary>
/// Per-type serialization/deserialization support for <see cref="HybridCache"/>.
/// </summary>
/// <typeparam name="T">The type being serialized/deserialized.</typeparam>
public interface IHybridCacheSerializer<T>
{
/// <summary>
/// Deserialize a <typeparamref name="T"/> value from the provided <paramref name="source"/>.
/// </summary>
T Deserialize(ReadOnlySequence<byte> source);

/// <summary>
/// Serialize <paramref name="value"/>, writing to the provided <paramref name="target"/>.
mgravell marked this conversation as resolved.
Show resolved Hide resolved
/// </summary>
void Serialize(T value, IBufferWriter<byte> target);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics.CodeAnalysis;

namespace Microsoft.Extensions.Caching.Hybrid;

/// <summary>
/// Factory provider for per-type <see cref="IHybridCacheSerializer{T}"/> instances.
/// </summary>
public interface IHybridCacheSerializerFactory
{
/// <summary>
/// Request a serializer for the provided type, if possible.
/// </summary>
/// <typeparam name="T">The type being serialized/deserialized.</typeparam>
/// <param name="serializer">The serializer.</param>
/// <returns><c>true</c> if the factory supports this type, <c>false</c> otherwise.</returns>
bool TryCreateSerializer<T>([NotNullWhen(true)] out IHybridCacheSerializer<T>? serializer);
}
Loading
Loading