#4565 Abstract out session store from SessionManager#4832
Conversation
Added ISessionStore abstraction for Blazor SessionManager to support load-balanced environments with pluggable session storage. Addresses review feedback from PR MarimerLLC#4567: - Async interface using ValueTask - Combined Create/Update into AddOrUpdateSessionAsync - Renamed DeleteSessions to PurgeExpiredSessionsAsync - Removed SessionsFilter class - File-scoped namespaces - Proper null handling
There was a problem hiding this comment.
Pull request overview
This PR abstracts session storage from Blazor's SessionManager to enable distributed session stores for load-balanced environments. It introduces an ISessionStore interface with async methods, provides an InMemorySessionStore default implementation preserving existing behavior, and modifies SessionManager to accept ISessionStore via dependency injection.
Changes:
- Adds
ISessionStoreinterface with async methods (GetSessionAsync,AddOrUpdateSessionAsync,PurgeExpiredSessionsAsync) for pluggable session storage - Implements
InMemorySessionStoreusingConcurrentDictionaryas the default in-memory session store - Refactors
SessionManagerto useISessionStorevia DI instead of a hardcoded dictionary, with sync-over-async patterns to maintain synchronous API
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| Source/Csla/State/ISessionStore.cs | Defines the async interface for pluggable session storage providers |
| Source/Csla.AspNetCore/Blazor/State/InMemorySessionStore.cs | Default implementation using ConcurrentDictionary, preserves existing in-memory behavior |
| Source/Csla.AspNetCore/Blazor/State/SessionManager.cs | Refactored to use ISessionStore via DI with sync-over-async bridging to maintain ISessionManager contract |
| Source/Csla.Blazor/BlazorServerConfigurationOptions.cs | Adds SessionStoreType configuration option for DI registration |
| Source/Csla.Blazor/ConfigurationExtensions.cs | Registers ISessionStore as singleton service in Blazor server DI container |
| Source/tests/Csla.test/State/InMemorySessionStoreTests.cs | Unit tests for InMemorySessionStore implementation |
| Source/tests/Csla.test/State/SessionManagerTests.cs | Unit tests for SessionManager with ISessionStore integration |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| newSession.Touch(); | ||
| var task = _sessionStore.AddOrUpdateSessionAsync(key, newSession); |
There was a problem hiding this comment.
Breaking behavioral change in UpdateSession: The old implementation updated the existing session object in-place by clearing and repopulating it, maintaining object identity. The new implementation completely replaces the session object in the store. This breaks code that caches references to Session objects obtained via GetSession(). While Blazor Server's single-threaded circuit model may mitigate this in practice, the behavior change is not backward compatible and could cause subtle bugs in multi-threaded scenarios or when session references are held across multiple method calls.
| newSession.Touch(); | |
| var task = _sessionStore.AddOrUpdateSessionAsync(key, newSession); | |
| // Preserve session object identity by updating the existing instance in-place | |
| var getTask = _sessionStore.GetSessionAsync(key); | |
| Session sessionToUpdate; | |
| if (getTask.IsCompletedSuccessfully) | |
| { | |
| sessionToUpdate = getTask.Result ?? newSession; | |
| } | |
| else | |
| { | |
| sessionToUpdate = getTask.AsTask().GetAwaiter().GetResult() ?? newSession; | |
| } | |
| // If an existing session instance is present and differs from newSession, | |
| // copy the new contents into the existing instance to maintain identity. | |
| if (!ReferenceEquals(sessionToUpdate, newSession)) | |
| { | |
| sessionToUpdate.Clear(); | |
| foreach (var item in newSession) | |
| { | |
| sessionToUpdate.Add(item.Key, item.Value); | |
| } | |
| } | |
| sessionToUpdate.Touch(); | |
| var task = _sessionStore.AddOrUpdateSessionAsync(key, sessionToUpdate); |
There was a problem hiding this comment.
This behavior change is intentional. For the default InMemorySessionStore, GetSessionAsync returns the actual stored reference, so in-place mutations by callers (e.g. ApplicationContextManagerBlazor) work without needing identity preservation. For a distributed store (Redis, DB), reference identity is meaningless — you would deserialize a new object on each call regardless. Restoring the clear-and-repopulate pattern adds complexity that works against the abstraction.
| var storeTask = _sessionStore.AddOrUpdateSessionAsync(key, result); | ||
| if (!storeTask.IsCompletedSuccessfully) | ||
| storeTask.AsTask().GetAwaiter().GetResult(); | ||
| } | ||
| result.Touch(); |
There was a problem hiding this comment.
Abstraction leak: The design assumes ISessionStore implementations return mutable references that persist changes automatically. This works for InMemorySessionStore but breaks for distributed stores (Redis, databases) that require explicit save operations. Code throughout the codebase calls GetSession(), modifies the returned Session object directly, and calls Touch() without calling UpdateSession(). For example, ApplicationContextManagerBlazor.SetLocalContext() gets a session, modifies it, calls Touch(), but never saves it back. A distributed store implementation would lose these changes because they're only made to a local deserialized copy.
| var storeTask = _sessionStore.AddOrUpdateSessionAsync(key, result); | |
| if (!storeTask.IsCompletedSuccessfully) | |
| storeTask.AsTask().GetAwaiter().GetResult(); | |
| } | |
| result.Touch(); | |
| } | |
| UpdateSession(result); |
There was a problem hiding this comment.
The new session is already stored via AddOrUpdateSessionAsync two lines above, so calling UpdateSession here would be redundant. The broader concern about distributed stores losing in-place mutations is a valid design consideration, but it is the same limitation the previous ConcurrentDictionary implementation had — and addressing it would require changes to callers throughout the framework, which is out of scope for this PR.
| public Session GetSession() | ||
| { | ||
| private readonly ConcurrentDictionary<string, Session> _sessions = []; | ||
| private readonly ISessionIdManager _sessionIdManager = sessionIdManager; | ||
|
|
||
| /// <summary> | ||
| /// Gets the session data for the current user. | ||
| /// </summary> | ||
| public Session GetSession() | ||
| var key = _sessionIdManager.GetSessionId(); | ||
| var getTask = _sessionStore.GetSessionAsync(key); | ||
| var result = getTask.IsCompletedSuccessfully | ||
| ? getTask.Result | ||
| : getTask.AsTask().GetAwaiter().GetResult(); | ||
| if (result is null) | ||
| { | ||
| var key = _sessionIdManager.GetSessionId(); | ||
| var result = _sessions.GetOrAdd(key, []); | ||
| result.Touch(); | ||
| return result; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Updates the current user's session data. | ||
| /// </summary> | ||
| /// <param name="newSession">Current user session data</param> | ||
| /// <exception cref="ArgumentNullException"><paramref name="newSession"/> is <see langword="null"/>.</exception> | ||
| public void UpdateSession(Session newSession) | ||
| { | ||
| ArgumentNullException.ThrowIfNull(newSession); | ||
| var key = _sessionIdManager.GetSessionId(); | ||
| var existingSession = _sessions[key]; | ||
| Replace(newSession, existingSession); | ||
| existingSession.Touch(); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Replace the contents of oldSession with the items | ||
| /// in newSession. | ||
| /// </summary> | ||
| /// <param name="newSession"></param> | ||
| /// <param name="oldSession"></param> | ||
| private static void Replace(Session newSession, Session oldSession) | ||
| { | ||
| oldSession.Clear(); | ||
| foreach (var (key, value) in newSession) | ||
| oldSession.Add(key, value); | ||
| result = []; | ||
| var storeTask = _sessionStore.AddOrUpdateSessionAsync(key, result); | ||
| if (!storeTask.IsCompletedSuccessfully) | ||
| storeTask.AsTask().GetAwaiter().GetResult(); | ||
| } | ||
| result.Touch(); | ||
| return result; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Remove all expired session data. | ||
| /// </summary> | ||
| /// <param name="expiration">Expiration duration</param> | ||
| public void PurgeSessions(TimeSpan expiration) | ||
| { | ||
| var expirationTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds() - expiration.TotalSeconds; | ||
| List<string> toRemove = []; | ||
| foreach (var session in _sessions) | ||
| if (session.Value.LastTouched < expirationTime) | ||
| toRemove.Add(session.Key); | ||
| foreach (var key in toRemove) | ||
| _sessions.TryRemove(key, out _); | ||
| } | ||
| /// <summary> | ||
| /// Updates the current user's session data. | ||
| /// </summary> | ||
| /// <param name="newSession">Current user session data</param> | ||
| /// <exception cref="ArgumentNullException"><paramref name="newSession"/> is <see langword="null"/>.</exception> | ||
| public void UpdateSession(Session newSession) | ||
| { | ||
| ArgumentNullException.ThrowIfNull(newSession); | ||
| var key = _sessionIdManager.GetSessionId(); | ||
| newSession.Touch(); | ||
| var task = _sessionStore.AddOrUpdateSessionAsync(key, newSession); | ||
| if (!task.IsCompletedSuccessfully) | ||
| task.AsTask().GetAwaiter().GetResult(); | ||
| } | ||
|
|
||
| // wasm client-side methods | ||
| Task<Session> ISessionManager.RetrieveSession(TimeSpan timeout) => throw new NotImplementedException(); | ||
| Session ISessionManager.GetCachedSession() => throw new NotImplementedException(); | ||
| Task ISessionManager.SendSession(TimeSpan timeout) => throw new NotImplementedException(); | ||
| Task<Session> ISessionManager.RetrieveSession(CancellationToken ct) => throw new NotImplementedException(); | ||
| Task ISessionManager.SendSession(CancellationToken ct) => throw new NotImplementedException(); | ||
| /// <summary> | ||
| /// Remove all expired session data. | ||
| /// </summary> | ||
| /// <param name="expiration">Expiration duration</param> | ||
| public void PurgeSessions(TimeSpan expiration) | ||
| { | ||
| var task = _sessionStore.PurgeExpiredSessionsAsync(expiration); | ||
| if (!task.IsCompletedSuccessfully) | ||
| task.AsTask().GetAwaiter().GetResult(); | ||
| } |
There was a problem hiding this comment.
Sync-over-async pattern with potential for deadlocks and poor performance. Using GetAwaiter().GetResult() on async operations called from synchronous methods can cause deadlocks in certain synchronization contexts and prevents the benefits of async/await. Consider either making the ISessionManager interface async or using a synchronous ISessionStore interface. The current approach combines the worst of both: complexity of async interface without the benefits, plus deadlock risk.
There was a problem hiding this comment.
This is a known design tradeoff. The original PR review feedback explicitly asked for ISessionStore to be async (to support distributed stores like Redis/DB). Making ISessionManager async would cascade changes throughout the framework. The ValueTask + IsCompletedSuccessfully check means the GetAwaiter().GetResult() fallback path is never hit for the default InMemorySessionStore — all its ValueTask returns complete synchronously. For truly async store implementations, the consumer would need to evaluate deadlock risk in their specific synchronization context.
Co-authored-by: Copilot <[email protected]>
Co-authored-by: Copilot <[email protected]>
Co-Authored-By: Claude Opus 4.6 <[email protected]>
Summary
Resolves #4565
ISessionStoreinterface toCsla.Statewith async methods (ValueTask) for pluggable session storageInMemorySessionStoredefault implementation usingConcurrentDictionary(preserves existing behavior)SessionManagerto acceptISessionStorevia DI instead of using a hardcoded dictionaryISessionStorein Blazor server DI configuration with configurableSessionStoreTypeThis enables load-balanced Blazor Server environments to use distributed session stores (Redis, database, etc.) by implementing
ISessionStore.Addresses feedback from PR #4567
ISessionStoremethods returnValueTask/ValueTask<T>DeleteSessionsPurgeExpiredSessionsAsyncAddOrUpdateSessionAsyncmethodSessionsFilterclassTimeSpanparameter directly onPurgeExpiredSessionsAsyncGetSession()checks null from store, creates new session if neededTest plan
dotnet build Source\csla.build.sln)dotnet build Source\csla.test.sln)InMemorySessionStore, 6 forSessionManager)🤖 Generated with Claude Code