Skip to content

[Blazor] Adds support for persisting and restoring disconnected circuits from storage #62259

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

Open
wants to merge 19 commits into
base: main
Choose a base branch
from

Conversation

javiercn
Copy link
Member

@javiercn javiercn commented Jun 5, 2025

Details to be added. There are some missing things on this PR that will be done in separate PRs:

  • Gracefully persisting a circuit, including sending the state to the client.
  • Persisting a circuit to distributed storage, which will use HybridCache as a base implementation.

The detailed design can be found #60494

The main changes from the original design as well as some additional details are:

  • We rely on HybridCache for handling storage to distributed storage mechanisms.
    • This means we don't have to create individual packages for different distributed backends (redis, blob).
  • We provide a default implementation that leverages MemoryCache (same as disconnected circuits in CircuitRegistry) and that stores a limited number of persisted circuits but for a longer period of time (2h is the default).
    • We expect the amount of memory used to persist the circuit state to be in the range of dozens to hundreds of KB.
      • The way we persist state on disconnection is equivalent to the way we do so during prerendering and persisting more than 100Kb starts to degrade the experience. (You wouldn't push 10MB of state to a client on the first request).
    • Keeping state around for longer is cheaper than keeping the circuit for several reasons:
      • The circuit might continue to do work even if disconnected and consume resources like CPU and memory. The persisted state only consumes a fixed amount of memory that the developer is in control of.
      • The persisted state represents a subset of all the memory consumed by the application (we don't have to keep track of the individual components and so on).
    • We retain the persisted circuit state for 2h by default in memory or until we run out of capacity. The default is chosen as a trade-off between the average session duration and the % of circuits that we fail to resume because we discarded the state.
      • Apps need to adjust this to their individual needs based on their user's usage.

@github-actions github-actions bot added the area-blazor Includes: Blazor, Razor Components label Jun 5, 2025
@javiercn javiercn marked this pull request as ready for review June 6, 2025 09:35
@javiercn javiercn requested a review from a team as a code owner June 6, 2025 09:35
@javiercn javiercn force-pushed the javiercn/persistent-component-state-ungraceful branch from 7116e55 to 70da07d Compare June 6, 2025 15:38
Copy link
Member

@MackinnonBuck MackinnonBuck left a comment

Choose a reason for hiding this comment

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

Looks great so far - still need to do a deeper review of some parts.

Comment on lines +23 to +34
var renderer = circuit.Renderer;
var persistenceManager = circuit.Services.GetRequiredService<ComponentStatePersistenceManager>();
using var subscription = persistenceManager.State.RegisterOnPersisting(
() => PersistRootComponents(renderer, persistenceManager.State),
RenderMode.InteractiveServer);
var store = new CircuitPersistenceManagerStore();
await persistenceManager.PersistStateAsync(store, renderer);

await circuitPersistenceProvider.PersistCircuitAsync(
circuit.CircuitId,
store.PersistedCircuitState,
cancellation);
Copy link
Member

Choose a reason for hiding this comment

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

If I'm understanding this correctly:

  1. We register a new callback to persist root component state
  2. We then immediately call PersistStateAsync(), which invokes the callback
  3. The callback serializes root components into markers (similar to how prerendering works, which makes sense), which then gets serialized into a JSON byte[] and stored in the Dictionary<string, byte[]> managed by the PersistentComponentState
  4. A custom IPersistentComponentStateStore then receives the aforementioned dictionary in its PersistStateAsync() method, at which point it extracts the root component state from the dictionary and constructs a new Dictionary<string, byte[]> without the root component state

Is this doing more work than necessary? Would it be possible to serialize root component state directly into a JSON byte[] without inserting it into the PersistentComponentState's dictionary only to extract it out shortly thereafter? Then we could just directly assign the root component byte[] to PersistedCircuitState.RootComponents, and directly assign the PersistentComponentState dictionary to PersistedCircuitState.ApplicationState without having to reconstruct it first.

I see that a comment later in this file mentions that the purpose of the callback is to have it run at the same time as other callbacks. Do we expect that removing the callback and instead performing the root component serialization directly after calling PersistStateAsync() would cause issues? If so, would it work to keep the callback but just not call state.PersistAsJson() and instead store the root component state separately to avoid reconstructing the dictionary? Totally possible that I'm missing a detail that makes this impractical!

var localRetention = circuitOptions.Value.PersistedCircuitInMemoryRetentionPeriod;
var maxRetention = distributedRetention > localRetention ? distributedRetention : localRetention;

var marker = ComponentMarker.Create(ComponentMarker.ServerMarkerType, false, componentKey);
Copy link
Member

Choose a reason for hiding this comment

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

Nit:

Suggested change
var marker = ComponentMarker.Create(ComponentMarker.ServerMarkerType, false, componentKey);
var marker = ComponentMarker.Create(ComponentMarker.ServerMarkerType, prerender: false, componentKey);

private readonly Lock _lock = new();
private readonly CircuitOptions _options;
private readonly MemoryCache _persistedCircuits;
private readonly Task<PersistedCircuitState> _noMatch = Task.FromResult<PersistedCircuitState>(null);
Copy link
Member

Choose a reason for hiding this comment

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

Nit: _noMatch could be static.


public CancellationTokenSource TokenSource { get; set; }

public CircuitId CircuitId { get; set; }
Copy link
Member

Choose a reason for hiding this comment

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

I don't see this CircuitId property being set anywhere. Maybe that's missing from PersistCore()?

bool TryDeserializeRootComponentOperations(string serializedComponentOperations, [NotNullWhen(true)] out RootComponentOperationBatch? operationBatch);
bool TryDeserializeRootComponentOperations(string serializedComponentOperations, [NotNullWhen(true)] out RootComponentOperationBatch? operationBatch, bool deserializeDescriptors = true);

public bool TryDeserializeWebRootComponentDescriptor(ComponentMarker record, [NotNullWhen(true)] out WebRootComponentDescriptor? result);
Copy link
Member

Choose a reason for hiding this comment

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

The public modifier isn't necessary

}

PersistedCircuitState? persistedCircuitState;
if (rootComponents == "[]" && string.IsNullOrEmpty(applicationState))
Copy link
Member

Choose a reason for hiding this comment

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

Should this line also be using RootComponentIsEmpty() like the else if block below it?

namespace Microsoft.AspNetCore.Components.Server.Circuits;

// Default implmentation of ICircuitPersistenceProvider that uses an in-memory cache
internal sealed partial class DefaultInMemoryCircuitPersistenceProvider : ICircuitPersistenceProvider
Copy link
Member

Choose a reason for hiding this comment

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

Should this file name be changed to match the name of this class?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-blazor Includes: Blazor, Razor Components
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants