Skip to content

Persistence: remove ambient AsyncLocal context stack#795

Draft
sven-n wants to merge 3 commits into
masterfrom
claude/persistence-simplification-plan-OVKNW
Draft

Persistence: remove ambient AsyncLocal context stack#795
sven-n wants to merge 3 commits into
masterfrom
claude/persistence-simplification-plan-OVKNW

Conversation

@sven-n

@sven-n sven-n commented Jun 7, 2026

Copy link
Copy Markdown
Member

$(cat <<'EOF'

Summary

Removes the AsyncLocal<Stack<IContext>> ambient context stack (ContextStack, IContextStack, IContextStackProvider) from the EntityFramework persistence layer and replaces it with explicit context threading.

Why the stack was unnecessary:

  • The GameConfiguration is already stored on EntityDataContext.CurrentGameConfiguration and never changes during a context's lifetime.
  • Genuine nesting never occurs — the Stack<> depth was always ≤ 1.
  • The two sites that temporarily change the current configuration (CachingGameConfigurationRepository.GetAllAsync, GameServerDefinitionRepository.LoadDependentDataAsync) mutate the EntityDataContext field, not the stack.

What changed:

  • Deleted ContextStack.cs, IContextStack.cs, IContextStackProvider.cs.
  • Added IContextAwareRepository (internal interface) — context-aware GetByIdAsync/GetAllAsync overloads.
  • Added context-aware GetRepository(Type, EntityFrameworkContextBase?) overloads to IContextAwareRepositoryProvider / CacheAwareRepositoryProvider / RepositoryProvider.
  • All UseContext(this) push/pop calls removed from EntityFrameworkContextBase and PlayerContext; the originating context is now passed explicitly.
  • Repository methods thread the originating context through LoadDependentDataAsyncLoadCollectionAsyncLoadNavigationPropertyAsync.
  • The per-GameConfiguration caches in ConfigurationTypeRepository<T> remain shared singletons — the caching benefit is fully preserved.
  • The public API (IPersistenceContextProvider, IRepositoryProvider, ICacheAwareRepositoryProvider) and the InMemory implementation are untouched.

Result: simpler, allocation-free (no AsyncLocal), and the data flow is explicit.

Test plan

  • dotnet build passes (CI)
  • MUnique.OpenMU.Persistence.Initialization.Tests pass (CI)
  • MUnique.OpenMU.Tests pass (CI) — covers FriendServer, Guild, Trade, MoveItem scenarios
    EOF
    )

Generated by Claude Code

claude added 2 commits June 6, 2026 21:37
The EntityFramework persistence layer used an AsyncLocal-based context
stack (ContextStack) so that shared singleton repositories could discover
the current context, its DbContext and its GameConfiguration. Since every
repository operation already originates from an EntityFrameworkContextBase
(which holds its own DbContext and current GameConfiguration) and the stack
never actually nested distinct contexts, the ambient mechanism was
unnecessary.

This replaces the stack by threading the originating context explicitly:

- Remove ContextStack / IContextStack / IContextStackProvider.
- Add an internal IContextAwareRepository (and context-aware GetRepository
  overloads on IContextAwareRepositoryProvider) so repositories receive the
  originating context as an explicit parameter.
- Thread the originating context through GenericRepositoryBase, the
  configuration/account/letter/game-server repositories and CachedRepository,
  while keeping the shared per-GameConfiguration caches intact.
- Move the cache/non-cache repository selection (edit TypedContext check) to
  read the explicitly passed context instead of the ambient stack.

Public API (IPersistenceContextProvider, IRepositoryProvider,
ICacheAwareRepositoryProvider) and the InMemory implementation are unchanged.

https://claude.ai/code/session_015sGcQwyYMwppSjbP1uD66j
…ds must be internal

CachedRepository<T> is a public class, so its public methods cannot expose
the internal type EntityFrameworkContextBase. The two context-aware overloads
added during the ambient-stack removal are changed from public to internal;
they are called only within this assembly.

https://claude.ai/code/session_015sGcQwyYMwppSjbP1uD66j
@gemini-code-assist

Copy link
Copy Markdown

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request refactors the EntityFramework persistence layer by removing the ambient AsyncLocal context stack. By replacing implicit context management with explicit context threading, the system becomes more predictable and allocation-free. The changes ensure that the originating context is correctly passed through repository operations, while maintaining existing caching behaviors and public API compatibility.

