Skip to content

Commit 336519c

Browse files
authored
[Blazor] Support HybridCache backend for persistent component state (#62299)
* Adds support for HybridCache as a backend for persisting component state. * The implementation automatically switches to use HybridCache if one is registered on DI. * A custom HybridCache instance can be set in options for full control over the limits.
1 parent c40323a commit 336519c

16 files changed

+354
-6
lines changed

eng/Dependencies.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ and are generated based on the last package release.
2929
<LatestPackageReference Include="Microsoft.DotNet.HotReload.Agent.Data" />
3030
<LatestPackageReference Include="Microsoft.Extensions.Caching.Abstractions" />
3131
<LatestPackageReference Include="Microsoft.Extensions.Caching.Memory" />
32+
<LatestPackageReference Include="Microsoft.Extensions.Caching.Hybrid" />
3233
<LatestPackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
3334
<LatestPackageReference Include="Microsoft.Extensions.Configuration.Binder" />
3435
<LatestPackageReference Include="Microsoft.Extensions.Configuration.CommandLine" />

eng/Version.Details.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@
5151
<Uri>https://github.com/dotnet/dotnet</Uri>
5252
<Sha>20fdc50b34ee89e7c54eef0a193c30ed4816597a</Sha>
5353
</Dependency>
54+
<Dependency Name="Microsoft.Extensions.Caching.Hybrid" Version="10.0.0-alpha.2.24462.2">
55+
<Uri>https://github.com/dotnet/dotnet</Uri>
56+
<Sha>f485d74550db5bd91617accc1bb548ae6013756b</Sha>
57+
</Dependency>
5458
<Dependency Name="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0-preview.6.25311.102">
5559
<Uri>https://github.com/dotnet/dotnet</Uri>
5660
<Sha>20fdc50b34ee89e7c54eef0a193c30ed4816597a</Sha>

eng/Versions.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
<MicrosoftNETCoreBrowserDebugHostTransportVersion>10.0.0-preview.6.25311.102</MicrosoftNETCoreBrowserDebugHostTransportVersion>
7575
<MicrosoftExtensionsCachingAbstractionsVersion>10.0.0-preview.6.25311.102</MicrosoftExtensionsCachingAbstractionsVersion>
7676
<MicrosoftExtensionsCachingMemoryVersion>10.0.0-preview.6.25311.102</MicrosoftExtensionsCachingMemoryVersion>
77+
<MicrosoftExtensionsCachingHybridVersion>10.0.0-alpha.2.24462.2</MicrosoftExtensionsCachingHybridVersion>
7778
<MicrosoftExtensionsConfigurationAbstractionsVersion>10.0.0-preview.6.25311.102</MicrosoftExtensionsConfigurationAbstractionsVersion>
7879
<MicrosoftExtensionsConfigurationBinderVersion>10.0.0-preview.6.25311.102</MicrosoftExtensionsConfigurationBinderVersion>
7980
<MicrosoftExtensionsConfigurationCommandLineVersion>10.0.0-preview.6.25311.102</MicrosoftExtensionsConfigurationCommandLineVersion>

src/Components/Server/src/CircuitOptions.cs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using Microsoft.Extensions.Caching.Hybrid;
5+
46
namespace Microsoft.AspNetCore.Components.Server;
57

68
/// <summary>
@@ -49,17 +51,40 @@ public sealed class CircuitOptions
4951
/// are retained in memory by the server when no distributed cache is configured.
5052
/// </summary>
5153
/// <remarks>
52-
/// When using a distributed cache like <see cref="Extensions.Caching.Hybrid.HybridCache"/> this value is ignored
54+
/// <para>
55+
/// When using a distributed cache like <see cref="HybridCache"/> this value is ignored
5356
/// and the configuration from <see cref="Extensions.DependencyInjection.MemoryCacheServiceCollectionExtensions.AddMemoryCache(Extensions.DependencyInjection.IServiceCollection)"/>
5457
/// is used instead.
58+
/// </para>
59+
/// <para>
60+
/// To explicitly control the in memory cache limits when using a distributed cache. Setup a separate instance in <see cref="HybridPersistenceCache"/> with
61+
/// the desired configuration.
62+
/// </para>
5563
/// </remarks>
5664
public int PersistedCircuitInMemoryMaxRetained { get; set; } = 1000;
5765

5866
/// <summary>
5967
/// Gets or sets the duration for which a persisted circuit is retained in memory.
6068
/// </summary>
69+
/// <remarks>
70+
/// When using a <see cref="HybridCache"/> based implementation this value
71+
/// is used for the local cache retention period.
72+
/// </remarks>
6173
public TimeSpan PersistedCircuitInMemoryRetentionPeriod { get; set; } = TimeSpan.FromHours(2);
6274

75+
/// <summary>
76+
/// Gets or sets the duration for which a persisted circuit is retained in the distributed cache.
77+
/// </summary>
78+
/// <remarks>
79+
/// This setting is ignored when using an in-memory cache implementation.
80+
/// </remarks>
81+
public TimeSpan? PersistedCircuitDistributedRetentionPeriod { get; set; } = TimeSpan.FromHours(8);
82+
83+
/// <summary>
84+
/// Gets or sets the <see cref="HybridCache"/> instance to use for persisting circuit state across servers.
85+
/// </summary>
86+
public HybridCache? HybridPersistenceCache { get; set; }
87+
6388
/// <summary>
6489
/// Gets or sets a value that determines whether or not to send detailed exception messages to JavaScript when an unhandled exception
6590
/// happens on the circuit or when a .NET method invocation through JS interop results in an exception.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Microsoft.Extensions.Caching.Hybrid;
5+
using Microsoft.Extensions.Options;
6+
7+
namespace Microsoft.AspNetCore.Components.Server.Circuits;
8+
9+
/// <summary>
10+
/// Default configuration for <see cref="CircuitOptions.HybridPersistenceCache"/>.
11+
/// </summary>
12+
internal sealed class DefaultHybridCache : IPostConfigureOptions<CircuitOptions>
13+
{
14+
private readonly HybridCache? _hybridCache;
15+
16+
/// <summary>
17+
/// Initializes a new instance of <see cref="DefaultHybridCache"/>.
18+
/// </summary>
19+
/// <param name="hybridCache">The <see cref="HybridCache"/> service, if available.</param>
20+
public DefaultHybridCache(HybridCache? hybridCache = null)
21+
{
22+
_hybridCache = hybridCache;
23+
}
24+
25+
/// <inheritdoc />
26+
public void PostConfigure(string? name, CircuitOptions options)
27+
{
28+
// Only set the HybridPersistenceCache if it hasn't been explicitly configured
29+
// and a HybridCache service is available
30+
if (options.HybridPersistenceCache is null && _hybridCache is not null)
31+
{
32+
options.HybridPersistenceCache = _hybridCache;
33+
}
34+
}
35+
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Microsoft.Extensions.Caching.Hybrid;
5+
using Microsoft.Extensions.Logging;
6+
using Microsoft.Extensions.Options;
7+
8+
namespace Microsoft.AspNetCore.Components.Server.Circuits;
9+
10+
// Implementation of ICircuitPersistenceProvider that uses HybridCache for distributed caching
11+
internal sealed partial class HybridCacheCircuitPersistenceProvider : ICircuitPersistenceProvider
12+
{
13+
private static readonly Func<CancellationToken, ValueTask<PersistedCircuitState>> _failOnCreate =
14+
static ct => throw new InvalidOperationException();
15+
16+
private static readonly string[] _tags = ["Microsoft.AspNetCore.Components.Server.PersistedCircuitState"];
17+
18+
private readonly SemaphoreSlim _lock = new(1, 1);
19+
private readonly HybridCache _hybridCache;
20+
private readonly ILogger<ICircuitPersistenceProvider> _logger;
21+
private readonly HybridCacheEntryOptions _cacheWriteOptions;
22+
private readonly HybridCacheEntryOptions _cacheReadOptions;
23+
24+
public HybridCacheCircuitPersistenceProvider(
25+
HybridCache hybridCache,
26+
ILogger<ICircuitPersistenceProvider> logger,
27+
IOptions<CircuitOptions> options)
28+
{
29+
_hybridCache = hybridCache;
30+
_logger = logger;
31+
_cacheWriteOptions = new HybridCacheEntryOptions
32+
{
33+
Expiration = options.Value.PersistedCircuitDistributedRetentionPeriod,
34+
LocalCacheExpiration = options.Value.PersistedCircuitInMemoryRetentionPeriod,
35+
};
36+
_cacheReadOptions = new HybridCacheEntryOptions
37+
{
38+
Flags = HybridCacheEntryFlags.DisableLocalCacheWrite |
39+
HybridCacheEntryFlags.DisableDistributedCacheWrite |
40+
HybridCacheEntryFlags.DisableUnderlyingData,
41+
};
42+
}
43+
44+
public async Task PersistCircuitAsync(CircuitId circuitId, PersistedCircuitState persistedCircuitState, CancellationToken cancellation = default)
45+
{
46+
Log.CircuitPauseStarted(_logger, circuitId);
47+
48+
try
49+
{
50+
await _lock.WaitAsync(cancellation);
51+
await _hybridCache.SetAsync(circuitId.Secret, persistedCircuitState, _cacheWriteOptions, _tags, cancellation);
52+
}
53+
catch (Exception ex)
54+
{
55+
Log.ExceptionPersistingCircuit(_logger, circuitId, ex);
56+
}
57+
finally
58+
{
59+
_lock.Release();
60+
}
61+
}
62+
63+
public async Task<PersistedCircuitState> RestoreCircuitAsync(CircuitId circuitId, CancellationToken cancellation = default)
64+
{
65+
Log.CircuitResumeStarted(_logger, circuitId);
66+
67+
try
68+
{
69+
await _lock.WaitAsync(cancellation);
70+
var state = await _hybridCache.GetOrCreateAsync(
71+
circuitId.Secret,
72+
factory: _failOnCreate,
73+
options: _cacheReadOptions,
74+
_tags,
75+
cancellation);
76+
77+
if (state == null)
78+
{
79+
Log.FailedToFindCircuitState(_logger, circuitId);
80+
return null;
81+
}
82+
83+
await _hybridCache.RemoveAsync(circuitId.Secret, cancellation);
84+
85+
Log.CircuitStateFound(_logger, circuitId);
86+
return state;
87+
}
88+
catch (Exception ex)
89+
{
90+
Log.ExceptionRestoringCircuit(_logger, circuitId, ex);
91+
return null;
92+
}
93+
finally
94+
{
95+
_lock.Release();
96+
}
97+
}
98+
99+
private static partial class Log
100+
{
101+
[LoggerMessage(201, LogLevel.Debug, "Circuit state evicted for circuit {CircuitId} due to {Reason}", EventName = "CircuitStateEvicted")]
102+
public static partial void CircuitStateEvicted(ILogger logger, CircuitId circuitId, string reason);
103+
104+
[LoggerMessage(202, LogLevel.Debug, "Resuming circuit with ID {CircuitId}", EventName = "CircuitResumeStarted")]
105+
public static partial void CircuitResumeStarted(ILogger logger, CircuitId circuitId);
106+
107+
[LoggerMessage(203, LogLevel.Debug, "Failed to find persisted circuit with ID {CircuitId}", EventName = "FailedToFindCircuitState")]
108+
public static partial void FailedToFindCircuitState(ILogger logger, CircuitId circuitId);
109+
110+
[LoggerMessage(204, LogLevel.Debug, "Circuit state found for circuit {CircuitId}", EventName = "CircuitStateFound")]
111+
public static partial void CircuitStateFound(ILogger logger, CircuitId circuitId);
112+
113+
[LoggerMessage(205, LogLevel.Error, "An exception occurred while disposing the token source.", EventName = "ExceptionDisposingTokenSource")]
114+
public static partial void ExceptionDisposingTokenSource(ILogger logger, Exception exception);
115+
116+
[LoggerMessage(206, LogLevel.Debug, "Pausing circuit with ID {CircuitId}", EventName = "CircuitPauseStarted")]
117+
public static partial void CircuitPauseStarted(ILogger logger, CircuitId circuitId);
118+
119+
[LoggerMessage(207, LogLevel.Error, "An exception occurred while persisting circuit {CircuitId}.", EventName = "ExceptionPersistingCircuit")]
120+
public static partial void ExceptionPersistingCircuit(ILogger logger, CircuitId circuitId, Exception exception);
121+
122+
[LoggerMessage(208, LogLevel.Error, "An exception occurred while restoring circuit {CircuitId}.", EventName = "ExceptionRestoringCircuit")]
123+
public static partial void ExceptionRestoringCircuit(ILogger logger, CircuitId circuitId, Exception exception);
124+
125+
[LoggerMessage(209, LogLevel.Error, "An exception occurred during expiration handling for circuit {CircuitId}.", EventName = "ExceptionDuringExpiration")]
126+
public static partial void ExceptionDuringExpiration(ILogger logger, CircuitId circuitId, Exception exception);
127+
128+
[LoggerMessage(210, LogLevel.Error, "An exception occurred while removing expired circuit {CircuitId}.", EventName = "ExceptionRemovingExpiredCircuit")]
129+
public static partial void ExceptionRemovingExpiredCircuit(ILogger logger, CircuitId circuitId, Exception exception);
130+
}
131+
}

src/Components/Server/src/Circuits/PersistedCircuitState.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits;
88
[DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")]
99
internal class PersistedCircuitState
1010
{
11-
public IReadOnlyDictionary<string, byte[]> ApplicationState { get; internal set; }
11+
public IReadOnlyDictionary<string, byte[]> ApplicationState { get; set; }
1212

13-
public byte[] RootComponents { get; internal set; }
13+
public byte[] RootComponents { get; set; }
1414

1515
private string GetDebuggerDisplay()
1616
{

src/Components/Server/src/DependencyInjection/ComponentServiceCollectionExtensions.cs

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
using Microsoft.AspNetCore.SignalR.Protocol;
1616
using Microsoft.Extensions.DependencyInjection.Extensions;
1717
using Microsoft.Extensions.Internal;
18+
using Microsoft.Extensions.Logging;
1819
using Microsoft.Extensions.Options;
1920
using Microsoft.JSInterop;
2021

@@ -77,11 +78,29 @@ public static IServerSideBlazorBuilder AddServerSideBlazor(this IServiceCollecti
7778

7879
services.TryAddScoped(s => s.GetRequiredService<ICircuitAccessor>().Circuit);
7980
services.TryAddScoped<ICircuitAccessor, DefaultCircuitAccessor>();
80-
8181
services.TryAddSingleton<ISystemClock, SystemClock>();
8282
services.TryAddSingleton<CircuitRegistry>();
8383
services.TryAddSingleton<CircuitPersistenceManager>();
84-
services.TryAddSingleton<ICircuitPersistenceProvider, DefaultInMemoryCircuitPersistenceProvider>();
84+
85+
// Register the circuit persistence provider conditionally based on HybridCache availability
86+
services.TryAddSingleton<ICircuitPersistenceProvider>(serviceProvider =>
87+
{
88+
var circuitOptions = serviceProvider.GetRequiredService<IOptions<CircuitOptions>>();
89+
if (circuitOptions.Value.HybridPersistenceCache is not null)
90+
{
91+
var logger = serviceProvider.GetRequiredService<ILogger<ICircuitPersistenceProvider>>();
92+
return new HybridCacheCircuitPersistenceProvider(circuitOptions.Value.HybridPersistenceCache, logger, circuitOptions);
93+
}
94+
else
95+
{
96+
var logger = serviceProvider.GetRequiredService<ILogger<ICircuitPersistenceProvider>>();
97+
var clock = serviceProvider.GetRequiredService<ISystemClock>();
98+
return new DefaultInMemoryCircuitPersistenceProvider(clock, logger, circuitOptions);
99+
}
100+
});
101+
102+
// Register the configurator for HybridCache
103+
services.TryAddEnumerable(ServiceDescriptor.Singleton<IPostConfigureOptions<CircuitOptions>, DefaultHybridCache>());
85104

86105
// Standard blazor hosting services implementations
87106
//

src/Components/Server/src/PublicAPI.Unshipped.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
#nullable enable
2+
Microsoft.AspNetCore.Components.Server.CircuitOptions.HybridPersistenceCache.get -> Microsoft.Extensions.Caching.Hybrid.HybridCache?
3+
Microsoft.AspNetCore.Components.Server.CircuitOptions.HybridPersistenceCache.set -> void
4+
Microsoft.AspNetCore.Components.Server.CircuitOptions.PersistedCircuitDistributedRetentionPeriod.get -> System.TimeSpan?
5+
Microsoft.AspNetCore.Components.Server.CircuitOptions.PersistedCircuitDistributedRetentionPeriod.set -> void
26
Microsoft.AspNetCore.Components.Server.CircuitOptions.PersistedCircuitInMemoryMaxRetained.get -> int
37
Microsoft.AspNetCore.Components.Server.CircuitOptions.PersistedCircuitInMemoryMaxRetained.set -> void
48
Microsoft.AspNetCore.Components.Server.CircuitOptions.PersistedCircuitInMemoryRetentionPeriod.get -> System.TimeSpan

0 commit comments

Comments
 (0)