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