diff --git a/eng/Dependencies.props b/eng/Dependencies.props index 7d944888c843..6f79305687c0 100644 --- a/eng/Dependencies.props +++ b/eng/Dependencies.props @@ -29,6 +29,7 @@ and are generated based on the last package release. + diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 60a03e72adfd..6a76ee051d97 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -51,6 +51,10 @@ https://github.com/dotnet/dotnet 20fdc50b34ee89e7c54eef0a193c30ed4816597a + + https://github.com/dotnet/dotnet + f485d74550db5bd91617accc1bb548ae6013756b + https://github.com/dotnet/dotnet 20fdc50b34ee89e7c54eef0a193c30ed4816597a diff --git a/eng/Versions.props b/eng/Versions.props index 31308b813cfe..7ae3fd4c16fb 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -74,6 +74,7 @@ 10.0.0-preview.6.25311.102 10.0.0-preview.6.25311.102 10.0.0-preview.6.25311.102 + 10.0.0-alpha.2.24462.2 10.0.0-preview.6.25311.102 10.0.0-preview.6.25311.102 10.0.0-preview.6.25311.102 diff --git a/src/Components/Server/src/CircuitOptions.cs b/src/Components/Server/src/CircuitOptions.cs index 7c63f0ef361e..d9904e5a3699 100644 --- a/src/Components/Server/src/CircuitOptions.cs +++ b/src/Components/Server/src/CircuitOptions.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.Extensions.Caching.Hybrid; + namespace Microsoft.AspNetCore.Components.Server; /// @@ -49,17 +51,40 @@ public sealed class CircuitOptions /// are retained in memory by the server when no distributed cache is configured. /// /// - /// When using a distributed cache like this value is ignored + /// + /// When using a distributed cache like this value is ignored /// and the configuration from /// is used instead. + /// + /// + /// To explicitly control the in memory cache limits when using a distributed cache. Setup a separate instance in with + /// the desired configuration. + /// /// public int PersistedCircuitInMemoryMaxRetained { get; set; } = 1000; /// /// Gets or sets the duration for which a persisted circuit is retained in memory. /// + /// + /// When using a based implementation this value + /// is used for the local cache retention period. + /// public TimeSpan PersistedCircuitInMemoryRetentionPeriod { get; set; } = TimeSpan.FromHours(2); + /// + /// Gets or sets the duration for which a persisted circuit is retained in the distributed cache. + /// + /// + /// This setting is ignored when using an in-memory cache implementation. + /// + public TimeSpan? PersistedCircuitDistributedRetentionPeriod { get; set; } = TimeSpan.FromHours(8); + + /// + /// Gets or sets the instance to use for persisting circuit state across servers. + /// + public HybridCache? HybridPersistenceCache { get; set; } + /// /// Gets or sets a value that determines whether or not to send detailed exception messages to JavaScript when an unhandled exception /// happens on the circuit or when a .NET method invocation through JS interop results in an exception. diff --git a/src/Components/Server/src/Circuits/DefaultHybridCache.cs b/src/Components/Server/src/Circuits/DefaultHybridCache.cs new file mode 100644 index 000000000000..6531a388ae23 --- /dev/null +++ b/src/Components/Server/src/Circuits/DefaultHybridCache.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Components.Server.Circuits; + +/// +/// Default configuration for . +/// +internal sealed class DefaultHybridCache : IPostConfigureOptions +{ + private readonly HybridCache? _hybridCache; + + /// + /// Initializes a new instance of . + /// + /// The service, if available. + public DefaultHybridCache(HybridCache? hybridCache = null) + { + _hybridCache = hybridCache; + } + + /// + public void PostConfigure(string? name, CircuitOptions options) + { + // Only set the HybridPersistenceCache if it hasn't been explicitly configured + // and a HybridCache service is available + if (options.HybridPersistenceCache is null && _hybridCache is not null) + { + options.HybridPersistenceCache = _hybridCache; + } + } +} diff --git a/src/Components/Server/src/Circuits/HybridCacheCircuitPersistenceProvider.cs b/src/Components/Server/src/Circuits/HybridCacheCircuitPersistenceProvider.cs new file mode 100644 index 000000000000..d97b392aa937 --- /dev/null +++ b/src/Components/Server/src/Circuits/HybridCacheCircuitPersistenceProvider.cs @@ -0,0 +1,131 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Components.Server.Circuits; + +// Implementation of ICircuitPersistenceProvider that uses HybridCache for distributed caching +internal sealed partial class HybridCacheCircuitPersistenceProvider : ICircuitPersistenceProvider +{ + private static readonly Func> _failOnCreate = + static ct => throw new InvalidOperationException(); + + private static readonly string[] _tags = ["Microsoft.AspNetCore.Components.Server.PersistedCircuitState"]; + + private readonly SemaphoreSlim _lock = new(1, 1); + private readonly HybridCache _hybridCache; + private readonly ILogger _logger; + private readonly HybridCacheEntryOptions _cacheWriteOptions; + private readonly HybridCacheEntryOptions _cacheReadOptions; + + public HybridCacheCircuitPersistenceProvider( + HybridCache hybridCache, + ILogger logger, + IOptions options) + { + _hybridCache = hybridCache; + _logger = logger; + _cacheWriteOptions = new HybridCacheEntryOptions + { + Expiration = options.Value.PersistedCircuitDistributedRetentionPeriod, + LocalCacheExpiration = options.Value.PersistedCircuitInMemoryRetentionPeriod, + }; + _cacheReadOptions = new HybridCacheEntryOptions + { + Flags = HybridCacheEntryFlags.DisableLocalCacheWrite | + HybridCacheEntryFlags.DisableDistributedCacheWrite | + HybridCacheEntryFlags.DisableUnderlyingData, + }; + } + + public async Task PersistCircuitAsync(CircuitId circuitId, PersistedCircuitState persistedCircuitState, CancellationToken cancellation = default) + { + Log.CircuitPauseStarted(_logger, circuitId); + + try + { + await _lock.WaitAsync(cancellation); + await _hybridCache.SetAsync(circuitId.Secret, persistedCircuitState, _cacheWriteOptions, _tags, cancellation); + } + catch (Exception ex) + { + Log.ExceptionPersistingCircuit(_logger, circuitId, ex); + } + finally + { + _lock.Release(); + } + } + + public async Task RestoreCircuitAsync(CircuitId circuitId, CancellationToken cancellation = default) + { + Log.CircuitResumeStarted(_logger, circuitId); + + try + { + await _lock.WaitAsync(cancellation); + var state = await _hybridCache.GetOrCreateAsync( + circuitId.Secret, + factory: _failOnCreate, + options: _cacheReadOptions, + _tags, + cancellation); + + if (state == null) + { + Log.FailedToFindCircuitState(_logger, circuitId); + return null; + } + + await _hybridCache.RemoveAsync(circuitId.Secret, cancellation); + + Log.CircuitStateFound(_logger, circuitId); + return state; + } + catch (Exception ex) + { + Log.ExceptionRestoringCircuit(_logger, circuitId, ex); + return null; + } + finally + { + _lock.Release(); + } + } + + private static partial class Log + { + [LoggerMessage(201, LogLevel.Debug, "Circuit state evicted for circuit {CircuitId} due to {Reason}", EventName = "CircuitStateEvicted")] + public static partial void CircuitStateEvicted(ILogger logger, CircuitId circuitId, string reason); + + [LoggerMessage(202, LogLevel.Debug, "Resuming circuit with ID {CircuitId}", EventName = "CircuitResumeStarted")] + public static partial void CircuitResumeStarted(ILogger logger, CircuitId circuitId); + + [LoggerMessage(203, LogLevel.Debug, "Failed to find persisted circuit with ID {CircuitId}", EventName = "FailedToFindCircuitState")] + public static partial void FailedToFindCircuitState(ILogger logger, CircuitId circuitId); + + [LoggerMessage(204, LogLevel.Debug, "Circuit state found for circuit {CircuitId}", EventName = "CircuitStateFound")] + public static partial void CircuitStateFound(ILogger logger, CircuitId circuitId); + + [LoggerMessage(205, LogLevel.Error, "An exception occurred while disposing the token source.", EventName = "ExceptionDisposingTokenSource")] + public static partial void ExceptionDisposingTokenSource(ILogger logger, Exception exception); + + [LoggerMessage(206, LogLevel.Debug, "Pausing circuit with ID {CircuitId}", EventName = "CircuitPauseStarted")] + public static partial void CircuitPauseStarted(ILogger logger, CircuitId circuitId); + + [LoggerMessage(207, LogLevel.Error, "An exception occurred while persisting circuit {CircuitId}.", EventName = "ExceptionPersistingCircuit")] + public static partial void ExceptionPersistingCircuit(ILogger logger, CircuitId circuitId, Exception exception); + + [LoggerMessage(208, LogLevel.Error, "An exception occurred while restoring circuit {CircuitId}.", EventName = "ExceptionRestoringCircuit")] + public static partial void ExceptionRestoringCircuit(ILogger logger, CircuitId circuitId, Exception exception); + + [LoggerMessage(209, LogLevel.Error, "An exception occurred during expiration handling for circuit {CircuitId}.", EventName = "ExceptionDuringExpiration")] + public static partial void ExceptionDuringExpiration(ILogger logger, CircuitId circuitId, Exception exception); + + [LoggerMessage(210, LogLevel.Error, "An exception occurred while removing expired circuit {CircuitId}.", EventName = "ExceptionRemovingExpiredCircuit")] + public static partial void ExceptionRemovingExpiredCircuit(ILogger logger, CircuitId circuitId, Exception exception); + } +} diff --git a/src/Components/Server/src/Circuits/PersistedCircuitState.cs b/src/Components/Server/src/Circuits/PersistedCircuitState.cs index 81f1277d66ad..e02536f925dc 100644 --- a/src/Components/Server/src/Circuits/PersistedCircuitState.cs +++ b/src/Components/Server/src/Circuits/PersistedCircuitState.cs @@ -8,9 +8,9 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits; [DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] internal class PersistedCircuitState { - public IReadOnlyDictionary ApplicationState { get; internal set; } + public IReadOnlyDictionary ApplicationState { get; set; } - public byte[] RootComponents { get; internal set; } + public byte[] RootComponents { get; set; } private string GetDebuggerDisplay() { diff --git a/src/Components/Server/src/DependencyInjection/ComponentServiceCollectionExtensions.cs b/src/Components/Server/src/DependencyInjection/ComponentServiceCollectionExtensions.cs index a93fc5a9da95..f63b514fe0f6 100644 --- a/src/Components/Server/src/DependencyInjection/ComponentServiceCollectionExtensions.cs +++ b/src/Components/Server/src/DependencyInjection/ComponentServiceCollectionExtensions.cs @@ -15,6 +15,7 @@ using Microsoft.AspNetCore.SignalR.Protocol; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Internal; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.JSInterop; @@ -77,11 +78,29 @@ public static IServerSideBlazorBuilder AddServerSideBlazor(this IServiceCollecti services.TryAddScoped(s => s.GetRequiredService().Circuit); services.TryAddScoped(); - services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); - services.TryAddSingleton(); + + // Register the circuit persistence provider conditionally based on HybridCache availability + services.TryAddSingleton(serviceProvider => + { + var circuitOptions = serviceProvider.GetRequiredService>(); + if (circuitOptions.Value.HybridPersistenceCache is not null) + { + var logger = serviceProvider.GetRequiredService>(); + return new HybridCacheCircuitPersistenceProvider(circuitOptions.Value.HybridPersistenceCache, logger, circuitOptions); + } + else + { + var logger = serviceProvider.GetRequiredService>(); + var clock = serviceProvider.GetRequiredService(); + return new DefaultInMemoryCircuitPersistenceProvider(clock, logger, circuitOptions); + } + }); + + // Register the configurator for HybridCache + services.TryAddEnumerable(ServiceDescriptor.Singleton, DefaultHybridCache>()); // Standard blazor hosting services implementations // diff --git a/src/Components/Server/src/PublicAPI.Unshipped.txt b/src/Components/Server/src/PublicAPI.Unshipped.txt index a59d7ec1457c..210a9b4fdd3c 100644 --- a/src/Components/Server/src/PublicAPI.Unshipped.txt +++ b/src/Components/Server/src/PublicAPI.Unshipped.txt @@ -1,4 +1,8 @@ #nullable enable +Microsoft.AspNetCore.Components.Server.CircuitOptions.HybridPersistenceCache.get -> Microsoft.Extensions.Caching.Hybrid.HybridCache? +Microsoft.AspNetCore.Components.Server.CircuitOptions.HybridPersistenceCache.set -> void +Microsoft.AspNetCore.Components.Server.CircuitOptions.PersistedCircuitDistributedRetentionPeriod.get -> System.TimeSpan? +Microsoft.AspNetCore.Components.Server.CircuitOptions.PersistedCircuitDistributedRetentionPeriod.set -> void Microsoft.AspNetCore.Components.Server.CircuitOptions.PersistedCircuitInMemoryMaxRetained.get -> int Microsoft.AspNetCore.Components.Server.CircuitOptions.PersistedCircuitInMemoryMaxRetained.set -> void Microsoft.AspNetCore.Components.Server.CircuitOptions.PersistedCircuitInMemoryRetentionPeriod.get -> System.TimeSpan diff --git a/src/Components/Server/test/Circuits/HybridCacheCircuitPersistenceProviderTest.cs b/src/Components/Server/test/Circuits/HybridCacheCircuitPersistenceProviderTest.cs new file mode 100644 index 000000000000..ade8e56eb769 --- /dev/null +++ b/src/Components/Server/test/Circuits/HybridCacheCircuitPersistenceProviderTest.cs @@ -0,0 +1,107 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components.Server.Circuits; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; + +namespace Microsoft.AspNetCore.Components.Server.Tests.Circuits; + +public class HybridCacheCircuitPersistenceProviderTest +{ + [Fact] + public async Task CanPersistAndRestoreState() + { + // Arrange + var hybridCache = CreateHybridCache(); + var circuitId = TestCircuitIdFactory.CreateTestFactory().CreateCircuitId(); + var persistedState = new PersistedCircuitState() + { + RootComponents = [1, 2, 3], + ApplicationState = new Dictionary { + { "key1", new byte[] { 4, 5, 6 } }, + { "key2", new byte[] { 7, 8, 9 } } + } + }; + var provider = CreateProvider(hybridCache); + + // Act + await provider.PersistCircuitAsync(circuitId, persistedState); + + // Assert + var result = await provider.RestoreCircuitAsync(circuitId); + Assert.NotNull(result); + Assert.Equal(persistedState.RootComponents, result.RootComponents); + Assert.Equal(persistedState.ApplicationState, result.ApplicationState); + } + + [Fact] + public async Task RestoreCircuitAsync_ReturnsNull_WhenCircuitDoesNotExist() + { + // Arrange + var hybridCache = CreateHybridCache(); + var circuitId = TestCircuitIdFactory.CreateTestFactory().CreateCircuitId(); + var provider = CreateProvider(hybridCache); + var cacheKey = circuitId.Secret; + + // Act + var result = await provider.RestoreCircuitAsync(circuitId); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task RestoreCircuitAsync_RemovesCircuitFromCache() + { + // Arrange + var hybridCache = CreateHybridCache(); + var circuitId = TestCircuitIdFactory.CreateTestFactory().CreateCircuitId(); + var persistedState = new PersistedCircuitState() + { + RootComponents = [1, 2, 3], + ApplicationState = new Dictionary { + { "key1", new byte[] { 4, 5, 6 } }, + { "key2", new byte[] { 7, 8, 9 } } + } + }; + + var provider = CreateProvider(hybridCache); + var cacheKey = circuitId.Secret; + + await provider.PersistCircuitAsync(circuitId, persistedState); + + // Act + var result1 = await provider.RestoreCircuitAsync(circuitId); + var result2 = await provider.RestoreCircuitAsync(circuitId); + + // Assert + Assert.NotNull(result1); + Assert.Equal(persistedState.RootComponents, result1.RootComponents); + Assert.Equal(persistedState.ApplicationState, result1.ApplicationState); + + Assert.Null(result2); // Circuit should be removed after first restore + } + + private HybridCache CreateHybridCache() + { + return new ServiceCollection() + .AddHybridCache().Services + .BuildServiceProvider() + .GetRequiredService(); + } + + private static HybridCacheCircuitPersistenceProvider CreateProvider( + HybridCache hybridCache, + CircuitOptions options = null) + { + return new HybridCacheCircuitPersistenceProvider( + hybridCache, + NullLogger.Instance, + Options.Create(options ?? new CircuitOptions())); + } +} diff --git a/src/Components/Server/test/Microsoft.AspNetCore.Components.Server.Tests.csproj b/src/Components/Server/test/Microsoft.AspNetCore.Components.Server.Tests.csproj index 341fa72218a0..d0ca069cc1c3 100644 --- a/src/Components/Server/test/Microsoft.AspNetCore.Components.Server.Tests.csproj +++ b/src/Components/Server/test/Microsoft.AspNetCore.Components.Server.Tests.csproj @@ -8,6 +8,7 @@ + diff --git a/src/Components/test/E2ETest/ServerExecutionTests/ServerResumeTests.cs b/src/Components/test/E2ETest/ServerExecutionTests/ServerResumeTests.cs index 5ad43fc2c634..f79d5064c8b4 100644 --- a/src/Components/test/E2ETest/ServerExecutionTests/ServerResumeTests.cs +++ b/src/Components/test/E2ETest/ServerExecutionTests/ServerResumeTests.cs @@ -208,3 +208,15 @@ protected override void InitializeAsyncCore() Browser.Exists(By.CssSelector("#components-reconnect-modal[data-nosnippet]")); } } + +public class HybridCacheServerResumeTests : ServerResumeTests +{ + public HybridCacheServerResumeTests( + BrowserFixture browserFixture, + BasicTestAppServerSiteFixture> serverFixture, + ITestOutputHelper output) + : base(browserFixture, serverFixture, output) + { + serverFixture.AdditionalArguments.AddRange("--UseHybridCache", "true"); + } +} diff --git a/src/Components/test/testassets/Components.TestServer/Components.TestServer.csproj b/src/Components/test/testassets/Components.TestServer/Components.TestServer.csproj index 1341319c942c..b7d6d2b019b8 100644 --- a/src/Components/test/testassets/Components.TestServer/Components.TestServer.csproj +++ b/src/Components/test/testassets/Components.TestServer/Components.TestServer.csproj @@ -28,6 +28,7 @@ + diff --git a/src/Components/test/testassets/Components.TestServer/Program.cs b/src/Components/test/testassets/Components.TestServer/Program.cs index bbe5562ddf3a..800ad27ddce4 100644 --- a/src/Components/test/testassets/Components.TestServer/Program.cs +++ b/src/Components/test/testassets/Components.TestServer/Program.cs @@ -33,6 +33,7 @@ public static async Task Main(string[] args) ["Server-side blazor"] = (BuildWebHost(CreateAdditionalArgs(args)), "/subdir"), ["Blazor web with server-side blazor root component"] = (BuildWebHost>(CreateAdditionalArgs(args)), "/subdir"), ["Blazor web with server-side reconnection disabled"] = (BuildWebHost>(CreateAdditionalArgs([.. args, "--DisableReconnectionCache", "true"])), "/subdir"), + ["Blazor web with server-side hybrid cache"] = (BuildWebHost>(CreateAdditionalArgs([.. args, "--DisableReconnectionCache", "true", "--UseHybridCache", "true"])), "/subdir"), ["Hosted client-side blazor"] = (BuildWebHost(CreateAdditionalArgs(args)), "/subdir"), ["Hot Reload"] = (BuildWebHost(CreateAdditionalArgs(args)), "/subdir"), ["Dev server client-side blazor"] = CreateDevServerHost(CreateAdditionalArgs(args)), diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs index eedeacc7ef57..4bb4bee5014c 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs +++ b/src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs @@ -44,6 +44,7 @@ public void ConfigureServices(IServiceCollection services) { // This disables the reconnection cache, which forces the server to persist the circuit state. options.DisconnectedCircuitMaxRetained = 0; + options.DetailedErrors = true; } }) .AddAuthenticationStateSerialization(options => @@ -52,6 +53,11 @@ public void ConfigureServices(IServiceCollection services) options.SerializeAllClaims = serializeAllClaims; }); + if (Configuration.GetValue("UseHybridCache")) + { + services.AddHybridCache(); + } + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Root.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Root.razor index e6c80d54a12a..07a5daa32bde 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Root.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Root.razor @@ -65,7 +65,7 @@ startButton.textContent = 'Call Blazor.start()'; startButton.onclick = callBlazorStart; document.body.appendChild(startButton); - } + } @if(CustomReconnectUI) {