Skip to content

#4565 Abstract out session store from SessionManager#4832

Merged
rockfordlhotka merged 4 commits into
MarimerLLC:mainfrom
joshhanson314:dev/4565-state-management-session-store
Feb 19, 2026
Merged

#4565 Abstract out session store from SessionManager#4832
rockfordlhotka merged 4 commits into
MarimerLLC:mainfrom
joshhanson314:dev/4565-state-management-session-store

Conversation

@joshhanson314

@joshhanson314 joshhanson314 commented Feb 19, 2026

Copy link
Copy Markdown
Member

Summary

Resolves #4565

  • Adds ISessionStore interface to Csla.State with async methods (ValueTask) for pluggable session storage
  • Provides InMemorySessionStore default implementation using ConcurrentDictionary (preserves existing behavior)
  • Modifies SessionManager to accept ISessionStore via DI instead of using a hardcoded dictionary
  • Registers ISessionStore in Blazor server DI configuration with configurable SessionStoreType

This enables load-balanced Blazor Server environments to use distributed session stores (Redis, database, etc.) by implementing ISessionStore.

Addresses feedback from PR #4567

Feedback Resolution
Make interface async All ISessionStore methods return ValueTask/ValueTask<T>
Rename DeleteSessions PurgeExpiredSessionsAsync
Combine Create/Update Single AddOrUpdateSessionAsync method
No hardcoded English strings No custom exception messages needed
Drop SessionsFilter class TimeSpan parameter directly on PurgeExpiredSessionsAsync
File-scoped namespaces All new/modified files
Handle null properly GetSession() checks null from store, creates new session if needed

Test plan

  • Framework builds cleanly (dotnet build Source\csla.build.sln)
  • Test solution builds (dotnet build Source\csla.test.sln)
  • 11 new unit tests pass (5 for InMemorySessionStore, 6 for SessionManager)
  • Full test suite passes (642 tests: 585 passed, 57 skipped, 0 failures)
  • CI passes

🤖 Generated with Claude Code

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 ISessionStore interface with async methods (GetSessionAsync, AddOrUpdateSessionAsync, PurgeExpiredSessionsAsync) for pluggable session storage
  • Implements InMemorySessionStore using ConcurrentDictionary as the default in-memory session store
  • Refactors SessionManager to use ISessionStore via 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.

Comment on lines +54 to +55
newSession.Touch();
var task = _sessionStore.AddOrUpdateSessionAsync(key, newSession);

Copilot AI Feb 19, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Suggested change
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);

Copilot uses AI. Check for mistakes.

@joshhanson314 joshhanson314 Feb 19, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

Comment on lines +37 to +41
var storeTask = _sessionStore.AddOrUpdateSessionAsync(key, result);
if (!storeTask.IsCompletedSuccessfully)
storeTask.AsTask().GetAwaiter().GetResult();
}
result.Touch();

Copilot AI Feb 19, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Suggested change
var storeTask = _sessionStore.AddOrUpdateSessionAsync(key, result);
if (!storeTask.IsCompletedSuccessfully)
storeTask.AsTask().GetAwaiter().GetResult();
}
result.Touch();
}
UpdateSession(result);

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

Comment on lines +27 to 69
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();
}

Copilot AI Feb 19, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

Comment thread Source/tests/Csla.test/State/SessionManagerTests.cs
Comment thread Source/Csla.AspNetCore/Blazor/State/InMemorySessionStore.cs
Comment thread Source/tests/Csla.test/State/SessionManagerTests.cs Outdated
@rockfordlhotka rockfordlhotka self-requested a review February 19, 2026 20:51
@rockfordlhotka rockfordlhotka merged commit 805f29d into MarimerLLC:main Feb 19, 2026
2 checks passed
@joshhanson314 joshhanson314 deleted the dev/4565-state-management-session-store branch February 19, 2026 22:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Abstracting the data persistence of the Blazor state management session storage

3 participants