Skip to content

[Blazor] Support HybridCache backend for persistent component state #62299

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jun 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions eng/Dependencies.props
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ and are generated based on the last package release.
<LatestPackageReference Include="Microsoft.DotNet.HotReload.Agent.Data" />
<LatestPackageReference Include="Microsoft.Extensions.Caching.Abstractions" />
<LatestPackageReference Include="Microsoft.Extensions.Caching.Memory" />
<LatestPackageReference Include="Microsoft.Extensions.Caching.Hybrid" />
<LatestPackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<LatestPackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<LatestPackageReference Include="Microsoft.Extensions.Configuration.CommandLine" />
Expand Down
4 changes: 4 additions & 0 deletions eng/Version.Details.xml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@
<Uri>https://github.com/dotnet/dotnet</Uri>
<Sha>20fdc50b34ee89e7c54eef0a193c30ed4816597a</Sha>
</Dependency>
<Dependency Name="Microsoft.Extensions.Caching.Hybrid" Version="10.0.0-alpha.2.24462.2">
<Uri>https://github.com/dotnet/dotnet</Uri>
<Sha>f485d74550db5bd91617accc1bb548ae6013756b</Sha>
</Dependency>
<Dependency Name="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0-preview.6.25311.102">
<Uri>https://github.com/dotnet/dotnet</Uri>
<Sha>20fdc50b34ee89e7c54eef0a193c30ed4816597a</Sha>
Expand Down
1 change: 1 addition & 0 deletions eng/Versions.props
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
<MicrosoftNETCoreBrowserDebugHostTransportVersion>10.0.0-preview.6.25311.102</MicrosoftNETCoreBrowserDebugHostTransportVersion>
<MicrosoftExtensionsCachingAbstractionsVersion>10.0.0-preview.6.25311.102</MicrosoftExtensionsCachingAbstractionsVersion>
<MicrosoftExtensionsCachingMemoryVersion>10.0.0-preview.6.25311.102</MicrosoftExtensionsCachingMemoryVersion>
<MicrosoftExtensionsCachingHybridVersion>10.0.0-alpha.2.24462.2</MicrosoftExtensionsCachingHybridVersion>
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@wtgodbe @mgravell Do you know why there aren't 10.0.0-preview6 versions on our feeds?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

<MicrosoftExtensionsConfigurationAbstractionsVersion>10.0.0-preview.6.25311.102</MicrosoftExtensionsConfigurationAbstractionsVersion>
<MicrosoftExtensionsConfigurationBinderVersion>10.0.0-preview.6.25311.102</MicrosoftExtensionsConfigurationBinderVersion>
<MicrosoftExtensionsConfigurationCommandLineVersion>10.0.0-preview.6.25311.102</MicrosoftExtensionsConfigurationCommandLineVersion>
Expand Down
27 changes: 26 additions & 1 deletion src/Components/Server/src/CircuitOptions.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
Expand Down Expand Up @@ -49,17 +51,40 @@ public sealed class CircuitOptions
/// are retained in memory by the server when no distributed cache is configured.
/// </summary>
/// <remarks>
/// When using a distributed cache like <see cref="Extensions.Caching.Hybrid.HybridCache"/> this value is ignored
/// <para>
/// When using a distributed cache like <see cref="HybridCache"/> this value is ignored
/// and the configuration from <see cref="Extensions.DependencyInjection.MemoryCacheServiceCollectionExtensions.AddMemoryCache(Extensions.DependencyInjection.IServiceCollection)"/>
/// is used instead.
/// </para>
/// <para>
/// To explicitly control the in memory cache limits when using a distributed cache. Setup a separate instance in <see cref="HybridPersistenceCache"/> with
/// the desired configuration.
/// </para>
/// </remarks>
public int PersistedCircuitInMemoryMaxRetained { get; set; } = 1000;

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

/// <summary>
/// Gets or sets the duration for which a persisted circuit is retained in the distributed cache.
/// </summary>
/// <remarks>
/// This setting is ignored when using an in-memory cache implementation.
/// </remarks>
public TimeSpan? PersistedCircuitDistributedRetentionPeriod { get; set; } = TimeSpan.FromHours(8);

/// <summary>
/// Gets or sets the <see cref="HybridCache"/> instance to use for persisting circuit state across servers.
/// </summary>
public HybridCache? HybridPersistenceCache { get; set; }

