diff --git a/src/libraries/Microsoft.Extensions.Caching.Abstractions/ref/Microsoft.Extensions.Caching.Abstractions.cs b/src/libraries/Microsoft.Extensions.Caching.Abstractions/ref/Microsoft.Extensions.Caching.Abstractions.cs index f1184552d6a578..69a41d5d87cd06 100644 --- a/src/libraries/Microsoft.Extensions.Caching.Abstractions/ref/Microsoft.Extensions.Caching.Abstractions.cs +++ b/src/libraries/Microsoft.Extensions.Caching.Abstractions/ref/Microsoft.Extensions.Caching.Abstractions.cs @@ -177,9 +177,11 @@ public interface IHybridCacheSerializerFactory } public sealed class HybridCacheEntryOptions { - public System.TimeSpan? Expiration { get; init; } - public System.TimeSpan? LocalCacheExpiration { get; init; } - public HybridCacheEntryFlags? Flags { get; init; } + public System.TimeSpan? Expiration { get; set; } + public System.TimeSpan? LocalCacheExpiration { get; set; } + public HybridCacheEntryFlags? Flags { get; set; } + public long? LocalSize { get; set; } + public int Revision { get { throw null; } } } [System.Flags] public enum HybridCacheEntryFlags @@ -203,6 +205,16 @@ public System.Threading.Tasks.ValueTask GetOrCreateAsync(string key, Syste HybridCacheEntryOptions? options = null, System.Collections.Generic.IEnumerable? tags = null, System.Threading.CancellationToken cancellationToken = default) => throw null; + public virtual System.Threading.Tasks.ValueTask GetOrCreateAsync(string key, TState state, + System.Func> factory, + HybridCacheEntryOptions? options = null, System.Collections.Generic.IEnumerable? tags = null, System.Threading.CancellationToken cancellationToken = default) + => throw null; + + public System.Threading.Tasks.ValueTask GetOrCreateAsync(string key, + System.Func> factory, + HybridCacheEntryOptions? options = null, System.Collections.Generic.IEnumerable? tags = null, System.Threading.CancellationToken cancellationToken = default) + => throw null; + public abstract System.Threading.Tasks.ValueTask SetAsync(string key, T value, HybridCacheEntryOptions? options = null, System.Collections.Generic.IEnumerable? tags = null, System.Threading.CancellationToken cancellationToken = default); public abstract System.Threading.Tasks.ValueTask RemoveAsync(string key, System.Threading.CancellationToken cancellationToken = default); diff --git a/src/libraries/Microsoft.Extensions.Caching.Abstractions/ref/Microsoft.Extensions.Caching.Abstractions.net10.cs b/src/libraries/Microsoft.Extensions.Caching.Abstractions/ref/Microsoft.Extensions.Caching.Abstractions.net10.cs index 57aa53f1e513f7..aa47043ffe7304 100644 --- a/src/libraries/Microsoft.Extensions.Caching.Abstractions/ref/Microsoft.Extensions.Caching.Abstractions.net10.cs +++ b/src/libraries/Microsoft.Extensions.Caching.Abstractions/ref/Microsoft.Extensions.Caching.Abstractions.net10.cs @@ -34,5 +34,31 @@ public System.Threading.Tasks.ValueTask GetOrCreateAsync( Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions? options = null, System.Collections.Generic.IEnumerable? tags = null, System.Threading.CancellationToken cancellationToken = default) => throw null; + public System.Threading.Tasks.ValueTask GetOrCreateAsync( + System.ReadOnlySpan key, + System.Func> factory, + Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions? options = null, + System.Collections.Generic.IEnumerable? tags = null, + System.Threading.CancellationToken cancellationToken = default) => throw null; + public virtual System.Threading.Tasks.ValueTask GetOrCreateAsync( + System.ReadOnlySpan key, + TState state, + System.Func> factory, + Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions? options = null, + System.Collections.Generic.IEnumerable? tags = null, + System.Threading.CancellationToken cancellationToken = default) => throw null; + public System.Threading.Tasks.ValueTask GetOrCreateAsync( + ref System.Runtime.CompilerServices.DefaultInterpolatedStringHandler key, + System.Func> factory, + Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions? options = null, + System.Collections.Generic.IEnumerable? tags = null, + System.Threading.CancellationToken cancellationToken = default) => throw null; + public System.Threading.Tasks.ValueTask GetOrCreateAsync( + ref System.Runtime.CompilerServices.DefaultInterpolatedStringHandler key, + TState state, + System.Func> factory, + Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions? options = null, + System.Collections.Generic.IEnumerable? tags = null, + System.Threading.CancellationToken cancellationToken = default) => throw null; } } diff --git a/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/CompatibilitySuppressions.xml b/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/CompatibilitySuppressions.xml new file mode 100644 index 00000000000000..9339604ec12072 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/CompatibilitySuppressions.xml @@ -0,0 +1,67 @@ + + + + + CP0002 + M:Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions.set_Expiration(System.Nullable{System.TimeSpan}) + lib/net10.0/Microsoft.Extensions.Caching.Abstractions.dll + lib/net10.0/Microsoft.Extensions.Caching.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions.set_Flags(System.Nullable{Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags}) + lib/net10.0/Microsoft.Extensions.Caching.Abstractions.dll + lib/net10.0/Microsoft.Extensions.Caching.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions.set_LocalCacheExpiration(System.Nullable{System.TimeSpan}) + lib/net10.0/Microsoft.Extensions.Caching.Abstractions.dll + lib/net10.0/Microsoft.Extensions.Caching.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions.set_Expiration(System.Nullable{System.TimeSpan}) + lib/net462/Microsoft.Extensions.Caching.Abstractions.dll + lib/net462/Microsoft.Extensions.Caching.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions.set_Flags(System.Nullable{Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags}) + lib/net462/Microsoft.Extensions.Caching.Abstractions.dll + lib/net462/Microsoft.Extensions.Caching.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions.set_LocalCacheExpiration(System.Nullable{System.TimeSpan}) + lib/net462/Microsoft.Extensions.Caching.Abstractions.dll + lib/net462/Microsoft.Extensions.Caching.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions.set_Expiration(System.Nullable{System.TimeSpan}) + lib/netstandard2.0/Microsoft.Extensions.Caching.Abstractions.dll + lib/netstandard2.0/Microsoft.Extensions.Caching.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions.set_Flags(System.Nullable{Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags}) + lib/netstandard2.0/Microsoft.Extensions.Caching.Abstractions.dll + lib/netstandard2.0/Microsoft.Extensions.Caching.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions.set_LocalCacheExpiration(System.Nullable{System.TimeSpan}) + lib/netstandard2.0/Microsoft.Extensions.Caching.Abstractions.dll + lib/netstandard2.0/Microsoft.Extensions.Caching.Abstractions.dll + true + + \ No newline at end of file diff --git a/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/Hybrid/HybridCache.cs b/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/Hybrid/HybridCache.cs index 7d93d1e45169d3..22198a5ab9b08b 100644 --- a/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/Hybrid/HybridCache.cs +++ b/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/Hybrid/HybridCache.cs @@ -141,6 +141,183 @@ public ValueTask GetOrCreateAsync( } #endif + /// + /// 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. + /// + /// The type of additional state required by . + /// The type of the data being considered. + /// The key of the entry to look for or create. + /// The state required for . + /// Provides the underlying data service if the data is not available in the cache. The passed to the factory is mutable and allows it to influence cache entry options based on the result. + /// Additional options for this cache entry. + /// The tags to associate with this cache item. + /// The used to propagate notifications that the operation should be canceled. + /// The data, either from cache or the underlying data service. + public virtual async ValueTask GetOrCreateAsync(string key, TState state, + Func> factory, + HybridCacheEntryOptions? options = null, IEnumerable? tags = null, CancellationToken cancellationToken = default) + { + var factoryOptions = options is null ? new HybridCacheEntryOptions() : options.Clone(); + + // Suppress writes in the inner call so we can perform a single, correct SetAsync afterwards + // using the options that the factory ultimately produced. + // This introduces some race conditions, but that's considered better than not honoring the factory's options. + var innerOptions = options is null ? new HybridCacheEntryOptions() : options.Clone(); + innerOptions.Flags = (innerOptions.Flags ?? HybridCacheEntryFlags.None) + | HybridCacheEntryFlags.DisableLocalCacheWrite + | HybridCacheEntryFlags.DisableDistributedCacheWrite; + + var packedState = new DefaultImplState(state, factory, factoryOptions); + + T value = await GetOrCreateAsync(key, packedState, static (packed, ct) => + { + packed.FactoryRan = true; + return packed.Factory(packed.State, packed.FactoryOptions, ct); + }, innerOptions, tags, cancellationToken).ConfigureAwait(false); + + // Only the stampede leader observes FactoryRan == true; followers reuse the leader's value + // (and the leader's SetAsync) without issuing a duplicate write. + if (packedState.FactoryRan) + { + const HybridCacheEntryFlags BothWritesDisabled = + HybridCacheEntryFlags.DisableLocalCacheWrite + | HybridCacheEntryFlags.DisableDistributedCacheWrite; + if ((factoryOptions.Flags & BothWritesDisabled) != BothWritesDisabled) + { + // Cancellation of the caller should not abort the write: the factory has already + // produced a value that we are about to return; canceling SetAsync here would + // discard a completed result and force the next caller to re-run the factory. + await SetAsync(key, value, factoryOptions, tags, CancellationToken.None).ConfigureAwait(false); + } + } + + return value; + } + + /// + /// 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. + /// + /// The type of the data being considered. + /// The key of the entry to look for or create. + /// Provides the underlying data service if the data is not available in the cache. The passed to the factory is mutable and allows it to influence cache entry options based on the result. + /// Additional options for this cache entry. + /// The tags to associate with this cache item. + /// The used to propagate notifications that the operation should be canceled. + /// The data, either from cache or the underlying data service. + public ValueTask GetOrCreateAsync(string key, + Func> factory, + HybridCacheEntryOptions? options = null, IEnumerable? tags = null, CancellationToken cancellationToken = default) + => GetOrCreateAsync(key, factory, WrappedOptionsCallbackCache.Instance, options, tags, cancellationToken); + +#if NET + /// + /// 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. + /// + /// The type of the data being considered. + /// The key of the entry to look for or create. + /// Provides the underlying data service if the data is not available in the cache. The passed to the factory is mutable and allows it to influence cache entry options based on the result. + /// Additional options for this cache entry. + /// The tags to associate with this cache item. + /// The used to propagate notifications that the operation should be canceled. + /// The data, either from cache or the underlying data service. + public ValueTask GetOrCreateAsync( + ReadOnlySpan key, + Func> factory, + HybridCacheEntryOptions? options = null, + IEnumerable? tags = null, + CancellationToken cancellationToken = default) + => GetOrCreateAsync(key, factory, WrappedOptionsCallbackCache.Instance, options, tags, cancellationToken); + + /// + /// 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. + /// + /// The type of additional state required by . + /// The type of the data being considered. + /// The key of the entry to look for or create. + /// The state required for . + /// Provides the underlying data service if the data is not available in the cache. The passed to the factory is mutable and allows it to influence cache entry options based on the result. + /// Additional options for this cache entry. + /// The tags to associate with this cache item. + /// The used to propagate notifications that the operation should be canceled. + /// The data, either from cache or the underlying data service. + public virtual ValueTask GetOrCreateAsync( + ReadOnlySpan key, + TState state, + Func> factory, + HybridCacheEntryOptions? options = null, + IEnumerable? tags = null, + CancellationToken cancellationToken = default) + => GetOrCreateAsync(key.ToString(), state, factory, options, tags, cancellationToken); + + /// + /// 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. + /// + /// The type of the data being considered. + /// The key of the entry to look for or create. + /// Provides the underlying data service if the data is not available in the cache. The passed to the factory is mutable and allows it to influence cache entry options based on the result. + /// Additional options for this cache entry. + /// The tags to associate with this cache item. + /// The used to propagate notifications that the operation should be canceled. + /// The data, either from cache or the underlying data service. + public ValueTask GetOrCreateAsync( + ref DefaultInterpolatedStringHandler key, + Func> factory, + HybridCacheEntryOptions? options = null, + IEnumerable? tags = null, + CancellationToken cancellationToken = default) + { + ValueTask result = GetOrCreateAsync(key.Text, factory, WrappedOptionsCallbackCache.Instance, options, tags, cancellationToken); + key.Clear(); + return result; + } + + /// + /// 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. + /// + /// The type of additional state required by . + /// The type of the data being considered. + /// The key of the entry to look for or create. + /// The state required for . + /// Provides the underlying data service if the data is not available in the cache. The passed to the factory is mutable and allows it to influence cache entry options based on the result. + /// Additional options for this cache entry. + /// The tags to associate with this cache item. + /// The used to propagate notifications that the operation should be canceled. + /// The data, either from cache or the underlying data service. + public ValueTask GetOrCreateAsync( + ref DefaultInterpolatedStringHandler key, + TState state, + Func> factory, + HybridCacheEntryOptions? options = null, + IEnumerable? tags = null, + CancellationToken cancellationToken = default) + { + ValueTask result = GetOrCreateAsync(key.Text, state, factory, options, tags, cancellationToken); + key.Clear(); + return result; + } +#endif + + private static class WrappedOptionsCallbackCache + { + public static readonly Func>, HybridCacheEntryOptions, CancellationToken, ValueTask> Instance = + static (callback, opts, ct) => callback(opts, ct); + } + + private sealed class DefaultImplState + { + public readonly TState State; + public readonly Func> Factory; + public readonly HybridCacheEntryOptions FactoryOptions; + public bool FactoryRan; + + public DefaultImplState(TState state, Func> factory, HybridCacheEntryOptions factoryOptions) + { + State = state; + Factory = factory; + FactoryOptions = factoryOptions; + } + } + private static class WrappedCallbackCache // per-T memoized helper that allows GetOrCreateAsync and GetOrCreateAsync 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 diff --git a/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/Hybrid/HybridCacheEntryOptions.cs b/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/Hybrid/HybridCacheEntryOptions.cs index f9708fdd6bd8f1..0efe7c2f0c9a1d 100644 --- a/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/Hybrid/HybridCacheEntryOptions.cs +++ b/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/Hybrid/HybridCacheEntryOptions.cs @@ -12,26 +12,125 @@ namespace Microsoft.Extensions.Caching.Hybrid; /// most granular non-null value is used, with null values being inherited. If no value is specified at /// any level, the implementation can choose a reasonable default. /// +/// +/// The properties on this type are mutable so that a factory callback supplied to +/// +/// can adjust them based on the result of the data fetch. Implementations can use +/// to detect whether the factory mutated the options. +/// public sealed class HybridCacheEntryOptions { + // memoize when possible; invalidated whenever Expiration changes + private DistributedCacheEntryOptions? _dc; + /// - /// Gets or set the overall cache duration of this entry, passed to the backend distributed cache. + /// Gets or sets the overall cache duration of this entry, passed to the backend distributed cache. /// - public TimeSpan? Expiration { get; init; } + public TimeSpan? Expiration + { + get; + set + { + if (field != value) + { + field = value; + _dc = null; + BumpRevision(); + } + } + } + /// + /// Gets or sets the expiration for the local (in-process) cache entry. + /// /// /// 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. /// - public TimeSpan? LocalCacheExpiration { get; init; } + public TimeSpan? LocalCacheExpiration + { + get; + set + { + if (field != value) + { + field = value; + BumpRevision(); + } + } + } /// /// Gets or sets additional flags that apply to the requested operation. /// - public HybridCacheEntryFlags? Flags { get; init; } + public HybridCacheEntryFlags? Flags + { + get; + set + { + if (field != value) + { + field = value; + BumpRevision(); + } + } + } - // memoize when possible - private DistributedCacheEntryOptions? _dc; + /// + /// Gets or sets the size to assign to entries in the local (in-process) cache. + /// + /// + /// The units are determined by the underlying local cache implementation. When the local cache + /// is an configured with a size limit, this value corresponds to + /// . + /// + /// When , the implementation may compute a default size (for example, from + /// the serialized payload length). + /// + /// + public long? LocalSize + { + get; + set + { + if (field != value) + { + field = value; + BumpRevision(); + } + } + } + + /// + /// Gets a value that increments whenever a property on this instance is changed. + /// + /// + /// Implementations of can capture this value before invoking a factory + /// callback and compare it afterwards to determine whether the factory mutated the options. + /// The value increments only when a setter assigns a different value than the current one. + /// + public int Revision { get; private set; } + + private void BumpRevision() + { + // unchecked: rollover should be fine because callers compare for inequality, not ordering + unchecked + { + Revision++; + } + } + + // DefaultHybridCache (in MS.E.Caching.Hybrid) uses these through UnsafeAccessor. + // Since this assembly (ME.E.Caching.Abstractions) is now part of the shared framework, + // it is effectively automatically updated when TFM is updated, so we shouldn't remove these methods. internal DistributedCacheEntryOptions? ToDistributedCacheEntryOptions() => Expiration is null ? null : (_dc ??= new() { AbsoluteExpirationRelativeToNow = Expiration }); + + internal HybridCacheEntryOptions Clone() + { + // shallow copy is sufficient: all settable state is value-typed and _dc is recomputable. + var clone = (HybridCacheEntryOptions)MemberwiseClone(); + clone._dc = null; + return clone; + } } diff --git a/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/ILLink/ILLink.Descriptors.LibraryBuild.xml b/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/ILLink/ILLink.Descriptors.LibraryBuild.xml new file mode 100644 index 00000000000000..3d01f85d20fa11 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/ILLink/ILLink.Descriptors.LibraryBuild.xml @@ -0,0 +1,9 @@ + + + + + + + + +