File: Hybrid\HybridCache.cs
Web Access
Project: src\runtime\src\libraries\Microsoft.Extensions.Caching.Abstractions\src\Microsoft.Extensions.Caching.Abstractions.csproj (Microsoft.Extensions.Caching.Abstractions)
// 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.Runtime.CompilerServices;
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 if the data is not available in the cache.</param>
    /// <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>
    public abstract ValueTask<T> GetOrCreateAsync<TState, T>(string key, TState state, Func<TState, CancellationToken, ValueTask<T>> factory,
        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 if 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>
    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);

#if NET
    /// <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 if 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>
    public ValueTask<T> GetOrCreateAsync<T>(
        ReadOnlySpan<char> 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);

    /// <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 if the data is not available in the cache.</param>
    /// <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>
    /// <remarks>Implementors may use the key span to attempt a local-cache synchronous 'get' without requiring the key as a <see cref="string"/>.</remarks>
    public virtual ValueTask<T> GetOrCreateAsync<TState, T>(
        ReadOnlySpan<char> key,
        TState state,
        Func<TState, CancellationToken, ValueTask<T>> factory,
        HybridCacheEntryOptions? options = null,
        IEnumerable<string>? tags = null,
        CancellationToken cancellationToken = default)
        => GetOrCreateAsync(key.ToString(), state, factory, options, tags, cancellationToken);

    /// <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 if 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>
    public ValueTask<T> GetOrCreateAsync<T>(
        ref DefaultInterpolatedStringHandler key,
        Func<CancellationToken, ValueTask<T>> factory,
        HybridCacheEntryOptions? options = null,
        IEnumerable<string>? tags = null,
        CancellationToken cancellationToken = default)
        => ClearAndReturn(ref key,
            GetOrCreateAsync(key.Text, factory, WrappedCallbackCache<T>.Instance, options, tags, cancellationToken));

    /// <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 if the data is not available in the cache.</param>
    /// <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>
    public ValueTask<T> GetOrCreateAsync<TState, T>(
        ref DefaultInterpolatedStringHandler key,
        TState state,
        Func<TState, CancellationToken, ValueTask<T>> factory,
        HybridCacheEntryOptions? options = null,
        IEnumerable<string>? tags = null,
        CancellationToken cancellationToken = default)
        => ClearAndReturn(ref key,
            GetOrCreateAsync(key.Text, state, factory, options, tags, cancellationToken));
#endif

    /// <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="state">The state required for <paramref name="factory"/>.</param>
    /// <param name="factory">Provides the underlying data service if the data is not available in the cache. The <see cref="HybridCacheEntryContext"/> passed to the factory allows it to influence cache entry options based on the result.</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>
    public virtual async ValueTask<T> GetOrCreateAsync<TState, T>(string key, TState state,
        Func<TState, HybridCacheEntryContext, CancellationToken, ValueTask<T>> factory,
        HybridCacheEntryOptions? options = null, IEnumerable<string>? 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<TState, T>(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,
        };
    }

    /// <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 if the data is not available in the cache. The <see cref="HybridCacheEntryContext"/> passed to the factory allows it to influence cache entry options based on the result.</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>
    public ValueTask<T> GetOrCreateAsync<T>(string key,
        Func<HybridCacheEntryContext, CancellationToken, ValueTask<T>> factory,
        HybridCacheEntryOptions? options = null, IEnumerable<string>? tags = null, CancellationToken cancellationToken = default)
        => GetOrCreateAsync(key, factory, WrappedOptionsCallbackCache<T>.Instance, options, tags, cancellationToken);

#if NET
    /// <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 if the data is not available in the cache. The <see cref="HybridCacheEntryContext"/> passed to the factory allows it to influence cache entry options based on the result.</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>
    public ValueTask<T> GetOrCreateAsync<T>(
        ReadOnlySpan<char> key,
        Func<HybridCacheEntryContext, CancellationToken, ValueTask<T>> factory,
        HybridCacheEntryOptions? options = null,
        IEnumerable<string>? tags = null,
        CancellationToken cancellationToken = default)
        => GetOrCreateAsync(key, factory, WrappedOptionsCallbackCache<T>.Instance, options, tags, cancellationToken);

    /// <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="state">The state required for <paramref name="factory"/>.</param>
    /// <param name="factory">Provides the underlying data service if the data is not available in the cache. The <see cref="HybridCacheEntryContext"/> passed to the factory allows it to influence cache entry options based on the result.</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>
    public virtual ValueTask<T> GetOrCreateAsync<TState, T>(
        ReadOnlySpan<char> key,
        TState state,
        Func<TState, HybridCacheEntryContext, CancellationToken, ValueTask<T>> factory,
        HybridCacheEntryOptions? options = null,
        IEnumerable<string>? tags = null,
        CancellationToken cancellationToken = default)
        => GetOrCreateAsync(key.ToString(), state, factory, options, tags, cancellationToken);

    /// <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 if the data is not available in the cache. The <see cref="HybridCacheEntryContext"/> passed to the factory allows it to influence cache entry options based on the result.</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>
    public ValueTask<T> GetOrCreateAsync<T>(
        ref DefaultInterpolatedStringHandler key,
        Func<HybridCacheEntryContext, CancellationToken, ValueTask<T>> factory,
        HybridCacheEntryOptions? options = null,
        IEnumerable<string>? tags = null,
        CancellationToken cancellationToken = default)
        => ClearAndReturn(ref key,
            GetOrCreateAsync(key.Text, factory, WrappedOptionsCallbackCache<T>.Instance, options, tags, cancellationToken));

    /// <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="state">The state required for <paramref name="factory"/>.</param>
    /// <param name="factory">Provides the underlying data service if the data is not available in the cache. The <see cref="HybridCacheEntryContext"/> passed to the factory allows it to influence cache entry options based on the result.</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>
    public ValueTask<T> GetOrCreateAsync<TState, T>(
        ref DefaultInterpolatedStringHandler key,
        TState state,
        Func<TState, HybridCacheEntryContext, CancellationToken, ValueTask<T>> factory,
        HybridCacheEntryOptions? options = null,
        IEnumerable<string>? 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<T> ClearAndReturn<T>(ref DefaultInterpolatedStringHandler key, ValueTask<T> result)
    {
        key.Clear();
        return result;
    }
#endif

    private static class WrappedOptionsCallbackCache<T>
    {
        public static readonly Func<Func<HybridCacheEntryContext, CancellationToken, ValueTask<T>>, HybridCacheEntryContext, CancellationToken, ValueTask<T>> Instance =
            static (callback, context, ct) => callback(context, ct);
    }

    private sealed class DefaultImplState<TState, T>
    {
        public readonly TState State;
        public readonly Func<TState, HybridCacheEntryContext, CancellationToken, ValueTask<T>> 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<TState, HybridCacheEntryContext, CancellationToken, ValueTask<T>> factory, HybridCacheEntryOptions? options)
        {
            State = state;
            Factory = factory;
            Options = options;
        }
    }

    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);
    }

    /// <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>
    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>
    public virtual ValueTask RemoveAsync(IEnumerable<string> keys, CancellationToken cancellationToken = default)
    {
        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.First(), cancellationToken),
            _ => 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);
            }
        }
    }

    /// <summary>
    /// Asynchronously removes all values associated with the specified tags.
    /// </summary>
    /// <remarks>Implementors should treat <c>null</c> as empty</remarks>
    public virtual ValueTask RemoveByTagAsync(IEnumerable<string> tags, CancellationToken cancellationToken = default)
    {
        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>
    public abstract ValueTask RemoveByTagAsync(string tag, CancellationToken cancellationToken = default);
}