Skip to content

Commit 2ee09f5

Browse files
authored
[Blazor] Adds support for persisting and restoring disconnected circuits from storage (#62259)
* Circuit persistence implementation * Added sample * Fix DI issues * Temporarily add JS bits * Fixes * Refine ts * Wire up the default handler * Sample cleanups * Provide expiration timeout for the serialized descriptors * tmp * Add tests * More tests * More tests * MemoryPersistenceProvider tests * Add E2E test * Cleanup E2E test * Undo sample changes * Fix build * Tmp * Address feedback * Fix test * Improve tests * Remove unnecessary files
1 parent 450d6ef commit 2ee09f5

File tree

46 files changed

+2124
-114
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+2124
-114
lines changed

src/Components/Endpoints/src/Microsoft.AspNetCore.Components.Endpoints.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
<Compile Include="$(SharedSourceRoot)Components\ServerComponentSerializationSettings.cs" LinkBase="DependencyInjection" />
2424
<Compile Include="$(SharedSourceRoot)Components\ServerComponent.cs" LinkBase="DependencyInjection" />
2525
<Compile Include="$(SharedSourceRoot)Components\ResourceCollectionResolver.cs" LinkBase="Assets" />
26+
<Compile Include="$(SharedSourceRoot)Components\ServerComponentSerializer.cs" LinkBase="DependencyInjection" />
27+
<Compile Include="$(SharedSourceRoot)Components\ServerComponentInvocationSequence.cs" LinkBase="DependencyInjection" />
2628
<Compile Include="$(RepoRoot)src\Shared\Components\ComponentParameter.cs" LinkBase="DependencyInjection" />
2729
<Compile Include="$(RepoRoot)src\Shared\Components\PrerenderComponentApplicationStore.cs" LinkBase="DependencyInjection" />
2830
<Compile Include="$(RepoRoot)src\Shared\Components\ProtectedPrerenderComponentApplicationStore.cs" LinkBase="DependencyInjection" />

src/Components/Server/src/CircuitOptions.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,22 @@ public sealed class CircuitOptions
4444
/// </value>
4545
public TimeSpan DisconnectedCircuitRetentionPeriod { get; set; } = TimeSpan.FromMinutes(3);
4646

47+
/// <summary>
48+
/// Gets or sets a value that determines the maximum number of persisted circuits state that
49+
/// are retained in memory by the server when no distributed cache is configured.
50+
/// </summary>
51+
/// <remarks>
52+
/// When using a distributed cache like <see cref="Extensions.Caching.Hybrid.HybridCache"/> this value is ignored
53+
/// and the configuration from <see cref="Extensions.DependencyInjection.MemoryCacheServiceCollectionExtensions.AddMemoryCache(Extensions.DependencyInjection.IServiceCollection)"/>
54+
/// is used instead.
55+
/// </remarks>
56+
public int PersistedCircuitInMemoryMaxRetained { get; set; } = 1000;
57+
58+
/// <summary>
59+
/// Gets or sets the duration for which a persisted circuit is retained in memory.
60+
/// </summary>
61+
public TimeSpan PersistedCircuitInMemoryRetentionPeriod { get; set; } = TimeSpan.FromHours(2);
62+
4763
/// <summary>
4864
/// Gets or sets a value that determines whether or not to send detailed exception messages to JavaScript when an unhandled exception
4965
/// happens on the circuit or when a .NET method invocation through JS interop results in an exception.

src/Components/Server/src/Circuits/CircuitHost.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ internal partial class CircuitHost : IAsyncDisposable
3232
private bool _isFirstUpdate = true;
3333
private bool _disposed;
3434
private long _startTime;
35+
private PersistedCircuitState _persistedCircuitState;
3536

3637
// This event is fired when there's an unrecoverable exception coming from the circuit, and
3738
// it need so be torn down. The registry listens to this even so that the circuit can
@@ -106,6 +107,8 @@ public CircuitHost(
106107

107108
public IServiceProvider Services { get; }
108109

110+
internal bool HasPendingPersistedCircuitState => _persistedCircuitState != null;
111+
109112
// InitializeAsync is used in a fire-and-forget context, so it's responsible for its own
110113
// error handling.
111114
public Task InitializeAsync(ProtectedPrerenderComponentApplicationStore store, ActivityContext httpContext, CancellationToken cancellationToken)
@@ -873,6 +876,23 @@ await HandleInboundActivityAsync(() =>
873876
}
874877
}
875878

879+
internal void AttachPersistedState(PersistedCircuitState persistedCircuitState)
880+
{
881+
if (_persistedCircuitState != null)
882+
{
883+
throw new InvalidOperationException("Persisted state has already been attached to this circuit.");
884+
}
885+
886+
_persistedCircuitState = persistedCircuitState;
887+
}
888+
889+
internal PersistedCircuitState TakePersistedCircuitState()
890+
{
891+
var result = _persistedCircuitState;
892+
_persistedCircuitState = null;
893+
return result;
894+
}
895+
876896
private static partial class Log
877897
{
878898
// 100s used for lifecycle stuff
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
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 System.Text.Json;
5+
using System.Text.Json.Serialization;
6+
using Microsoft.AspNetCore.Components.Endpoints;
7+
using Microsoft.AspNetCore.Components.Infrastructure;
8+
using Microsoft.AspNetCore.Components.Web;
9+
using Microsoft.Extensions.DependencyInjection;
10+
using Microsoft.Extensions.Options;
11+
12+
namespace Microsoft.AspNetCore.Components.Server.Circuits;
13+
14+
internal partial class CircuitPersistenceManager(
15+
IOptions<CircuitOptions> circuitOptions,
16+
ServerComponentSerializer serverComponentSerializer,
17+
ICircuitPersistenceProvider circuitPersistenceProvider)
18+
{
19+
public async Task PauseCircuitAsync(CircuitHost circuit, CancellationToken cancellation = default)
20+
{
21+
var renderer = circuit.Renderer;
22+
var persistenceManager = circuit.Services.GetRequiredService<ComponentStatePersistenceManager>();
23+
var collector = new CircuitPersistenceManagerCollector(circuitOptions, serverComponentSerializer, circuit.Renderer);
24+
using var subscription = persistenceManager.State.RegisterOnPersisting(
25+
collector.PersistRootComponents,
26+
RenderMode.InteractiveServer);
27+
28+
await persistenceManager.PersistStateAsync(collector, renderer);
29+
30+
await circuitPersistenceProvider.PersistCircuitAsync(
31+
circuit.CircuitId,
32+
collector.PersistedCircuitState,
33+
cancellation);
34+
}
35+
36+
public async Task<PersistedCircuitState> ResumeCircuitAsync(CircuitId circuitId, CancellationToken cancellation = default)
37+
{
38+
return await circuitPersistenceProvider.RestoreCircuitAsync(circuitId, cancellation);
39+
}
40+
41+
// We are going to construct a RootComponentOperationBatch but we are going to replace the descriptors from the client with the
42+
// descriptors that we have persisted when pausing the circuit.
43+
// The way pausing and resuming works is that when the client starts the resume process, it 'simulates' that an SSR has happened and
44+
// queues an 'Add' operation for each server-side component that is on the document.
45+
// That ends up calling UpdateRootComponents with the old descriptors and no application state.
46+
// On the server side, we replace the descriptors with the ones that we have persisted. We can't use the original descriptors because
47+
// those have a lifetime of ~ 5 minutes, after which we are not able to unprotect them anymore.
48+
internal static RootComponentOperationBatch ToRootComponentOperationBatch(
49+
IServerComponentDeserializer serverComponentDeserializer,
50+
byte[] rootComponents,
51+
string serializedComponentOperations)
52+
{
53+
// Deserialize the existing batch the client has sent but ignore the markers
54+
if (!serverComponentDeserializer.TryDeserializeRootComponentOperations(
55+
serializedComponentOperations,
56+
out var batch,
57+
deserializeDescriptors: false))
58+
{
59+
return null;
60+
}
61+
62+
var persistedMarkers = TryDeserializeMarkers(rootComponents);
63+
64+
if (persistedMarkers == null)
65+
{
66+
return null;
67+
}
68+
69+
if (batch.Operations.Length != persistedMarkers.Count)
70+
{
71+
return null;
72+
}
73+
74+
// Ensure that all operations in the batch are `Add` operations.
75+
for (var i = 0; i < batch.Operations.Length; i++)
76+
{
77+
var operation = batch.Operations[i];
78+
if (operation.Type != RootComponentOperationType.Add)
79+
{
80+
return null;
81+
}
82+
83+
// Retrieve the marker from the persisted root components, replace it and deserialize the descriptor
84+
if (!persistedMarkers.TryGetValue(operation.SsrComponentId, out var marker))
85+
{
86+
return null;
87+
}
88+
operation.Marker = marker;
89+
90+
if (!serverComponentDeserializer.TryDeserializeWebRootComponentDescriptor(operation.Marker.Value, out var descriptor))
91+
{
92+
return null;
93+
}
94+
95+
operation.Descriptor = descriptor;
96+
}
97+
98+
return batch;
99+
100+
static Dictionary<int, ComponentMarker> TryDeserializeMarkers(byte[] rootComponents)
101+
{
102+
if (rootComponents == null || rootComponents.Length == 0)
103+
{
104+
return null;
105+
}
106+
107+
try
108+
{
109+
return JsonSerializer.Deserialize<Dictionary<int, ComponentMarker>>(
110+
rootComponents,
111+
JsonSerializerOptionsProvider.Options);
112+
}
113+
catch
114+
{
115+
return null;
116+
}
117+
}
118+
}
119+
120+
private class CircuitPersistenceManagerCollector(
121+
IOptions<CircuitOptions> circuitOptions,
122+
ServerComponentSerializer serverComponentSerializer,
123+
RemoteRenderer renderer)
124+
: IPersistentComponentStateStore
125+
{
126+
internal PersistedCircuitState PersistedCircuitState { get; private set; }
127+
128+
public Task PersistRootComponents()
129+
{
130+
var persistedComponents = new Dictionary<int, ComponentMarker>();
131+
var components = renderer.GetOrCreateWebRootComponentManager().GetRootComponents();
132+
var invocation = new ServerComponentInvocationSequence();
133+
foreach (var (id, componentKey, (componentType, parameters)) in components)
134+
{
135+
var distributedRetention = circuitOptions.Value.PersistedCircuitInMemoryRetentionPeriod;
136+
var localRetention = circuitOptions.Value.PersistedCircuitInMemoryRetentionPeriod;
137+
var maxRetention = distributedRetention > localRetention ? distributedRetention : localRetention;
138+
139+
var marker = ComponentMarker.Create(ComponentMarker.ServerMarkerType, prerendered: false, componentKey);
140+
serverComponentSerializer.SerializeInvocation(ref marker, invocation, componentType, parameters, maxRetention);
141+
persistedComponents.Add(id, marker);
142+
}
143+
144+
PersistedCircuitState = new PersistedCircuitState
145+
{
146+
RootComponents = JsonSerializer.SerializeToUtf8Bytes(
147+
persistedComponents,
148+
CircuitPersistenceManagerSerializerContext.Default.DictionaryInt32ComponentMarker)
149+
};
150+
151+
return Task.CompletedTask;
152+
}
153+
154+
// This store only support serializing the state
155+
Task<IDictionary<string, byte[]>> IPersistentComponentStateStore.GetPersistedStateAsync() => throw new NotImplementedException();
156+
157+
// During the persisting phase the state is captured into a Dictionary<string, byte[]>, our implementation registers
158+
// a callback so that it can run at the same time as the other components' state is persisted.
159+
// We then are called to save the persisted state, at which point, we extract the component records
160+
// and store them separately from the other state.
161+
Task IPersistentComponentStateStore.PersistStateAsync(IReadOnlyDictionary<string, byte[]> state)
162+
{
163+
PersistedCircuitState.ApplicationState = state;
164+
return Task.CompletedTask;
165+
}
166+
}
167+
168+
[JsonSerializable(typeof(Dictionary<int, ComponentMarker>))]
169+
internal partial class CircuitPersistenceManagerSerializerContext : JsonSerializerContext
170+
{
171+
}
172+
}

src/Components/Server/src/Circuits/CircuitRegistry.cs

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,16 +41,19 @@ internal partial class CircuitRegistry
4141
private readonly CircuitOptions _options;
4242
private readonly ILogger _logger;
4343
private readonly CircuitIdFactory _circuitIdFactory;
44+
private readonly CircuitPersistenceManager _circuitPersistenceManager;
4445
private readonly PostEvictionCallbackRegistration _postEvictionCallback;
4546

4647
public CircuitRegistry(
4748
IOptions<CircuitOptions> options,
4849
ILogger<CircuitRegistry> logger,
49-
CircuitIdFactory CircuitHostFactory)
50+
CircuitIdFactory CircuitHostFactory,
51+
CircuitPersistenceManager circuitPersistenceManager)
5052
{
5153
_options = options.Value;
5254
_logger = logger;
5355
_circuitIdFactory = CircuitHostFactory;
56+
_circuitPersistenceManager = circuitPersistenceManager;
5457
ConnectedCircuits = new ConcurrentDictionary<CircuitId, CircuitHost>();
5558

5659
DisconnectedCircuits = new MemoryCache(new MemoryCacheOptions
@@ -265,7 +268,7 @@ protected virtual void OnEntryEvicted(object key, object value, EvictionReason r
265268
// Kick off the dispose in the background.
266269
var disconnectedEntry = (DisconnectedCircuitEntry)value;
267270
Log.CircuitEvicted(_logger, disconnectedEntry.CircuitHost.CircuitId, reason);
268-
_ = DisposeCircuitEntry(disconnectedEntry);
271+
_ = PauseAndDisposeCircuitEntry(disconnectedEntry);
269272
break;
270273

271274
case EvictionReason.Removed:
@@ -278,12 +281,23 @@ protected virtual void OnEntryEvicted(object key, object value, EvictionReason r
278281
}
279282
}
280283

281-
private async Task DisposeCircuitEntry(DisconnectedCircuitEntry entry)
284+
private async Task PauseAndDisposeCircuitEntry(DisconnectedCircuitEntry entry)
282285
{
283286
DisposeTokenSource(entry);
284287

285288
try
286289
{
290+
if (!entry.CircuitHost.HasPendingPersistedCircuitState)
291+
{
292+
// Only pause and persist the circuit state if it has been active at some point,
293+
// meaning that the client called UpdateRootComponents on it.
294+
await _circuitPersistenceManager.PauseCircuitAsync(entry.CircuitHost);
295+
}
296+
else
297+
{
298+
Log.PersistedCircuitStateDiscarded(_logger, entry.CircuitHost.CircuitId);
299+
}
300+
287301
entry.CircuitHost.UnhandledException -= CircuitHost_UnhandledException;
288302
await entry.CircuitHost.DisposeAsync();
289303
}
@@ -413,5 +427,8 @@ public static void ExceptionDisposingTokenSource(ILogger logger, Exception excep
413427

414428
[LoggerMessage(115, LogLevel.Debug, "Reconnect to circuit with id {CircuitId} succeeded.", EventName = "ReconnectionSucceeded")]
415429
public static partial void ReconnectionSucceeded(ILogger logger, CircuitId circuitId);
430+
431+
[LoggerMessage(116, LogLevel.Debug, "Circuit {CircuitId} was not resumed. Persisted circuit state for {CircuitId} discarded.", EventName = "PersistedCircuitStateDiscarded")]
432+
public static partial void PersistedCircuitStateDiscarded(ILogger logger, CircuitId circuitId);
416433
}
417434
}

0 commit comments

Comments
 (0)