/// <summary>
/// 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.
Expand Down
35 changes: 35 additions & 0 deletions src/Components/Server/src/Circuits/DefaultHybridCache.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Default configuration for <see cref="CircuitOptions.HybridPersistenceCache"/>.
/// </summary>
internal sealed class DefaultHybridCache : IPostConfigureOptions<CircuitOptions>
{
private readonly HybridCache? _hybridCache;

/// <summary>
/// Initializes a new instance of <see cref="DefaultHybridCache"/>.
/// </summary>
/// <param name="hybridCache">The <see cref="HybridCache"/> service, if available.</param>
public DefaultHybridCache(HybridCache? hybridCache = null)
{
_hybridCache = hybridCache;
}

/// <inheritdoc />
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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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<CancellationToken, ValueTask<PersistedCircuitState>> _failOnCreate =
static ct => throw new InvalidOperationException();

private static readonly string[] _tags = ["Microsoft.AspNetCore.Components.Server.PersistedCircuitState"];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the primary use of tags is for remove-by-tags-async, but I don't see that used; is this forward thinking to when that might be needed?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is about making sure that we are "good citizens" of the shared cache and that people have a way of specifically cleaning these entries. Is this the pattern or should I do it in a different way?


private readonly SemaphoreSlim _lock = new(1, 1);
private readonly HybridCache _hybridCache;
private readonly ILogger<ICircuitPersistenceProvider> _logger;
private readonly HybridCacheEntryOptions _cacheWriteOptions;
private readonly HybridCacheEntryOptions _cacheReadOptions;

public HybridCacheCircuitPersistenceProvider(
HybridCache hybridCache,
ILogger<ICircuitPersistenceProvider> logger,
IOptions<CircuitOptions> 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<PersistedCircuitState> RestoreCircuitAsync(CircuitId circuitId, CancellationToken cancellation = default)
{
Log.CircuitResumeStarted(_logger, circuitId);

try
{
await _lock.WaitAsync(cancellation);
var state = await _hybridCache.GetOrCreateAsync(
circuitId.Secret,
Copy link
Member

@mgravell mgravell Jun 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know a lot about "circuitid", but: is this unique enough vs other potential parallel HC uses? there may be some benefit in a key prefix, and if you do that, I strongly recommend using interpolated string literals, i.e. $"circuits/{circuitId.Secret}", because that should start benefitting from zero-alloc paths in .NET 10 (i.e. for cache hits, it doesn't actually allocate a string for the composed value, via voodoo)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

edit: and then I see that this is only used in the "we need to remove it" case, in which case: you will need the string, but: just to say, there may still be value in other cases....

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, the circuit id is 32 bytes of entropy so no chance we hit that :)

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);
}
}
4 changes: 2 additions & 2 deletions src/Components/Server/src/Circuits/PersistedCircuitState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits;
[DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")]
internal class PersistedCircuitState
{
public IReadOnlyDictionary<string, byte[]> ApplicationState { get; internal set; }
public IReadOnlyDictionary<string, byte[]> ApplicationState { get; set; }

public byte[] RootComponents { get; internal set; }
public byte[] RootComponents { get; set; }

private string GetDebuggerDisplay()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -77,11 +78,29 @@ public static IServerSideBlazorBuilder AddServerSideBlazor(this IServiceCollecti

services.TryAddScoped(s => s.GetRequiredService<ICircuitAccessor>().Circuit);
services.TryAddScoped<ICircuitAccessor, DefaultCircuitAccessor>();

services.TryAddSingleton<ISystemClock, SystemClock>();
services.TryAddSingleton<CircuitRegistry>();
services.TryAddSingleton<CircuitPersistenceManager>();
services.TryAddSingleton<ICircuitPersistenceProvider, DefaultInMemoryCircuitPersistenceProvider>();

// Register the circuit persistence provider conditionally based on HybridCache availability
services.TryAddSingleton<ICircuitPersistenceProvider>(serviceProvider =>
{
var circuitOptions = serviceProvider.GetRequiredService<IOptions<CircuitOptions>>();
if (circuitOptions.Value.HybridPersistenceCache is not null)
{
var logger = serviceProvider.GetRequiredService<ILogger<ICircuitPersistenceProvider>>();
return new HybridCacheCircuitPersistenceProvider(circuitOptions.Value.HybridPersistenceCache, logger, circuitOptions);
}
else
{
var logger = serviceProvider.GetRequiredService<ILogger<ICircuitPersistenceProvider>>();
var clock = serviceProvider.GetRequiredService<ISystemClock>();
return new DefaultInMemoryCircuitPersistenceProvider(clock, logger, circuitOptions);
}
});

// Register the configurator for HybridCache
services.TryAddEnumerable(ServiceDescriptor.Singleton<IPostConfigureOptions<CircuitOptions>, DefaultHybridCache>());

// Standard blazor hosting services implementations
//
Expand Down
4 changes: 4 additions & 0 deletions src/Components/Server/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading
Loading