Highlights

  • Removal of Ambient Context: Removed the AsyncLocal-based ambient context stack (ContextStack, IContextStack, IContextStackProvider) to eliminate hidden state and improve code clarity.
  • Explicit Context Threading: Transitioned to explicit context passing by introducing IContextAwareRepository and updating repository methods to accept an optional EntityFrameworkContextBase parameter.
  • API Refactoring: Updated repository providers and LoadDependentDataAsync flows to propagate the originating context, ensuring thread safety and explicit data flow.
New Features

🧠 You can now enable Memory (public preview) to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request refactors the persistence layer by removing the ambient ContextStack mechanism in favor of explicitly passing an originating context (EntityFrameworkContextBase) through repositories and repository providers. While this is a positive architectural change, several critical issues were identified in the implementation. Specifically, ensuring caches on a temporary context in AccountRepository will crash when no explicit context is provided, and calling DeleteAsync in ConfigurationTypeRepository with a null context is guaranteed to throw an exception. Additionally, throwing exceptions on null contexts in CachingGameConfigurationRepository breaks the parameterless repository interface contracts. These issues should be addressed by implementing appropriate fallback context creation and handling null configurations gracefully.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +35 to +38
using var ownedContext = context is null ? this.GetContext(null) : null;
var origin = context ?? ownedContext!;

using var context = this.GetContext();
await context.Context.Database.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
this.RepositoryProvider.EnsureCachesForCurrentGameConfiguration(origin);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

If context is null, ownedContext is created as a temporary context. However, this temporary context will not have CurrentGameConfiguration set on its EntityDataContext. Calling EnsureCachesForCurrentGameConfiguration(origin) will subsequently throw an InvalidOperationException because CurrentGameConfiguration is null.

To prevent this crash when loading an account without an explicit context, we should only ensure caches if the context actually has a game configuration set.

        using var ownedContext = context is null ? this.GetContext(null) : null;
        var origin = context ?? ownedContext!;

        if (origin.Context is EntityDataContext { CurrentGameConfiguration: not null })
        {
            this.RepositoryProvider.EnsureCachesForCurrentGameConfiguration(origin);
        }

Comment on lines 123 to 125
var gameConfiguration = this.GetCurrentGameConfiguration(null);
var collection = this._collectionSelector(gameConfiguration);
return collection.Remove(item);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Calling GetCurrentGameConfiguration(null) with a hardcoded null argument will always throw an InvalidOperationException because GetCurrentGameConfiguration expects a non-null context with an active game configuration. This makes DeleteAsync completely broken and guaranteed to crash at runtime.

If deleting configuration types is not supported, it is better to explicitly throw a NotSupportedException.

        throw new NotSupportedException("Deleting configuration types directly through the repository is not supported.");

Comment on lines 38 to 41
if (context is not { } currentContext)
{
throw new InvalidOperationException("There is no current context set.");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Throwing an InvalidOperationException when context is null breaks the public IRepository<GameConfiguration> interface contract, which allows parameterless calls.

Since CachingGameConfigurationRepository inherits from GenericRepositoryBase<GameConfiguration>, it can use the same fallback pattern as other repositories to create a temporary context when none is provided.

        using var ownedContext = context is null ? this.GetContext(null) : null;
        var currentContext = context ?? ownedContext!;

Comment on lines 58 to 61
if (context is not { } currentContext)
{
throw new InvalidOperationException("There is no current context set.");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Throwing an InvalidOperationException when context is null breaks the public IRepository<GameConfiguration> interface contract, which allows parameterless calls.

Since CachingGameConfigurationRepository inherits from GenericRepositoryBase<GameConfiguration>, it can use the same fallback pattern as other repositories to create a temporary context when none is provided.

        using var ownedContext = context is null ? this.GetContext(null) : null;
        var currentContext = context ?? ownedContext!;

- AccountRepository: guard EnsureCachesForCurrentGameConfiguration behind a
  CurrentGameConfiguration null-check so temporary contexts don't crash.
- ConfigurationTypeRepository.DeleteAsync(object): throw NotSupportedException
  instead of calling GetCurrentGameConfiguration(null) which always throws.
- CachingGameConfigurationRepository.GetByIdAsync/GetAllAsync: use the
  GetContext(null) fallback rather than throwing when context is null, so the
  parameterless IRepository<T> contract is honoured.
- GameConfigurationRepository.GetByIdAsync/GetAllAsync: same fallback fix.

https://claude.ai/code/session_015sGcQwyYMwppSjbP1uD66j
@sven-n sven-n marked this pull request as draft June 8, 2026 20:02
@sven-n

sven-n commented Jun 8, 2026

Copy link
Copy Markdown
Member Author

I'm not sure if I like this yet 😅

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.

2 participants