diff --git a/src/libraries/Microsoft.Extensions.Caching.Abstractions/Microsoft.Extensions.Caching.Abstractions.slnx b/src/libraries/Microsoft.Extensions.Caching.Abstractions/Microsoft.Extensions.Caching.Abstractions.slnx index 56c41562fcc5c8..45e480394723de 100644 --- a/src/libraries/Microsoft.Extensions.Caching.Abstractions/Microsoft.Extensions.Caching.Abstractions.slnx +++ b/src/libraries/Microsoft.Extensions.Caching.Abstractions/Microsoft.Extensions.Caching.Abstractions.slnx @@ -11,6 +11,9 @@ + + + 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..430ddb7ee1fb18 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 @@ -175,11 +175,21 @@ public interface IHybridCacheSerializerFactory { bool TryCreateSerializer([System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out IHybridCacheSerializer? serializer); } + public sealed class HybridCacheEntryContext + { + public HybridCacheEntryContext(HybridCacheEntryOptions? options) { } + public System.TimeSpan? Expiration { get { throw null; } set { } } + public System.TimeSpan? LocalCacheExpiration { get { throw null; } set { } } + public HybridCacheEntryFlags? Flags { get { throw null; } set { } } + public long? LocalSize { get { throw null; } set { } } + public int Revision { get { throw null; } } + } public sealed class HybridCacheEntryOptions { public System.TimeSpan? Expiration { get; init; } public System.TimeSpan? LocalCacheExpiration { get; init; } public HybridCacheEntryFlags? Flags { get; init; } + public long? LocalSize { get; init; } } [System.Flags] public enum HybridCacheEntryFlags @@ -203,6 +213,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..3878de18ca6026 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/Hybrid/HybridCache.cs b/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/Hybrid/HybridCache.cs index 7d93d1e45169d3..b62dd67138f5d6 100644 --- a/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/Hybrid/HybridCache.cs +++ b/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/Hybrid/HybridCache.cs @@ -103,14 +103,8 @@ public ValueTask GetOrCreateAsync( HybridCacheEntryOptions? options = null, IEnumerable? tags = null, CancellationToken cancellationToken = default) - { - // It is *not* an error that this Clear occurs before the "await"; by definition, the implementation is *required* to copy - // the value locally before an await, precisely because the ref-struct cannot bridge an await. Thus: we are fine to clean - // the buffer even in the non-synchronous completion scenario. - ValueTask result = GetOrCreateAsync(key.Text, factory, WrappedCallbackCache.Instance, options, tags, cancellationToken); - key.Clear(); - return result; - } + => ClearAndReturn(ref key, + GetOrCreateAsync(key.Text, factory, WrappedCallbackCache.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. @@ -131,16 +125,224 @@ public ValueTask GetOrCreateAsync( HybridCacheEntryOptions? options = null, IEnumerable? tags = null, CancellationToken cancellationToken = default) + => ClearAndReturn(ref key, + GetOrCreateAsync(key.Text, state, factory, options, tags, cancellationToken)); +#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 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) + { + // Carries state + factory into the wrapped delegate and the context back out. The context is + // created lazily inside the delegate, so a cache hit (where the factory never runs) does not + // allocate a context or seed it. + var packedState = new DefaultImplState(state, factory, options); + + // 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. + // The common options == null case reuses a shared immutable instance instead of allocating one. + HybridCacheEntryOptions innerOptions = options is null + ? s_writesDisabled + : CloneWithWritesDisabled(options); + + T value = await GetOrCreateAsync(key, packedState, static (packed, ct) => + { + // Reached only on a cache miss (the underlying implementation skips this for hits and for + // stampede followers), so the context is allocated exactly when it is needed. + var context = new HybridCacheEntryContext(packed.Options); + packed.Context = context; + return packed.Factory(packed.State, context, ct); + }, innerOptions, tags, cancellationToken).ConfigureAwait(false); + + // Context is non-null only if the wrapped factory actually ran; stampede followers reuse the + // leader's value (and the leader's SetAsync) without issuing a duplicate write. + if (packedState.Context is { } context) + { + const HybridCacheEntryFlags BothWritesDisabled = + HybridCacheEntryFlags.DisableLocalCacheWrite + | HybridCacheEntryFlags.DisableDistributedCacheWrite; + if ((context.Flags & BothWritesDisabled) != BothWritesDisabled) + { + // If the factory left the context unchanged, reuse the caller's original options: this + // avoids materializing a new instance and preserves any special handling of a null value + // (context.ToOptions() would always produce a non-null instance). + HybridCacheEntryOptions? writeOptions = context.Revision == 0 + ? packedState.Options + : context.ToOptions(); + + // 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, writeOptions, tags, CancellationToken.None).ConfigureAwait(false); + } + } + + return value; + } + + // options == null always yields the same write-disabled options, so hoist it out of the per-call + // path. Safe to share because HybridCacheEntryOptions is immutable. + private static readonly HybridCacheEntryOptions s_writesDisabled = new() + { + Flags = HybridCacheEntryFlags.DisableLocalCacheWrite | HybridCacheEntryFlags.DisableDistributedCacheWrite, + }; + + private static HybridCacheEntryOptions CloneWithWritesDisabled(HybridCacheEntryOptions options) + { + HybridCacheEntryFlags flags = (options.Flags ?? HybridCacheEntryFlags.None) + | HybridCacheEntryFlags.DisableLocalCacheWrite + | HybridCacheEntryFlags.DisableDistributedCacheWrite; + return new HybridCacheEntryOptions + { + Expiration = options.Expiration, + LocalCacheExpiration = options.LocalCacheExpiration, + LocalSize = options.LocalSize, + Flags = flags, + }; + } + + /// + /// 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 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 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 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 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) + => ClearAndReturn(ref key, + GetOrCreateAsync(key.Text, 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 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) + => ClearAndReturn(ref key, + GetOrCreateAsync(key.Text, state, factory, options, tags, cancellationToken)); + + // It is *not* an error that this Clear occurs before the "await"; by definition, the implementation is *required* to copy + // the value locally before an await, precisely because the ref-struct cannot bridge an await. Thus: we are fine to clean + // the buffer even in the non-synchronous completion scenario. + private static ValueTask ClearAndReturn(ref DefaultInterpolatedStringHandler key, ValueTask result) { - // It is *not* an error that this Clear occurs before the "await"; by definition, the implementation is *required* to copy - // the value locally before an await, precisely because the ref-struct cannot bridge an await. Thus: we are fine to clean - // the buffer even in the non-synchronous completion scenario. - ValueTask result = GetOrCreateAsync(key.Text, state, factory, options, tags, cancellationToken); key.Clear(); return result; } #endif + private static class WrappedOptionsCallbackCache + { + public static readonly Func>, HybridCacheEntryContext, CancellationToken, ValueTask> Instance = + static (callback, context, ct) => callback(context, ct); + } + + private sealed class DefaultImplState + { + public readonly TState State; + public readonly Func> Factory; + public readonly HybridCacheEntryOptions? Options; + + // Set by the wrapped delegate when the factory runs; a non-null value signals a cache miss. + public HybridCacheEntryContext? Context; + + public DefaultImplState(TState state, Func> factory, HybridCacheEntryOptions? options) + { + State = state; + Factory = factory; + Options = options; + } + } + 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/HybridCacheEntryContext.cs b/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/Hybrid/HybridCacheEntryContext.cs new file mode 100644 index 00000000000000..40e217e3a74a90 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/Hybrid/HybridCacheEntryContext.cs @@ -0,0 +1,151 @@ +// 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; + +/// +/// Provides a mutable view of the cache entry options in effect for a single operation, +/// supplied to a factory callback so that it can adjust those options based on the result of the data fetch. +/// +/// +/// When a implementation constructs the context for a factory callback, it seeds the +/// properties from the effective options for the operation (composed from the per-call and any global options), so +/// the factory observes the current values and can change them to influence how the resulting value is cached. +/// A context constructed directly reflects only whatever were supplied to +/// its constructor. Implementations can use to detect whether the factory changed any value. +/// +public sealed class HybridCacheEntryContext +{ + /// + /// Initializes a new instance of the class, seeding its values from + /// the supplied options. + /// + /// + /// The options to seed the context from, or to start with unset values. This is + /// typically the effective options for the operation, so that the factory callback observes the current values. + /// + /// + /// This is primarily intended for implementations that need to construct a context + /// to pass to a factory callback. + /// + public HybridCacheEntryContext(HybridCacheEntryOptions? options) + { + if (options is not null) + { + Expiration = options.Expiration; + LocalCacheExpiration = options.LocalCacheExpiration; + Flags = options.Flags; + LocalSize = options.LocalSize; + } + + // Seeding from the supplied options is not a caller change, so the context starts at revision zero. + Revision = 0; + } + + /// + /// Gets or sets the overall cache duration of this entry, passed to the backend distributed cache. + /// + public TimeSpan? Expiration + { + get; + set + { + if (field != value) + { + field = value; + 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; + set + { + if (field != value) + { + field = value; + BumpRevision(); + } + } + } + + /// + /// Gets or sets additional flags that apply to the requested operation. + /// + public HybridCacheEntryFlags? Flags + { + get; + set + { + if (field != value) + { + field = value; + BumpRevision(); + } + } + } + + /// + /// 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 changed any value. + /// 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 is fine because callers compare for inequality, not ordering. + unchecked + { + Revision++; + } + } + + internal HybridCacheEntryOptions ToOptions() + => new HybridCacheEntryOptions + { + Expiration = Expiration, + LocalCacheExpiration = LocalCacheExpiration, + Flags = Flags, + LocalSize = LocalSize, + }; +} 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..2aea248f7d569b 100644 --- a/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/Hybrid/HybridCacheEntryOptions.cs +++ b/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/Hybrid/HybridCacheEntryOptions.cs @@ -12,13 +12,24 @@ 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. /// +/// +/// This type is immutable. To adjust cache entry options based on the result of a data fetch, use one of the +/// +/// overloads, whose factory callback receives a mutable . +/// public sealed class HybridCacheEntryOptions { + // memoize when possible; safe because this type is immutable. + 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; } + /// + /// 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. @@ -30,8 +41,23 @@ public sealed class HybridCacheEntryOptions /// public HybridCacheEntryFlags? Flags { get; init; } - // 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; init; } + + // DefaultHybridCache (in MS.E.Caching.Hybrid) uses this 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 this method. internal DistributedCacheEntryOptions? ToDistributedCacheEntryOptions() => Expiration is null ? null : (_dc ??= new() { AbsoluteExpirationRelativeToNow = Expiration }); } diff --git a/src/libraries/Microsoft.Extensions.Caching.Abstractions/tests/HybridCacheTests.cs b/src/libraries/Microsoft.Extensions.Caching.Abstractions/tests/HybridCacheTests.cs new file mode 100644 index 00000000000000..4f3f7801da6978 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Caching.Abstractions/tests/HybridCacheTests.cs @@ -0,0 +1,400 @@ +// 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.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +#if NET +using System.Runtime.CompilerServices; +#endif +using Xunit; + +namespace Microsoft.Extensions.Caching.Hybrid.Tests; + +/// +/// Contract tests for the default +/// implementation, exercised against a subclass that does not override the new virtual. +/// +/// The fake cache stores writes from any path (the abstract +/// override or ) into the same observable , +/// so the tests describe externally observable behavior and not the specific mechanism the default +/// implementation uses to update the cache. +/// +/// +public class HybridCacheTests +{ + [Fact] + public async Task ReturnsFactoryValue() + { + var cache = new FakeHybridCache(); + + int result = await cache.GetOrCreateAsync( + "k", state: 0, + factory: (_, _, _) => new ValueTask(42)); + + Assert.Equal(42, result); + } + + [Fact] + public async Task FactoryReceivesNonNullMutableContext() + { + var cache = new FakeHybridCache(); + HybridCacheEntryContext? observed = null; + + await cache.GetOrCreateAsync( + "k", state: 0, + factory: (_, ctx, _) => + { + observed = ctx; + ctx.Expiration = TimeSpan.FromMinutes(5); + return new ValueTask(1); + }); + + Assert.NotNull(observed); + Assert.Equal(TimeSpan.FromMinutes(5), observed!.Expiration); + } + + [Fact] + public async Task CallerOptions_AreNotMutated_ByFactory() + { + var cache = new FakeHybridCache(); + var callerOptions = new HybridCacheEntryOptions + { + Expiration = TimeSpan.FromMinutes(1), + Flags = HybridCacheEntryFlags.None, + }; + + await cache.GetOrCreateAsync( + "k", state: 0, + factory: (_, ctx, _) => + { + ctx.Expiration = TimeSpan.FromHours(1); + ctx.LocalSize = 12345; + ctx.Flags = HybridCacheEntryFlags.DisableCompression; + return new ValueTask(1); + }, + options: callerOptions); + + Assert.Equal(TimeSpan.FromMinutes(1), callerOptions.Expiration); + Assert.Null(callerOptions.LocalSize); + Assert.Equal(HybridCacheEntryFlags.None, callerOptions.Flags); + } + + [Fact] + public async Task FactoryMutations_AreVisibleToCache() + { + var cache = new FakeHybridCache(); + + await cache.GetOrCreateAsync( + "k", state: 0, + factory: (_, ctx, _) => + { + ctx.Expiration = TimeSpan.FromHours(2); + ctx.LocalSize = 999; + return new ValueTask(7); + }); + + StoredEntry entry = Assert.Contains("k", (IReadOnlyDictionary)cache.Store); + Assert.Equal(7, entry.Value); + Assert.NotNull(entry.Options); + Assert.Equal(TimeSpan.FromHours(2), entry.Options!.Expiration); + Assert.Equal(999, entry.Options.LocalSize); + } + + [Fact] + public async Task FactoryLeavesContextUnchanged_WritesWithOriginalOptions() + { + var cache = new FakeHybridCache(); + + // The factory does not touch the context, so the write should reuse the caller's original + // options (here null) rather than materializing a new all-null-valued instance. + await cache.GetOrCreateAsync( + "k", state: 0, + factory: (_, _, _) => new ValueTask(5)); + + StoredEntry entry = Assert.Contains("k", (IReadOnlyDictionary)cache.Store); + Assert.Equal(5, entry.Value); + Assert.Null(entry.Options); + } + + [Fact] + public async Task FactoryDisablingBothWrites_SuppressesCacheWrite() + { + var cache = new FakeHybridCache(); + + await cache.GetOrCreateAsync( + "k", state: 0, + factory: (_, ctx, _) => + { + ctx.Flags = HybridCacheEntryFlags.DisableLocalCacheWrite | HybridCacheEntryFlags.DisableDistributedCacheWrite; + return new ValueTask(1); + }); + + Assert.DoesNotContain("k", (IReadOnlyDictionary)cache.Store); + } + + [Fact] + public async Task FactoryDisablingOnlyOneWrite_StillWrites() + { + var cache = new FakeHybridCache(); + + await cache.GetOrCreateAsync( + "k", state: 0, + factory: (_, ctx, _) => + { + ctx.Flags = HybridCacheEntryFlags.DisableLocalCacheWrite; + return new ValueTask(1); + }); + + Assert.Contains("k", (IReadOnlyDictionary)cache.Store); + } + + [Fact] + public async Task FactoryNotInvoked_LeavesExistingEntryUntouched() + { + var cache = new FakeHybridCache(); + var preExistingOptions = new HybridCacheEntryOptions { Expiration = TimeSpan.FromMinutes(10) }; + cache.Store["k"] = new StoredEntry(17, preExistingOptions, Tags: null); + + int result = await cache.GetOrCreateAsync( + "k", state: 0, + factory: (_, _, _) => throw new Xunit.Sdk.XunitException("factory should not have run")); + + Assert.Equal(17, result); + // pre-existing entry should not be touched + Assert.Same(preExistingOptions, cache.Store["k"].Options); + } + + [Fact] + public async Task CancellingCallerToken_DoesNotPreventCacheWrite() + { + var cache = new FakeHybridCache(); + using var cts = new CancellationTokenSource(); + + await cache.GetOrCreateAsync( + "k", state: 0, + factory: (_, _, _) => + { + // cancel after the factory has produced a value, before the cache write runs + cts.Cancel(); + return new ValueTask(1); + }, + cancellationToken: cts.Token); + + // The fake skips any write whose CancellationToken is already canceled, so this + // assertion fails for any implementation that forwards the caller's token to the + // post-factory cache write. + Assert.Contains("k", (IReadOnlyDictionary)cache.Store); + } + + [Fact] + public async Task NoStateOverload_BehavesLikeStatefulOverload() + { + var cache = new FakeHybridCache(); + + int result = await cache.GetOrCreateAsync( + "k", + factory: (ctx, _) => + { + ctx.Expiration = TimeSpan.FromMinutes(3); + return new ValueTask(99); + }); + + Assert.Equal(99, result); + StoredEntry entry = cache.Store["k"]; + Assert.Equal(TimeSpan.FromMinutes(3), entry.Options!.Expiration); + } + + [Fact] + public async Task TagsArePropagatedToCacheWrite() + { + var cache = new FakeHybridCache(); + var tags = new[] { "a", "b" }; + + await cache.GetOrCreateAsync( + "k", state: 0, + factory: (_, _, _) => new ValueTask(1), + tags: tags); + + StoredEntry entry = cache.Store["k"]; + Assert.NotNull(entry.Tags); + Assert.Equal(tags, entry.Tags!.ToArray()); + } + +#if NET + [Fact] + public async Task ReadOnlySpanKey_FactoryMutationsReachCache() + { + var cache = new FakeHybridCache(); + HybridCacheEntryContext? observed = null; + + int result = await cache.GetOrCreateAsync( + "span-key".AsSpan(), state: 0, + factory: (_, ctx, _) => + { + observed = ctx; + ctx.Expiration = TimeSpan.FromMinutes(4); + ctx.LocalSize = 321; + return new ValueTask(55); + }); + + Assert.Equal(55, result); + Assert.NotNull(observed); + StoredEntry entry = cache.Store["span-key"]; + Assert.Equal(55, entry.Value); + Assert.Equal(TimeSpan.FromMinutes(4), entry.Options!.Expiration); + Assert.Equal(321, entry.Options.LocalSize); + } + + [Fact] + public async Task ReadOnlySpanKey_NoStateOverload_FactoryMutationsReachCache() + { + var cache = new FakeHybridCache(); + + int result = await cache.GetOrCreateAsync( + "span-key-nostate".AsSpan(), + factory: (ctx, _) => + { + ctx.Expiration = TimeSpan.FromMinutes(6); + return new ValueTask(77); + }); + + Assert.Equal(77, result); + StoredEntry entry = cache.Store["span-key-nostate"]; + Assert.Equal(77, entry.Value); + Assert.Equal(TimeSpan.FromMinutes(6), entry.Options!.Expiration); + } + + [Fact] + public async Task InterpolatedStringKey_FactoryMutationsReachCache_AndClearsHandler() + { + var cache = new FakeHybridCache(); + + // A DefaultInterpolatedStringHandler is a ref struct and cannot be a local in an async method, + // so the synchronous handler work — including asserting the overload cleared it — lives in a + // non-async local function that hands back the pending operation. + ValueTask pending = Invoke(cache); + int result = await pending; + + Assert.Equal(42, result); + StoredEntry entry = cache.Store["dish-key"]; + Assert.Equal(42, entry.Value); + Assert.Equal(TimeSpan.FromMinutes(9), entry.Options!.Expiration); + + static ValueTask Invoke(FakeHybridCache cache) + { + var key = new DefaultInterpolatedStringHandler(literalLength: 8, formattedCount: 0); + key.AppendLiteral("dish-key"); + + ValueTask pending = cache.GetOrCreateAsync( + ref key, state: 0, + factory: (_, ctx, _) => + { + ctx.Expiration = TimeSpan.FromMinutes(9); + return new ValueTask(42); + }); + + // key.Clear() must run before the overload returns, on every completion path (here the + // factory ran, so the underlying operation may complete asynchronously). + Assert.Equal(string.Empty, key.ToString()); + return pending; + } + } + + [Fact] + public async Task InterpolatedStringKey_ClearsHandler_OnSynchronousCacheHit() + { + var cache = new FakeHybridCache(); + cache.Store["dish-hit"] = new StoredEntry(123, Options: null, Tags: null); + + // A pre-existing entry means the factory is never invoked and the operation completes + // synchronously; the handler must still be cleared. + ValueTask pending = Invoke(cache); + int result = await pending; + + Assert.Equal(123, result); + + static ValueTask Invoke(FakeHybridCache cache) + { + var key = new DefaultInterpolatedStringHandler(literalLength: 8, formattedCount: 0); + key.AppendLiteral("dish-hit"); + + ValueTask pending = cache.GetOrCreateAsync( + ref key, state: 0, + factory: (_, _, _) => throw new Xunit.Sdk.XunitException("factory should not run on a cache hit")); + + Assert.Equal(string.Empty, key.ToString()); + return pending; + } + } +#endif + + private sealed record StoredEntry(object? Value, HybridCacheEntryOptions? Options, IEnumerable? Tags); + + /// + /// A minimal backed by an in-memory dictionary. Writes from either the + /// abstract + /// override or land in , so the test assertions + /// are agnostic to which mechanism the default implementation uses. + /// + private sealed class FakeHybridCache : HybridCache + { + public Dictionary Store { get; } = new(); + + public override ValueTask GetOrCreateAsync( + string key, TState state, Func> factory, + HybridCacheEntryOptions? options = null, IEnumerable? tags = null, + CancellationToken cancellationToken = default) + { + if (Store.TryGetValue(key, out StoredEntry? existing) && existing.Value is T cached) + { + return new ValueTask(cached); + } + + return InvokeAndStoreAsync(key, state, factory, options, tags, cancellationToken); + } + + private async ValueTask InvokeAndStoreAsync( + string key, TState state, Func> factory, + HybridCacheEntryOptions? options, IEnumerable? tags, CancellationToken cancellationToken) + { + T value = await factory(state, cancellationToken).ConfigureAwait(false); + + const HybridCacheEntryFlags BothWritesDisabled = + HybridCacheEntryFlags.DisableLocalCacheWrite | HybridCacheEntryFlags.DisableDistributedCacheWrite; + HybridCacheEntryFlags flags = options?.Flags ?? HybridCacheEntryFlags.None; + if ((flags & BothWritesDisabled) != BothWritesDisabled) + { + TryWrite(key, value, options, tags, cancellationToken); + } + + return value; + } + + public override ValueTask SetAsync( + string key, T value, HybridCacheEntryOptions? options = null, + IEnumerable? tags = null, CancellationToken cancellationToken = default) + { + TryWrite(key, value, options, tags, cancellationToken); + return default; + } + + public override ValueTask RemoveAsync(string key, CancellationToken cancellationToken = default) => default; + + public override ValueTask RemoveByTagAsync(string tag, CancellationToken cancellationToken = default) => default; + + // Writes that arrive with an already-canceled token are dropped, so the test for + // "caller cancellation must not abort the post-factory write" actually has teeth. + private void TryWrite(string key, object? value, HybridCacheEntryOptions? options, IEnumerable? tags, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + return; + } + + Store[key] = new StoredEntry(value, options, tags); + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Caching.Abstractions/tests/Microsoft.Extensions.Caching.Abstractions.Tests.csproj b/src/libraries/Microsoft.Extensions.Caching.Abstractions/tests/Microsoft.Extensions.Caching.Abstractions.Tests.csproj new file mode 100644 index 00000000000000..897dba6387fdc2 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Caching.Abstractions/tests/Microsoft.Extensions.Caching.Abstractions.Tests.csproj @@ -0,0 +1,13 @@ + + + + $(NetCoreAppCurrent);$(NetFrameworkCurrent) + true + + + + + + + +