diff --git a/src/Caching/Caching.slnf b/src/Caching/Caching.slnf
index ebc2330a777e..dcecdb8a91c7 100644
--- a/src/Caching/Caching.slnf
+++ b/src/Caching/Caching.slnf
@@ -5,7 +5,8 @@
"src\\Caching\\SqlServer\\src\\Microsoft.Extensions.Caching.SqlServer.csproj",
"src\\Caching\\SqlServer\\test\\Microsoft.Extensions.Caching.SqlServer.Tests.csproj",
"src\\Caching\\StackExchangeRedis\\src\\Microsoft.Extensions.Caching.StackExchangeRedis.csproj",
- "src\\Caching\\StackExchangeRedis\\test\\Microsoft.Extensions.Caching.StackExchangeRedis.Tests.csproj"
+ "src\\Caching\\StackExchangeRedis\\test\\Microsoft.Extensions.Caching.StackExchangeRedis.Tests.csproj",
+ "src\\Middleware\\OutputCaching\\src\\Microsoft.AspNetCore.OutputCaching.csproj"
]
}
-}
+}
\ No newline at end of file
diff --git a/src/Caching/StackExchangeRedis/src/Microsoft.Extensions.Caching.StackExchangeRedis.csproj b/src/Caching/StackExchangeRedis/src/Microsoft.Extensions.Caching.StackExchangeRedis.csproj
index b73694371d1e..b5a3d703a4ab 100644
--- a/src/Caching/StackExchangeRedis/src/Microsoft.Extensions.Caching.StackExchangeRedis.csproj
+++ b/src/Caching/StackExchangeRedis/src/Microsoft.Extensions.Caching.StackExchangeRedis.csproj
@@ -13,14 +13,19 @@
+
+
+
+
+
diff --git a/src/Caching/StackExchangeRedis/src/PublicAPI.Shipped.txt b/src/Caching/StackExchangeRedis/src/PublicAPI/net462/PublicAPI.Shipped.txt
similarity index 100%
rename from src/Caching/StackExchangeRedis/src/PublicAPI.Shipped.txt
rename to src/Caching/StackExchangeRedis/src/PublicAPI/net462/PublicAPI.Shipped.txt
diff --git a/src/Caching/StackExchangeRedis/src/PublicAPI.Unshipped.txt b/src/Caching/StackExchangeRedis/src/PublicAPI/net462/PublicAPI.Unshipped.txt
similarity index 100%
rename from src/Caching/StackExchangeRedis/src/PublicAPI.Unshipped.txt
rename to src/Caching/StackExchangeRedis/src/PublicAPI/net462/PublicAPI.Unshipped.txt
diff --git a/src/Caching/StackExchangeRedis/src/PublicAPI/net8.0/PublicAPI.Shipped.txt b/src/Caching/StackExchangeRedis/src/PublicAPI/net8.0/PublicAPI.Shipped.txt
new file mode 100644
index 000000000000..bb997fdc8643
--- /dev/null
+++ b/src/Caching/StackExchangeRedis/src/PublicAPI/net8.0/PublicAPI.Shipped.txt
@@ -0,0 +1,26 @@
+#nullable enable
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache.Dispose() -> void
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache.Get(string! key) -> byte[]?
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache.GetAsync(string! key, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache.RedisCache(Microsoft.Extensions.Options.IOptions! optionsAccessor) -> void
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache.Refresh(string! key) -> void
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache.RefreshAsync(string! key, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache.Remove(string! key) -> void
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache.RemoveAsync(string! key, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache.Set(string! key, byte[]! value, Microsoft.Extensions.Caching.Distributed.DistributedCacheEntryOptions! options) -> void
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache.SetAsync(string! key, byte[]! value, Microsoft.Extensions.Caching.Distributed.DistributedCacheEntryOptions! options, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.Configuration.get -> string?
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.Configuration.set -> void
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.ConfigurationOptions.get -> StackExchange.Redis.ConfigurationOptions?
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.ConfigurationOptions.set -> void
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.ConnectionMultiplexerFactory.get -> System.Func!>?
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.ConnectionMultiplexerFactory.set -> void
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.InstanceName.get -> string?
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.InstanceName.set -> void
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.ProfilingSession.get -> System.Func?
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.ProfilingSession.set -> void
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.RedisCacheOptions() -> void
+Microsoft.Extensions.DependencyInjection.StackExchangeRedisCacheServiceCollectionExtensions
+static Microsoft.Extensions.DependencyInjection.StackExchangeRedisCacheServiceCollectionExtensions.AddStackExchangeRedisCache(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action! setupAction) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
diff --git a/src/Caching/StackExchangeRedis/src/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/src/Caching/StackExchangeRedis/src/PublicAPI/net8.0/PublicAPI.Unshipped.txt
new file mode 100644
index 000000000000..a38dbc1bfe7b
--- /dev/null
+++ b/src/Caching/StackExchangeRedis/src/PublicAPI/net8.0/PublicAPI.Unshipped.txt
@@ -0,0 +1,2 @@
+#nullable enable
+static Microsoft.Extensions.DependencyInjection.StackExchangeRedisCacheServiceCollectionExtensions.AddStackExchangeRedisOutputCache(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action! setupAction) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
diff --git a/src/Caching/StackExchangeRedis/src/PublicAPI/netstandard2.0/PublicAPI.Shipped.txt b/src/Caching/StackExchangeRedis/src/PublicAPI/netstandard2.0/PublicAPI.Shipped.txt
new file mode 100644
index 000000000000..bb997fdc8643
--- /dev/null
+++ b/src/Caching/StackExchangeRedis/src/PublicAPI/netstandard2.0/PublicAPI.Shipped.txt
@@ -0,0 +1,26 @@
+#nullable enable
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache.Dispose() -> void
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache.Get(string! key) -> byte[]?
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache.GetAsync(string! key, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache.RedisCache(Microsoft.Extensions.Options.IOptions! optionsAccessor) -> void
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache.Refresh(string! key) -> void
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache.RefreshAsync(string! key, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache.Remove(string! key) -> void
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache.RemoveAsync(string! key, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache.Set(string! key, byte[]! value, Microsoft.Extensions.Caching.Distributed.DistributedCacheEntryOptions! options) -> void
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache.SetAsync(string! key, byte[]! value, Microsoft.Extensions.Caching.Distributed.DistributedCacheEntryOptions! options, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.Configuration.get -> string?
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.Configuration.set -> void
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.ConfigurationOptions.get -> StackExchange.Redis.ConfigurationOptions?
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.ConfigurationOptions.set -> void
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.ConnectionMultiplexerFactory.get -> System.Func!>?
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.ConnectionMultiplexerFactory.set -> void
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.InstanceName.get -> string?
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.InstanceName.set -> void
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.ProfilingSession.get -> System.Func?
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.ProfilingSession.set -> void
+Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions.RedisCacheOptions() -> void
+Microsoft.Extensions.DependencyInjection.StackExchangeRedisCacheServiceCollectionExtensions
+static Microsoft.Extensions.DependencyInjection.StackExchangeRedisCacheServiceCollectionExtensions.AddStackExchangeRedisCache(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action! setupAction) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
diff --git a/src/Caching/StackExchangeRedis/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/src/Caching/StackExchangeRedis/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt
new file mode 100644
index 000000000000..7dc5c58110bf
--- /dev/null
+++ b/src/Caching/StackExchangeRedis/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt
@@ -0,0 +1 @@
+#nullable enable
diff --git a/src/Caching/StackExchangeRedis/src/RedisOutputCacheStore.cs b/src/Caching/StackExchangeRedis/src/RedisOutputCacheStore.cs
new file mode 100644
index 000000000000..5e4905c0cc05
--- /dev/null
+++ b/src/Caching/StackExchangeRedis/src/RedisOutputCacheStore.cs
@@ -0,0 +1,497 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#if NET7_0_OR_GREATER // IOutputCacheStore only exists from net7
+
+using System;
+using System.Buffers;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Globalization;
+using System.IO.Pipelines;
+using System.Net.Sockets;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.OutputCaching;
+using Microsoft.AspNetCore.Shared;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using StackExchange.Redis;
+
+namespace Microsoft.Extensions.Caching.StackExchangeRedis;
+
+internal class RedisOutputCacheStore : IOutputCacheStore, IOutputCacheBufferStore, IDisposable
+{
+ private readonly RedisCacheOptions _options;
+ private readonly ILogger _logger;
+ private readonly RedisKey _valueKeyPrefix;
+ private readonly RedisKey _tagKeyPrefix;
+ private readonly RedisKey _tagMasterKey;
+ private readonly RedisKey[] _tagMasterKeyArray; // for use with Lua if needed (to avoid array allocs)
+ private readonly SemaphoreSlim _connectionLock = new SemaphoreSlim(initialCount: 1, maxCount: 1);
+ private readonly CancellationTokenSource _disposalCancellation = new();
+
+ private bool _disposed;
+ private volatile IDatabase? _cache;
+ private long _lastConnectTicks = DateTimeOffset.UtcNow.Ticks;
+ private long _firstErrorTimeTicks;
+ private long _previousErrorTimeTicks;
+ private bool _useMultiExec, _use62Features;
+
+ internal bool GarbageCollectionEnabled { get; set; } = true;
+
+ // Never reconnect within 60 seconds of the last attempt to connect or reconnect.
+ private readonly TimeSpan ReconnectMinInterval = TimeSpan.FromSeconds(60);
+ // Only reconnect if errors have occurred for at least the last 30 seconds.
+ // This count resets if there are no errors for 30 seconds
+ private readonly TimeSpan ReconnectErrorThreshold = TimeSpan.FromSeconds(30);
+
+ ///
+ /// Initializes a new instance of .
+ ///
+ /// The configuration options.
+ public RedisOutputCacheStore(IOptions optionsAccessor) // TODO: OC-specific options?
+ : this(optionsAccessor, Logging.Abstractions.NullLoggerFactory.Instance.CreateLogger())
+ {
+ }
+
+#if DEBUG
+ internal async ValueTask GetConfigurationInfoAsync(CancellationToken cancellationToken = default)
+ {
+ await ConnectAsync(cancellationToken).ConfigureAwait(false);
+ return $"redis output-cache; MULTI/EXEC: {_useMultiExec}, v6.2+: {_use62Features}";
+ }
+#endif
+
+ ///
+ /// Initializes a new instance of .
+ ///
+ /// The configuration options.
+ /// The logger.
+ internal RedisOutputCacheStore(IOptions optionsAccessor, ILogger logger)
+ {
+ ArgumentNullThrowHelper.ThrowIfNull(optionsAccessor);
+ ArgumentNullThrowHelper.ThrowIfNull(logger);
+
+ _options = optionsAccessor.Value;
+ _logger = logger;
+
+ // This allows partitioning a single backend cache for use with multiple apps/services.
+
+ // SE.Redis allows efficient append of key-prefix scenarios, but we can help it
+ // avoid some work/allocations by forcing the key-prefix to be a byte[]; SE.Redis
+ // would do this itself anyway, using UTF8
+ _valueKeyPrefix = (RedisKey)Encoding.UTF8.GetBytes(_options.InstanceName + "__MSOCV_");
+ _tagKeyPrefix = (RedisKey)Encoding.UTF8.GetBytes(_options.InstanceName + "__MSOCT_");
+ _tagMasterKey = (RedisKey)Encoding.UTF8.GetBytes(_options.InstanceName + "__MSOCT");
+ _tagMasterKeyArray = new[] { _tagMasterKey };
+
+ _ = Task.Factory.StartNew(RunGarbageCollectionLoopAsync, default, TaskCreationOptions.LongRunning, TaskScheduler.Current);
+ }
+
+ private async Task RunGarbageCollectionLoopAsync()
+ {
+ try
+ {
+ while (!Volatile.Read(ref _disposed))
+ {
+ // approx every 5 minutes, with some randomization to prevent spikes of pile-on
+ var secondsWithJitter = 300 + Random.Shared.Next(-30, 30);
+ Debug.Assert(secondsWithJitter >= 270 && secondsWithJitter <= 330);
+ await Task.Delay(TimeSpan.FromSeconds(secondsWithJitter)).ConfigureAwait(false);
+ try
+ {
+ if (GarbageCollectionEnabled)
+ {
+ await ExecuteGarbageCollectionAsync(GetExpirationTimestamp(TimeSpan.Zero), _disposalCancellation.Token).ConfigureAwait(false);
+ }
+ }
+ catch (OperationCanceledException) when (_disposed)
+ {
+ // fine, service exiting
+ }
+ catch (Exception ex)
+ {
+ // this sweep failed; log it
+ _logger.LogDebug(ex, "Transient error occurred executing redis output-cache GC loop");
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ // the entire loop is dead
+ _logger.LogDebug(ex, "Fatal error occurred executing redis output-cache GC loop");
+ }
+ }
+
+ internal async ValueTask ExecuteGarbageCollectionAsync(long keepValuesGreaterThan, CancellationToken cancellationToken = default)
+ {
+ var cache = await ConnectAsync(CancellationToken.None).ConfigureAwait(false);
+
+ var gcKey = _tagMasterKey.Append("GC");
+ var gcLifetime = TimeSpan.FromMinutes(5);
+ // value is purely placeholder; it is the existence that matters
+ if (!await cache.StringSetAsync(gcKey, DateTime.UtcNow.ToString(CultureInfo.InvariantCulture), gcLifetime, when: When.NotExists).ConfigureAwait(false))
+ {
+ return null; // competition from another node; not even "nothing"
+ }
+ try
+ {
+ // we'll rely on the enumeration of ZSCAN to spot connection failures, and use "best efforts"
+ // on the individual operations - this avoids per-call latency
+ const CommandFlags GarbageCollectionFlags = CommandFlags.FireAndForget;
+
+ // the score is the effective timeout, so we simply need to cull everything with scores below "cull",
+ // for the individual tag sorted-sets, and also the master sorted-set
+ const int EXTEND_EVERY = 250; // some non-trivial number of work
+ int extendCountdown = EXTEND_EVERY;
+ await foreach (var entry in cache.SortedSetScanAsync(_tagMasterKey).WithCancellation(cancellationToken))
+ {
+ await cache.SortedSetRemoveRangeByScoreAsync(GetTagKey((string)entry.Element!), start: 0, stop: keepValuesGreaterThan, flags: GarbageCollectionFlags).ConfigureAwait(false);
+ if (--extendCountdown <= 0)
+ {
+ await cache.KeyExpireAsync(gcKey, gcLifetime).ConfigureAwait(false);
+ extendCountdown = EXTEND_EVERY;
+ }
+ }
+ // paying latency on the final master-tag purge: is fine
+ return await cache.SortedSetRemoveRangeByScoreAsync(_tagMasterKey, start: 0, stop: keepValuesGreaterThan).ConfigureAwait(false);
+ }
+ finally
+ {
+ await cache.KeyDeleteAsync(gcKey, CommandFlags.FireAndForget).ConfigureAwait(false);
+ }
+ }
+
+ async ValueTask IOutputCacheStore.EvictByTagAsync(string tag, CancellationToken cancellationToken)
+ {
+ var cache = await ConnectAsync(cancellationToken).ConfigureAwait(false);
+ Debug.Assert(cache is not null);
+
+ // we'll use fire-and-forget on individual deletes, relying on the paging mechanism
+ // of ZSCAN to detect fundamental connection problems - so failure will still be reported
+ const CommandFlags DeleteFlags = CommandFlags.FireAndForget;
+
+ var tagKey = GetTagKey(tag);
+ await foreach (var entry in cache.SortedSetScanAsync(tagKey).WithCancellation(cancellationToken))
+ {
+ await cache.KeyDeleteAsync(GetValueKey((string)entry.Element!), DeleteFlags).ConfigureAwait(false);
+ await cache.SortedSetRemoveAsync(tagKey, entry.Element, DeleteFlags).ConfigureAwait(false);
+ }
+ }
+
+ private RedisKey GetValueKey(string key)
+ => _valueKeyPrefix.Append(key);
+
+ private RedisKey GetTagKey(string tag)
+ => _tagKeyPrefix.Append(tag);
+
+ async ValueTask IOutputCacheStore.GetAsync(string key, CancellationToken cancellationToken)
+ {
+ ArgumentNullThrowHelper.ThrowIfNull(key);
+
+ var cache = await ConnectAsync(cancellationToken).ConfigureAwait(false);
+ Debug.Assert(cache is not null);
+
+ try
+ {
+ return (byte[]?)(await cache.StringGetAsync(GetValueKey(key)).ConfigureAwait(false));
+ }
+ catch (Exception ex)
+ {
+ OnRedisError(ex, cache);
+ throw;
+ }
+ }
+
+ async ValueTask IOutputCacheBufferStore.TryGetAsync(string key, PipeWriter destination, CancellationToken cancellationToken)
+ {
+ ArgumentNullThrowHelper.ThrowIfNull(key);
+ ArgumentNullThrowHelper.ThrowIfNull(destination);
+
+ var cache = await ConnectAsync(cancellationToken).ConfigureAwait(false);
+ Debug.Assert(cache is not null);
+
+ Lease? result = null;
+ try
+ {
+ result = await cache.StringGetLeaseAsync(GetValueKey(key)).ConfigureAwait(false);
+ if (result is null)
+ {
+ return false;
+ }
+
+ // future implementation will pass PipeWriter all the way down through redis,
+ // to allow end-to-end back-pressure; new SE.Redis API required
+ destination.Write(result.Span);
+ await destination.FlushAsync(cancellationToken).ConfigureAwait(false);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ result?.Dispose();
+ OnRedisError(ex, cache);
+ throw;
+ }
+ }
+
+ ValueTask IOutputCacheStore.SetAsync(string key, byte[] value, string[]? tags, TimeSpan validFor, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ return ((IOutputCacheBufferStore)this).SetAsync(key, new ReadOnlySequence(value), tags.AsMemory(), validFor, cancellationToken);
+ }
+
+ async ValueTask IOutputCacheBufferStore.SetAsync(string key, ReadOnlySequence value, ReadOnlyMemory tags, TimeSpan validFor, CancellationToken cancellationToken)
+ {
+ var cache = await ConnectAsync(cancellationToken).ConfigureAwait(false);
+ Debug.Assert(cache is not null);
+
+ byte[]? leased = null;
+ ReadOnlyMemory singleChunk;
+ if (value.IsSingleSegment)
+ {
+ singleChunk = value.First;
+ }
+ else
+ {
+ int len = checked((int)value.Length);
+ leased = ArrayPool.Shared.Rent(len);
+ value.CopyTo(leased);
+ singleChunk = new(leased, 0, len);
+ }
+
+ await cache.StringSetAsync(GetValueKey(key), singleChunk, validFor).ConfigureAwait(false);
+ // only return lease on success
+ if (leased is not null)
+ {
+ ArrayPool.Shared.Return(leased);
+ }
+
+ if (!tags.IsEmpty)
+ {
+ long expiryTimestamp = GetExpirationTimestamp(validFor);
+ var len = tags.Length;
+
+ // tags are secondary; to avoid latency costs, we'll use fire-and-forget when adding tags - this does
+ // mean that in theory tag-related error may go undetected, but: this is an acceptable trade-off
+ const CommandFlags TagCommandFlags = CommandFlags.FireAndForget;
+
+ for (var i = 0; i < len; i++) // can't use span in async method, so: eat a little overhead here
+ {
+ var tag = tags.Span[i];
+ if (_use62Features)
+ {
+ await cache.SortedSetAddAsync(_tagMasterKey, tag, expiryTimestamp, SortedSetWhen.GreaterThan, TagCommandFlags).ConfigureAwait(false);
+ }
+ else
+ {
+ // semantic equivalent of ZADD GT
+ const string ZADD_GT = """
+ local oldScore = tonumber(redis.call('ZSCORE', KEYS[1], ARGV[2]))
+ if oldScore == nil or oldScore < tonumber(ARGV[1]) then
+ redis.call('ZADD', KEYS[1], ARGV[1], ARGV[2])
+ end
+ """;
+
+ // note we're not sharing an ARGV array between tags here because then we'd need to wait on latency to avoid conflicts;
+ // in reality most caches have very limited tags (if any), so this is not perceived as an issue
+ await cache.ScriptEvaluateAsync(ZADD_GT, _tagMasterKeyArray, new RedisValue[] { expiryTimestamp, tag }, TagCommandFlags).ConfigureAwait(false);
+ }
+ await cache.SortedSetAddAsync(GetTagKey(tag), key, expiryTimestamp, SortedSetWhen.Always, TagCommandFlags).ConfigureAwait(false);
+ }
+ }
+ }
+
+ // note that by necessity we're interleaving two time systems here; the local time, and the
+ // time according to redis (and used internally for redis TTLs); in reality, if we disagree
+ // on time, we have bigger problems, so: this will have to suffice - we cannot reasonably
+ // push our in-proc time into out-of-proc redis
+ // TODO: TimeProvider? ISystemClock?
+ private static long GetExpirationTimestamp(TimeSpan timeout) =>
+ (long)((DateTime.UtcNow + timeout) - DateTime.UnixEpoch).TotalMilliseconds;
+
+ private ValueTask ConnectAsync(CancellationToken token = default)
+ {
+ CheckDisposed();
+ token.ThrowIfCancellationRequested();
+
+ var cache = _cache;
+ if (cache is not null)
+ {
+ return new(cache);
+ }
+ return ConnectSlowAsync(token);
+ }
+ private async ValueTask ConnectSlowAsync(CancellationToken token)
+ {
+ await _connectionLock.WaitAsync(token).ConfigureAwait(false);
+ try
+ {
+ var cache = _cache;
+ if (cache is null)
+ {
+ IConnectionMultiplexer connection;
+ if (_options.ConnectionMultiplexerFactory is null)
+ {
+ if (_options.ConfigurationOptions is not null)
+ {
+ connection = await ConnectionMultiplexer.ConnectAsync(_options.ConfigurationOptions).ConfigureAwait(false);
+ }
+ else
+ {
+ connection = await ConnectionMultiplexer.ConnectAsync(_options.Configuration!).ConfigureAwait(false);
+ }
+ }
+ else
+ {
+ connection = await _options.ConnectionMultiplexerFactory().ConfigureAwait(false);
+ }
+
+ PrepareConnection(connection);
+ cache = _cache = connection.GetDatabase();
+ }
+ Debug.Assert(_cache is not null);
+ return cache;
+ }
+ finally
+ {
+ _connectionLock.Release();
+ }
+ }
+
+ ///
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+ _disposed = true;
+ _disposalCancellation.Cancel();
+ ReleaseConnection(Interlocked.Exchange(ref _cache, null));
+ }
+
+ private void OnRedisError(Exception exception, IDatabase cache)
+ {
+ if (_options.UseForceReconnect && (exception is RedisConnectionException or SocketException))
+ {
+ var utcNow = DateTimeOffset.UtcNow;
+ var previousConnectTime = ReadTimeTicks(ref _lastConnectTicks);
+ TimeSpan elapsedSinceLastReconnect = utcNow - previousConnectTime;
+
+ // We want to limit how often we perform this top-level reconnect, so we check how long it's been since our last attempt.
+ if (elapsedSinceLastReconnect < ReconnectMinInterval)
+ {
+ return;
+ }
+
+ var firstErrorTime = ReadTimeTicks(ref _firstErrorTimeTicks);
+ if (firstErrorTime == DateTimeOffset.MinValue)
+ {
+ // note: order/timing here (between the two fields) is not critical
+ WriteTimeTicks(ref _firstErrorTimeTicks, utcNow);
+ WriteTimeTicks(ref _previousErrorTimeTicks, utcNow);
+ return;
+ }
+
+ TimeSpan elapsedSinceFirstError = utcNow - firstErrorTime;
+ TimeSpan elapsedSinceMostRecentError = utcNow - ReadTimeTicks(ref _previousErrorTimeTicks);
+
+ bool shouldReconnect =
+ elapsedSinceFirstError >= ReconnectErrorThreshold // Make sure we gave the multiplexer enough time to reconnect on its own if it could.
+ && elapsedSinceMostRecentError <= ReconnectErrorThreshold; // Make sure we aren't working on stale data (e.g. if there was a gap in errors, don't reconnect yet).
+
+ // Update the previousErrorTime timestamp to be now (e.g. this reconnect request).
+ WriteTimeTicks(ref _previousErrorTimeTicks, utcNow);
+
+ if (!shouldReconnect)
+ {
+ return;
+ }
+
+ WriteTimeTicks(ref _firstErrorTimeTicks, DateTimeOffset.MinValue);
+ WriteTimeTicks(ref _previousErrorTimeTicks, DateTimeOffset.MinValue);
+
+ // wipe the shared field, but *only* if it is still the cache we were
+ // thinking about (once it is null, the next caller will reconnect)
+ ReleaseConnection(Interlocked.CompareExchange(ref _cache, null, cache));
+ }
+ }
+
+ private void PrepareConnection(IConnectionMultiplexer connection)
+ {
+ WriteTimeTicks(ref _lastConnectTicks, DateTimeOffset.UtcNow);
+ ValidateServerFeatures(connection);
+ TryRegisterProfiler(connection);
+ }
+
+ private void ValidateServerFeatures(IConnectionMultiplexer connection)
+ {
+ int serverCount = 0, standaloneCount = 0, v62_Count = 0;
+ foreach (var ep in connection.GetEndPoints())
+ {
+ var server = connection.GetServer(ep);
+ if (server is null)
+ {
+ continue; // wat?
+ }
+ serverCount++;
+ if (server.ServerType == ServerType.Standalone)
+ {
+ standaloneCount++;
+ }
+ if (server.Features.SortedSetRangeStore) // just a random v6.2 feature
+ {
+ v62_Count++;
+ }
+ }
+ _useMultiExec = serverCount == standaloneCount;
+ _use62Features = serverCount == v62_Count;
+ }
+
+ private void TryRegisterProfiler(IConnectionMultiplexer connection)
+ {
+ _ = connection ?? throw new InvalidOperationException($"{nameof(connection)} cannot be null.");
+
+ if (_options.ProfilingSession is not null)
+ {
+ connection.RegisterProfiler(_options.ProfilingSession);
+ }
+ }
+
+ private static void WriteTimeTicks(ref long field, DateTimeOffset value)
+ {
+ var ticks = value == DateTimeOffset.MinValue ? 0L : value.UtcTicks;
+ Volatile.Write(ref field, ticks); // avoid torn values
+ }
+
+ private void CheckDisposed()
+ {
+ ObjectDisposedThrowHelper.ThrowIf(_disposed, this);
+ }
+
+ private static DateTimeOffset ReadTimeTicks(ref long field)
+ {
+ var ticks = Volatile.Read(ref field); // avoid torn values
+ return ticks == 0 ? DateTimeOffset.MinValue : new DateTimeOffset(ticks, TimeSpan.Zero);
+ }
+
+ static void ReleaseConnection(IDatabase? cache)
+ {
+ var connection = cache?.Multiplexer;
+ if (connection is not null)
+ {
+ try
+ {
+ connection.Close();
+ connection.Dispose();
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine(ex);
+ }
+ }
+ }
+}
+#endif
diff --git a/src/Caching/StackExchangeRedis/src/RedisOutputCacheStoreImpl.cs b/src/Caching/StackExchangeRedis/src/RedisOutputCacheStoreImpl.cs
new file mode 100644
index 000000000000..e27638aa6400
--- /dev/null
+++ b/src/Caching/StackExchangeRedis/src/RedisOutputCacheStoreImpl.cs
@@ -0,0 +1,24 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#if NET7_0_OR_GREATER // IOutputCacheStore only exists from net7
+
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace Microsoft.Extensions.Caching.StackExchangeRedis;
+
+internal sealed class RedisOutputCacheStoreImpl : RedisOutputCacheStore
+{
+ public RedisOutputCacheStoreImpl(IOptions optionsAccessor, ILogger logger)
+ : base(optionsAccessor, logger)
+ {
+ }
+
+ public RedisOutputCacheStoreImpl(IOptions optionsAccessor)
+ : base(optionsAccessor)
+ {
+ }
+}
+
+#endif
diff --git a/src/Caching/StackExchangeRedis/src/StackExchangeRedisCacheServiceCollectionExtensions.cs b/src/Caching/StackExchangeRedis/src/StackExchangeRedisCacheServiceCollectionExtensions.cs
index dc9bc9ca5357..d6fca729bf20 100644
--- a/src/Caching/StackExchangeRedis/src/StackExchangeRedisCacheServiceCollectionExtensions.cs
+++ b/src/Caching/StackExchangeRedis/src/StackExchangeRedisCacheServiceCollectionExtensions.cs
@@ -5,6 +5,7 @@
using Microsoft.AspNetCore.Shared;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.StackExchangeRedis;
+using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Microsoft.Extensions.DependencyInjection;
@@ -32,4 +33,27 @@ public static IServiceCollection AddStackExchangeRedisCache(this IServiceCollect
return services;
}
+
+#if NET7_0_OR_GREATER
+ ///
+ /// Adds Redis distributed caching services to the specified .
+ ///
+ /// The to add services to.
+ /// An to configure the provided
+ /// .
+ /// The so that additional calls can be chained.
+ public static IServiceCollection AddStackExchangeRedisOutputCache(this IServiceCollection services, Action setupAction)
+ {
+ ArgumentNullThrowHelper.ThrowIfNull(services);
+ ArgumentNullThrowHelper.ThrowIfNull(setupAction);
+
+ services.AddOptions();
+
+ services.Configure(setupAction);
+ // replace here (Add vs TryAdd) is intentional and part of test conditions
+ services.AddSingleton();
+
+ return services;
+ }
+#endif
}
diff --git a/src/Caching/StackExchangeRedis/test/OutputCache/OutputCacheGetSetTests.cs b/src/Caching/StackExchangeRedis/test/OutputCache/OutputCacheGetSetTests.cs
new file mode 100644
index 000000000000..088d7c76822d
--- /dev/null
+++ b/src/Caching/StackExchangeRedis/test/OutputCache/OutputCacheGetSetTests.cs
@@ -0,0 +1,449 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#if NET7_0_OR_GREATER
+
+using System;
+using System.Buffers;
+using System.IO.Pipelines;
+using System.Runtime.CompilerServices;
+using System.Security.Cryptography;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.OutputCaching;
+using Pipelines.Sockets.Unofficial.Buffers;
+using StackExchange.Redis;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Microsoft.Extensions.Caching.StackExchangeRedis;
+
+public class OutputCacheGetSetTests : IClassFixture
+{
+ private const string SkipReason = "TODO: Disabled due to CI failure. " +
+ "These tests require Redis server to be started on the machine. Make sure to change the value of" +
+ "\"RedisTestConfig.RedisPort\" accordingly.";
+
+ private readonly IOutputCacheBufferStore _cache;
+ private readonly RedisConnectionFixture _fixture;
+ private readonly ITestOutputHelper Log;
+
+ public OutputCacheGetSetTests(RedisConnectionFixture connection, ITestOutputHelper log)
+ {
+ _fixture = connection;
+ _cache = new RedisOutputCacheStore(new RedisCacheOptions
+ {
+ ConnectionMultiplexerFactory = () => Task.FromResult(_fixture.Connection),
+ InstanceName = "TestPrefix",
+ })
+ {
+ GarbageCollectionEnabled = false,
+ };
+ Log = log;
+ }
+
+#if DEBUG
+ private async ValueTask Cache()
+ {
+ if (_cache is RedisOutputCacheStore real)
+ {
+ Log.WriteLine(await real.GetConfigurationInfoAsync().ConfigureAwait(false));
+ }
+ return _cache;
+ }
+#else
+ private ValueTask Cache() => new(_cache); // avoid CS1998 - no "await"
+#endif
+
+ [Fact(Skip = SkipReason)]
+ public async Task GetMissingKeyReturnsNull()
+ {
+ var cache = await Cache().ConfigureAwait(false);
+ var result = await cache.GetAsync("non-existent-key", CancellationToken.None);
+ Assert.Null(result);
+ }
+
+ [Theory(Skip = SkipReason)]
+ [InlineData(true, false)]
+ [InlineData(true, true)]
+ [InlineData(false, false)]
+ [InlineData(false, true)]
+ public async Task SetStoresValueWithPrefixAndTimeout(bool useReadOnlySequence, bool withTags)
+ {
+ var cache = await Cache().ConfigureAwait(false);
+ var key = Guid.NewGuid().ToString();
+ byte[] storedValue = new byte[1017];
+ Random.Shared.NextBytes(storedValue);
+ RedisKey underlyingKey = "TestPrefix__MSOCV_" + key;
+
+ // pre-check
+ await _fixture.Database.KeyDeleteAsync(new RedisKey[] { "TestPrefix__MSOCT", "TestPrefix__MSOCT_tagA", "TestPrefix__MSOCT_tagB" });
+ var timeout = await _fixture.Database.KeyTimeToLiveAsync(underlyingKey);
+ Assert.Null(timeout); // means doesn't exist
+ Assert.False(await _fixture.Database.KeyExistsAsync("TestPrefix__MSOCT"));
+ Assert.False(await _fixture.Database.KeyExistsAsync("TestPrefix__MSOCT_tagA"));
+ Assert.False(await _fixture.Database.KeyExistsAsync("TestPrefix__MSOCT_tagB"));
+
+ // act
+ var actTime = DateTime.UtcNow;
+ var ttl = TimeSpan.FromSeconds(30);
+ var tags = withTags ? new[] { "tagA", "tagB" } : null;
+ if (useReadOnlySequence)
+ {
+ await cache.SetAsync(key, new ReadOnlySequence(storedValue), tags, ttl, CancellationToken.None);
+ }
+ else
+ {
+ await cache.SetAsync(key, storedValue, tags, ttl, CancellationToken.None);
+ }
+
+ // validate via redis direct
+ timeout = await _fixture.Database.KeyTimeToLiveAsync(underlyingKey);
+ Assert.NotNull(timeout); // means exists
+ var seconds = timeout.Value.TotalSeconds;
+ Assert.True(seconds >= 28 && seconds <= 32, "timeout should be in range");
+ var redisValue = (byte[])(await _fixture.Database.StringGetAsync(underlyingKey));
+ Assert.True(((ReadOnlySpan)storedValue).SequenceEqual(redisValue), "payload should match");
+
+ double expected = (long)((actTime + ttl) - DateTime.UnixEpoch).TotalMilliseconds;
+ if (withTags)
+ {
+ // we expect the tag structure to now exist, with the scores within a bit of a second
+ const double Tolerance = 100.0;
+ Assert.Equal((await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT", "tagA")).Value, expected, Tolerance);
+ Assert.Equal((await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT", "tagB")).Value, expected, Tolerance);
+ Assert.Equal((await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_tagA", key)).Value, expected, Tolerance);
+ Assert.Equal((await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_tagB", key)).Value, expected, Tolerance);
+ }
+ else
+ {
+ // we do *not* expect the tag structure to exist
+ Assert.False(await _fixture.Database.KeyExistsAsync("TestPrefix__MSOCT"));
+ Assert.False(await _fixture.Database.KeyExistsAsync("TestPrefix__MSOCT_tagA"));
+ Assert.False(await _fixture.Database.KeyExistsAsync("TestPrefix__MSOCT_tagB"));
+ }
+ }
+
+ [Theory(Skip = SkipReason)]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task CanFetchStoredValue(bool useReadOnlySequence)
+ {
+ var cache = await Cache().ConfigureAwait(false);
+ var key = Guid.NewGuid().ToString();
+ byte[] storedValue = new byte[1017];
+ Random.Shared.NextBytes(storedValue);
+
+ // pre-check
+ var fetchedValue = await cache.GetAsync(key, CancellationToken.None);
+ Assert.Null(fetchedValue);
+
+ // store and fetch via service
+ if (useReadOnlySequence)
+ {
+ await cache.SetAsync(key, new ReadOnlySequence(storedValue), null, TimeSpan.FromSeconds(30), CancellationToken.None);
+ }
+ else
+ {
+ await cache.SetAsync(key, storedValue, null, TimeSpan.FromSeconds(30), CancellationToken.None);
+ }
+ fetchedValue = await cache.GetAsync(key, CancellationToken.None);
+ Assert.NotNull(fetchedValue);
+
+ Assert.True(((ReadOnlySpan)storedValue).SequenceEqual(fetchedValue), "payload should match");
+ }
+
+ [Fact(Skip = SkipReason)]
+ public async Task GetMissingKeyReturnsFalse_BufferWriter() // "true" result checked in MultiSegmentWriteWorksAsExpected_BufferWriter
+ {
+ var cache = Assert.IsAssignableFrom(await Cache().ConfigureAwait(false));
+ var key = Guid.NewGuid().ToString();
+
+ var pipe = new Pipe();
+ Assert.False(await cache.TryGetAsync(key, pipe.Writer, CancellationToken.None));
+ pipe.Writer.Complete();
+ var read = await pipe.Reader.ReadAsync();
+ Assert.True(read.IsCompleted);
+ Assert.True(read.Buffer.IsEmpty);
+ pipe.Reader.AdvanceTo(read.Buffer.End);
+ }
+
+ [Fact(Skip = SkipReason)]
+ public async Task MasterTagScoreShouldOnlyIncrease()
+ {
+ // store some data
+ var cache = await Cache().ConfigureAwait(false);
+ byte[] storedValue = new byte[1017];
+ Random.Shared.NextBytes(storedValue);
+ var tags = new[] { "gtonly" };
+ await _fixture.Database.KeyDeleteAsync("TestPrefix__MSOCT"); // start from nil state
+
+ await cache.SetAsync(Guid.NewGuid().ToString(), storedValue, tags, TimeSpan.FromSeconds(30), CancellationToken.None);
+ var originalScore = await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT", "gtonly");
+ Assert.NotNull(originalScore);
+
+ // now store something with a shorter ttl; the score should not change
+ await cache.SetAsync(Guid.NewGuid().ToString(), storedValue, tags, TimeSpan.FromSeconds(15), CancellationToken.None);
+ var newScore = await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT", "gtonly");
+ Assert.NotNull(newScore);
+ Assert.Equal(originalScore, newScore);
+
+ // now store something with a longer ttl; the score should increase
+ await cache.SetAsync(Guid.NewGuid().ToString(), storedValue, tags, TimeSpan.FromSeconds(45), CancellationToken.None);
+ newScore = await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT", "gtonly");
+ Assert.NotNull(newScore);
+ Assert.True(newScore > originalScore, "should increase");
+ }
+
+ [Fact(Skip = SkipReason)]
+ public async Task CanEvictByTag()
+ {
+ // store some data
+ var cache = await Cache().ConfigureAwait(false);
+ byte[] storedValue = new byte[1017];
+ Random.Shared.NextBytes(storedValue);
+ var ttl = TimeSpan.FromSeconds(30);
+
+ var noTags = Guid.NewGuid().ToString();
+ await cache.SetAsync(noTags, storedValue, null, ttl, CancellationToken.None);
+
+ var foo = Guid.NewGuid().ToString();
+ await cache.SetAsync(foo, storedValue, new[] { "foo" }, ttl, CancellationToken.None);
+
+ var bar = Guid.NewGuid().ToString();
+ await cache.SetAsync(bar, storedValue, new[] { "bar" }, ttl, CancellationToken.None);
+
+ var fooBar = Guid.NewGuid().ToString();
+ await cache.SetAsync(fooBar, storedValue, new[] { "foo", "bar" }, ttl, CancellationToken.None);
+
+ // assert prior state
+ Assert.NotNull(await cache.GetAsync(noTags, CancellationToken.None));
+ Assert.NotNull(await cache.GetAsync(foo, CancellationToken.None));
+ Assert.NotNull(await cache.GetAsync(bar, CancellationToken.None));
+ Assert.NotNull(await cache.GetAsync(fooBar, CancellationToken.None));
+ Assert.Null(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_foo", noTags));
+ Assert.NotNull(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_foo", foo));
+ Assert.Null(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_foo", bar));
+ Assert.NotNull(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_foo", fooBar));
+ Assert.Null(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_bar", noTags));
+ Assert.Null(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_bar", foo));
+ Assert.NotNull(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_bar", bar));
+ Assert.NotNull(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_bar", fooBar));
+
+ // act
+ for (int i = 0; i < 2; i++) // loop is to ensure no oddity when working on tags that *don't* have entries
+ {
+ await cache.EvictByTagAsync("foo", CancellationToken.None);
+ Assert.NotNull(await cache.GetAsync(noTags, CancellationToken.None));
+ Assert.Null(await cache.GetAsync(foo, CancellationToken.None));
+ Assert.NotNull(await cache.GetAsync(bar, CancellationToken.None));
+ Assert.Null(await cache.GetAsync(fooBar, CancellationToken.None));
+ }
+ Assert.Null(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_foo", noTags));
+ Assert.Null(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_foo", foo));
+ Assert.Null(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_foo", bar));
+ Assert.Null(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_foo", fooBar));
+ Assert.Null(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_bar", noTags));
+ Assert.Null(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_bar", foo));
+ Assert.NotNull(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_bar", bar));
+ Assert.NotNull(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_bar", fooBar));
+
+ for (int i = 0; i < 2; i++) // loop is to ensure no oddity when working on tags that *don't* have entries
+ {
+ await cache.EvictByTagAsync("bar", CancellationToken.None);
+ Assert.NotNull(await cache.GetAsync(noTags, CancellationToken.None));
+ Assert.Null(await cache.GetAsync(foo, CancellationToken.None));
+ Assert.Null(await cache.GetAsync(bar, CancellationToken.None));
+ Assert.Null(await cache.GetAsync(fooBar, CancellationToken.None));
+ }
+ Assert.Null(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_foo", noTags));
+ Assert.Null(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_foo", foo));
+ Assert.Null(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_foo", bar));
+ Assert.Null(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_foo", fooBar));
+ Assert.Null(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_bar", noTags));
+ Assert.Null(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_bar", foo));
+ Assert.Null(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_bar", bar));
+ Assert.Null(await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT_bar", fooBar));
+
+ // assert expected state
+ Assert.NotNull(await cache.GetAsync(noTags, CancellationToken.None));
+ Assert.Null(await cache.GetAsync(foo, CancellationToken.None));
+ Assert.Null(await cache.GetAsync(bar, CancellationToken.None));
+ Assert.Null(await cache.GetAsync(fooBar, CancellationToken.None));
+ }
+
+ [Fact(Skip = SkipReason)]
+ public async Task MultiSegmentWriteWorksAsExpected_Array()
+ {
+ // store some data
+ var first = new Segment(1024, null);
+ var second = new Segment(1024, first);
+ var third = new Segment(1024, second);
+
+ Random.Shared.NextBytes(first.Array);
+ Random.Shared.NextBytes(second.Array);
+ Random.Shared.NextBytes(third.Array);
+ var payload = new ReadOnlySequence(first, 800, third, 42);
+ Assert.False(payload.IsSingleSegment, "multi-segment");
+ Assert.Equal(1290, payload.Length); // partial from first and last
+
+ var cache = await Cache().ConfigureAwait(false);
+ var key = Guid.NewGuid().ToString();
+ await cache.SetAsync(key, payload, default, TimeSpan.FromSeconds(30), CancellationToken.None);
+
+ var fetched = await cache.GetAsync(key, CancellationToken.None);
+ Assert.NotNull(fetched);
+ ReadOnlyMemory linear = payload.ToArray();
+ Assert.True(linear.Span.SequenceEqual(fetched), "payload match");
+ }
+
+ [Fact(Skip = SkipReason)]
+ public async Task MultiSegmentWriteWorksAsExpected_BufferWriter()
+ {
+ // store some data
+ var first = new Segment(1024, null);
+ var second = new Segment(1024, first);
+ var third = new Segment(1024, second);
+
+ Random.Shared.NextBytes(first.Array);
+ Random.Shared.NextBytes(second.Array);
+ Random.Shared.NextBytes(third.Array);
+ var payload = new ReadOnlySequence(first, 800, third, 42);
+ Assert.False(payload.IsSingleSegment, "multi-segment");
+ Assert.Equal(1290, payload.Length); // partial from first and last
+
+ var cache = Assert.IsAssignableFrom(await Cache().ConfigureAwait(false));
+ var key = Guid.NewGuid().ToString();
+ await cache.SetAsync(key, payload, default, TimeSpan.FromSeconds(30), CancellationToken.None);
+
+ var pipe = new Pipe();
+ Assert.True(await cache.TryGetAsync(key, pipe.Writer, CancellationToken.None));
+ pipe.Writer.Complete();
+ var read = await pipe.Reader.ReadAsync();
+ Assert.True(read.IsCompleted);
+ Assert.Equal(1290, read.Buffer.Length);
+
+ using (Linearize(payload, out var linearPayload))
+ using (Linearize(read.Buffer, out var linearRead))
+ {
+ Assert.True(linearPayload.Span.SequenceEqual(linearRead.Span), "payload match");
+ }
+ pipe.Reader.AdvanceTo(read.Buffer.End);
+
+ static IMemoryOwner Linearize(ReadOnlySequence payload, out ReadOnlyMemory linear)
+ {
+ if (payload.IsEmpty)
+ {
+ linear = default;
+ return null;
+ }
+ if (payload.IsSingleSegment)
+ {
+ linear = payload.First;
+ return null;
+ }
+ var len = checked((int)payload.Length);
+ var lease = MemoryPool.Shared.Rent(len);
+ var memory = lease.Memory.Slice(0, len);
+ payload.CopyTo(memory.Span);
+ linear = memory;
+ return lease;
+ }
+ }
+
+ [Fact(Skip = SkipReason)]
+ public async Task GarbageCollectionDoesNotRunWhenGCKeyHeld()
+ {
+ var cache = await Cache().ConfigureAwait(false);
+ var impl = Assert.IsAssignableFrom(cache);
+ await _fixture.Database.StringSetAsync("TestPrefix__MSOCTGC", "dummy", TimeSpan.FromMinutes(1));
+ try
+ {
+ Assert.Null(await impl.ExecuteGarbageCollectionAsync(42));
+ }
+ finally
+ {
+ await _fixture.Database.KeyDeleteAsync("TestPrefix__MSOCTGC");
+ }
+ }
+
+ [Fact(Skip = SkipReason)]
+ public async Task GarbageCollectionCleansUpTagData()
+ {
+ // importantly, we're not interested in the lifetime of the *values* - redis deals with that
+ // itself; we're only interested in the tag-expiry metadata
+ var blob = new byte[16];
+ Random.Shared.NextBytes(blob);
+ var cache = await Cache().ConfigureAwait(false);
+ var impl = Assert.IsAssignableFrom(cache);
+
+ // start vanilla
+ await _fixture.Database.KeyDeleteAsync(new RedisKey[] { "TestPrefix__MSOCT",
+ "TestPrefix__MSOCT_a", "TestPrefix__MSOCT_b",
+ "TestPrefix__MSOCT_c", "TestPrefix__MSOCT_d" });
+
+ await cache.SetAsync(Guid.NewGuid().ToString(), blob, new[] { "a", "b" }, TimeSpan.FromSeconds(5), CancellationToken.None); // a=b=5
+ await cache.SetAsync(Guid.NewGuid().ToString(), blob, new[] { "b", "c" }, TimeSpan.FromSeconds(10), CancellationToken.None); // a=5, b=c=10
+ await cache.SetAsync(Guid.NewGuid().ToString(), blob, new[] { "c", "d" }, TimeSpan.FromSeconds(15), CancellationToken.None); // a=5, b=10, c=d=15
+
+ long aScore = (long)await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT", "a"),
+ bScore = (long)await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT", "b"),
+ cScore = (long)await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT", "c"),
+ dScore = (long)await _fixture.Database.SortedSetScoreAsync("TestPrefix__MSOCT", "d");
+
+ Assert.False(await _fixture.Database.KeyExistsAsync("TestPrefix__MSOCTGC"), "GC key should not exist");
+ Assert.True(await _fixture.Database.KeyExistsAsync("TestPrefix__MSOCT"), "master tag should exist");
+ Assert.True(await _fixture.Database.KeyExistsAsync("TestPrefix__MSOCT_a"), "tag a should exist");
+ Assert.True(await _fixture.Database.KeyExistsAsync("TestPrefix__MSOCT_b"), "tag b should exist");
+ Assert.True(await _fixture.Database.KeyExistsAsync("TestPrefix__MSOCT_c"), "tag c should exist");
+ Assert.True(await _fixture.Database.KeyExistsAsync("TestPrefix__MSOCT_d"), "tag d should exist");
+
+ await CheckCounts(4, 1, 2, 2, 1);
+ Assert.Equal(0, await impl.ExecuteGarbageCollectionAsync(0)); // should not change anything
+ await CheckCounts(4, 1, 2, 2, 1);
+ Assert.Equal(1, await impl.ExecuteGarbageCollectionAsync(aScore)); // 1=removes a
+ await CheckCounts(3, 0, 1, 2, 1);
+ Assert.Equal(1, await impl.ExecuteGarbageCollectionAsync(bScore)); // 1=removes b
+ await CheckCounts(2, 0, 0, 1, 1);
+ Assert.Equal(2, await impl.ExecuteGarbageCollectionAsync(cScore)); // 2=removes c+d
+ await CheckCounts(0, 0, 0, 0, 0);
+ Assert.Equal(0, await impl.ExecuteGarbageCollectionAsync(dScore));
+ await CheckCounts(0, 0, 0, 0, 0);
+ Assert.Equal(0, await impl.ExecuteGarbageCollectionAsync(dScore + 1000)); // should have nothing left to do
+ await CheckCounts(0, 0, 0, 0, 0);
+
+ // we should now not have any of these left
+ Assert.False(await _fixture.Database.KeyExistsAsync("TestPrefix__MSOCTGC"), "GC key should not exist");
+ Assert.False(await _fixture.Database.KeyExistsAsync("TestPrefix__MSOCT"), "master tag still exists");
+ Assert.False(await _fixture.Database.KeyExistsAsync("TestPrefix__MSOCT_a"), "tag a still exists");
+ Assert.False(await _fixture.Database.KeyExistsAsync("TestPrefix__MSOCT_b"), "tag b still exists");
+ Assert.False(await _fixture.Database.KeyExistsAsync("TestPrefix__MSOCT_c"), "tag c still exists");
+ Assert.False(await _fixture.Database.KeyExistsAsync("TestPrefix__MSOCT_d"), "tag d still exists");
+
+ async Task CheckCounts(int master, int a, int b, int c, int d)
+ {
+ Assert.Equal(master, (int)await _fixture.Database.SortedSetLengthAsync("TestPrefix__MSOCT"));
+ Assert.Equal(a, (int)await _fixture.Database.SortedSetLengthAsync("TestPrefix__MSOCT_a"));
+ Assert.Equal(b, (int)await _fixture.Database.SortedSetLengthAsync("TestPrefix__MSOCT_b"));
+ Assert.Equal(c, (int)await _fixture.Database.SortedSetLengthAsync("TestPrefix__MSOCT_c"));
+ Assert.Equal(d, (int)await _fixture.Database.SortedSetLengthAsync("TestPrefix__MSOCT_d"));
+ }
+ }
+
+ private class Segment : ReadOnlySequenceSegment
+ {
+ public Segment(int length, Segment previous = null)
+ {
+ if (previous is not null)
+ {
+ previous.Next = this;
+ RunningIndex = previous.RunningIndex + previous.Memory.Length;
+ }
+ Array = new byte[length];
+ Memory = Array;
+ }
+ public byte[] Array { get; }
+ }
+}
+
+#endif
diff --git a/src/Caching/StackExchangeRedis/test/OutputCache/OutputCacheServiceExtensionsTests.cs b/src/Caching/StackExchangeRedis/test/OutputCache/OutputCacheServiceExtensionsTests.cs
new file mode 100644
index 000000000000..37efde7c19da
--- /dev/null
+++ b/src/Caching/StackExchangeRedis/test/OutputCache/OutputCacheServiceExtensionsTests.cs
@@ -0,0 +1,129 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#if NET7_0_OR_GREATER
+
+using System.Linq;
+using Microsoft.AspNetCore.OutputCaching;
+using Microsoft.Extensions.Caching.Distributed;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Microsoft.Extensions.Caching.StackExchangeRedis;
+
+public class OutputCacheServiceExtensionsTests
+{
+ [Fact]
+ public void AddStackExchangeRedisOutputCache_RegistersOutputCacheStoreAsSingleton()
+ {
+ // Arrange
+ var services = new ServiceCollection();
+
+ // Act
+ services.AddStackExchangeRedisOutputCache(options => { });
+
+ // Assert
+ var outputCacheStore = services.FirstOrDefault(desc => desc.ServiceType == typeof(IOutputCacheStore));
+
+ Assert.NotNull(outputCacheStore);
+ Assert.Equal(ServiceLifetime.Singleton, outputCacheStore.Lifetime);
+ }
+
+ [Fact]
+ public void AddStackExchangeRedisOutputCache_ReplacesPreviouslyUserRegisteredServices()
+ {
+ // Arrange
+ var services = new ServiceCollection();
+ services.AddScoped(typeof(IOutputCacheStore), sp => Mock.Of());
+
+ // Act
+ services.AddStackExchangeRedisOutputCache(options => { });
+
+ // Assert
+ var serviceProvider = services.BuildServiceProvider();
+
+ var distributedCache = services.FirstOrDefault(desc => desc.ServiceType == typeof(IOutputCacheStore));
+
+ Assert.NotNull(distributedCache);
+ Assert.Equal(ServiceLifetime.Scoped, distributedCache.Lifetime);
+ Assert.IsAssignableFrom(serviceProvider.GetRequiredService());
+ }
+
+ [Fact]
+ public void AddStackExchangeRedisOutputCache_AllowsChaining()
+ {
+ var services = new ServiceCollection();
+
+ Assert.Same(services, services.AddStackExchangeRedisOutputCache(_ => { }));
+ }
+
+ [Fact]
+ public void AddStackExchangeRedisOutputCache_IOutputCacheStoreWithoutLoggingCanBeResolved()
+ {
+ // Arrange
+ var services = new ServiceCollection();
+
+ // Act
+ services.AddStackExchangeRedisOutputCache(options => { });
+
+ // Assert
+ using var serviceProvider = services.BuildServiceProvider();
+ var outputCacheStore = serviceProvider.GetRequiredService();
+
+ Assert.NotNull(outputCacheStore);
+ }
+
+ [Fact]
+ public void AddStackExchangeRedisOutputCache_IOutputCacheStoreWithLoggingCanBeResolved()
+ {
+ // Arrange
+ var services = new ServiceCollection();
+
+ // Act
+ services.AddStackExchangeRedisOutputCache(options => { });
+ services.AddLogging();
+
+ // Assert
+ using var serviceProvider = services.BuildServiceProvider();
+ var outputCacheStore = serviceProvider.GetRequiredService();
+
+ Assert.NotNull(outputCacheStore);
+ }
+
+ [Fact]
+ public void AddStackExchangeRedisOutputCache_UsesLoggerFactoryAlreadyRegisteredWithServiceCollection()
+ {
+ // Arrange
+ var services = new ServiceCollection();
+ services.AddScoped(typeof(IOutputCacheStore), sp => Mock.Of());
+
+ var loggerFactory = new Mock();
+
+ loggerFactory
+ .Setup(lf => lf.CreateLogger(It.IsAny()))
+ .Returns((string name) => NullLoggerFactory.Instance.CreateLogger(name))
+ .Verifiable();
+
+ services.AddScoped(typeof(ILoggerFactory), _ => loggerFactory.Object);
+
+ // Act
+ services.AddLogging();
+ services.AddStackExchangeRedisOutputCache(options => { });
+
+ // Assert
+ var serviceProvider = services.BuildServiceProvider();
+
+ var distributedCache = services.FirstOrDefault(desc => desc.ServiceType == typeof(IOutputCacheStore));
+
+ Assert.NotNull(distributedCache);
+ Assert.Equal(ServiceLifetime.Scoped, distributedCache.Lifetime);
+ Assert.IsAssignableFrom(serviceProvider.GetRequiredService());
+
+ loggerFactory.Verify();
+ }
+}
+
+#endif
diff --git a/src/Caching/StackExchangeRedis/test/OutputCache/RedisConnectionFixture.cs b/src/Caching/StackExchangeRedis/test/OutputCache/RedisConnectionFixture.cs
new file mode 100644
index 000000000000..6188beb0d691
--- /dev/null
+++ b/src/Caching/StackExchangeRedis/test/OutputCache/RedisConnectionFixture.cs
@@ -0,0 +1,27 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#if NET7_0_OR_GREATER
+
+using System;
+using StackExchange.Redis;
+using Xunit;
+
+namespace Microsoft.Extensions.Caching.StackExchangeRedis;
+
+public class RedisConnectionFixture : IDisposable
+{
+ private readonly ConnectionMultiplexer _muxer;
+ public RedisConnectionFixture()
+ {
+ _muxer = ConnectionMultiplexer.Connect("127.0.0.1:6379");
+ }
+
+ public IDatabase Database => _muxer.GetDatabase();
+
+ public IConnectionMultiplexer Connection => _muxer;
+
+ public void Dispose() => _muxer.Dispose();
+}
+
+#endif
diff --git a/src/Middleware/OutputCaching/OutputCaching.slnf b/src/Middleware/OutputCaching/OutputCaching.slnf
index 7d85ca7205c3..872e454585eb 100644
--- a/src/Middleware/OutputCaching/OutputCaching.slnf
+++ b/src/Middleware/OutputCaching/OutputCaching.slnf
@@ -2,6 +2,8 @@
"solution": {
"path": "..\\..\\..\\AspNetCore.sln",
"projects": [
+ "src\\Caching\\StackExchangeRedis\\src\\Microsoft.Extensions.Caching.StackExchangeRedis.csproj",
+ "src\\Caching\\StackExchangeRedis\\test\\Microsoft.Extensions.Caching.StackExchangeRedis.Tests.csproj",
"src\\Middleware\\OutputCaching\\samples\\OutputCachingSample\\OutputCachingSample.csproj",
"src\\Middleware\\OutputCaching\\src\\Microsoft.AspNetCore.OutputCaching.csproj",
"src\\Middleware\\OutputCaching\\test\\Microsoft.AspNetCore.OutputCaching.Tests.csproj"
diff --git a/src/Middleware/OutputCaching/src/IOutputCacheStore.cs b/src/Middleware/OutputCaching/src/IOutputCacheStore.cs
index ffba017ef6f9..9b9e0738cb4b 100644
--- a/src/Middleware/OutputCaching/src/IOutputCacheStore.cs
+++ b/src/Middleware/OutputCaching/src/IOutputCacheStore.cs
@@ -1,6 +1,9 @@
// 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;
+using System.IO.Pipelines;
+
namespace Microsoft.AspNetCore.OutputCaching;
///
@@ -34,3 +37,29 @@ public interface IOutputCacheStore
/// Indicates that the operation should be cancelled.
ValueTask SetAsync(string key, byte[] value, string[]? tags, TimeSpan validFor, CancellationToken cancellationToken);
}
+
+///
+/// Represents a store for cached responses that uses a as the target.
+///
+public interface IOutputCacheBufferStore : IOutputCacheStore
+{
+ ///
+ /// Gets the cached response for the given key, if it exists.
+ /// If no cached response exists for the given key, null is returned.
+ ///
+ /// The cache key to look up.
+ /// The location to which the value should be written.
+ /// Indicates that the operation should be cancelled.
+ /// True if the response cache entry if it exists; otherwise False.
+ ValueTask TryGetAsync(string key, PipeWriter destination, CancellationToken cancellationToken);
+
+ ///
+ /// Stores the given response in the response cache.
+ ///
+ /// The cache key to store the response under.
+ /// The response cache entry to store; this value is only defined for the duration of the method, and should not be stored without making a copy.
+ /// The tags associated with the cache entry to store.
+ /// The amount of time the entry will be kept in the cache before expiring, relative to now.
+ /// Indicates that the operation should be cancelled.
+ ValueTask SetAsync(string key, ReadOnlySequence value, ReadOnlyMemory tags, TimeSpan validFor, CancellationToken cancellationToken);
+}
diff --git a/src/Middleware/OutputCaching/src/OutputCacheEntryFormatter.cs b/src/Middleware/OutputCaching/src/OutputCacheEntryFormatter.cs
index 50a9066fde5b..963c6fbef24d 100644
--- a/src/Middleware/OutputCaching/src/OutputCacheEntryFormatter.cs
+++ b/src/Middleware/OutputCaching/src/OutputCacheEntryFormatter.cs
@@ -1,6 +1,7 @@
// 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;
using System.Linq;
using System.Text;
using Microsoft.AspNetCore.OutputCaching.Serialization;
@@ -78,7 +79,21 @@ public static async ValueTask StoreAsync(string key, OutputCacheEntry value, Tim
Serialize(bufferStream, formatterEntry);
- await store.SetAsync(key, bufferStream.ToArray(), value.Tags ?? Array.Empty(), duration, cancellationToken);
+ if (!bufferStream.TryGetBuffer(out var segment))
+ {
+ segment = bufferStream.ToArray();
+ }
+
+ var payload = new ReadOnlySequence(segment.Array!, segment.Offset, segment.Count);
+ if (store is IOutputCacheBufferStore bufferStore)
+ {
+ await bufferStore.SetAsync(key, payload, value.Tags, duration, cancellationToken);
+ }
+ else
+ {
+ // legacy API/in-proc: create an isolated right-sized byte[] for the payload
+ await store.SetAsync(key, payload.ToArray(), value.Tags, duration, cancellationToken);
+ }
}
// Format:
diff --git a/src/Middleware/OutputCaching/src/PublicAPI.Unshipped.txt b/src/Middleware/OutputCaching/src/PublicAPI.Unshipped.txt
index ebd6fd195922..828a06a00180 100644
--- a/src/Middleware/OutputCaching/src/PublicAPI.Unshipped.txt
+++ b/src/Middleware/OutputCaching/src/PublicAPI.Unshipped.txt
@@ -1,3 +1,6 @@
#nullable enable
+Microsoft.AspNetCore.OutputCaching.IOutputCacheBufferStore
+Microsoft.AspNetCore.OutputCaching.IOutputCacheBufferStore.SetAsync(string! key, System.Buffers.ReadOnlySequence value, System.ReadOnlyMemory tags, System.TimeSpan validFor, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask
+Microsoft.AspNetCore.OutputCaching.IOutputCacheBufferStore.TryGetAsync(string! key, System.IO.Pipelines.PipeWriter! destination, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask
Microsoft.AspNetCore.OutputCaching.OutputCacheAttribute.Tags.get -> string![]?
Microsoft.AspNetCore.OutputCaching.OutputCacheAttribute.Tags.init -> void