Skip to content

DbContext pooling recycles a context whose ConcurrencyDetector is stuck in the critical section; every later rent fails with "A second operation was started on this context instance" #38983

Closed

Description

@LuKuanTsen

Bug description

ConcurrencyDetector is not reset when a pooled DbContext is returned to the pool, and EnterCriticalSection/ExitCriticalSection toggle _inCriticalSection around an allocating AsyncLocal<int> write. If that write throws (in practice: OutOfMemoryException under memory pressure), the flag stays set, the context is returned to the pool as usual, and every subsequent operation on that instance — for the rest of the process lifetime — throws InvalidOperationException: A second operation was started on this context instance....

This turns one transient failure in one request into a permanent failure of the process. In our case a single low-traffic pod (the pool held one or two instances, so the poisoned one was rented almost every time) failed every database call for 45 minutes until it was redeployed: the original OOM hit one endpoint 24 times, the poisoned context produced ~1,000 errors on unrelated endpoints.

This is the mechanism behind #22802, which was closed with "OutOfMemoryException is not something that can be recovered from". Agreed that the request that hit OOM is lost; the ask here is different: the pool should not hand a known-bad instance to the next request, and the detector should not have a window where a failed allocation leaves it permanently locked. Both are cheap to fix and need no OOM handling.

Interlocked.CompareExchange(ref _inCriticalSection, 1, 0) has already run when ThreadAcquiredLocksCount.Value++ throws, so EnterCriticalSection never returns a disposer and nothing ever clears the flag. ExitCriticalSection has the mirror-image window: ThreadAcquiredLocksCount.Value-- (also an allocating write) runs before _inCriticalSection = 0.

Your code

The pooling half is deterministic and reproduces without OOM, using the standard AddDbContextPool + scope-per-request setup:

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();
services.AddDbContextPool<BlogContext>(o => o.UseSqlite("Data Source=repro.db"));
await using var provider = services.BuildServiceProvider();

using (var scope = provider.CreateScope())
{
    var ctx = scope.ServiceProvider.GetRequiredService<BlogContext>();
    ctx.Database.EnsureDeleted();
    ctx.Database.EnsureCreated();
}

var first = await Request1();
await Request2(first);

// Separate async method: AsyncLocal changes made here do not flow back to the caller,
// so Request2 runs on a fresh flow like a real next request would.
async Task<BlogContext> Request1()
{
    using var scope = provider.CreateScope();
    var ctx = scope.ServiceProvider.GetRequiredService<BlogContext>();
    await ctx.Blogs.ToListAsync();

    // Simulates EnterCriticalSection succeeding but the matching Exit never running
    // (what happens when the AsyncLocal write inside Enter/Exit throws).
    ctx.GetService<IConcurrencyDetector>().EnterCriticalSection(); // disposer dropped
    return ctx;
} // scope disposed -> context returned to pool; ResetState() does not touch ConcurrencyDetector

async Task Request2(BlogContext first)
{
    using var scope = provider.CreateScope();
    var ctx = scope.ServiceProvider.GetRequiredService<BlogContext>();
    Console.WriteLine($"same instance: {ReferenceEquals(first, ctx)}"); // True
    await ctx.Blogs.ToListAsync(); // InvalidOperationException: A second operation was started on this context instance...
}

public class BlogContext(DbContextOptions<BlogContext> options) : DbContext(options)
{
    public DbSet<Blog> Blogs => Set<Blog>();
}
public class Blog { public int Id { get; set; } }

Note the two requests must run on different async flows (separate async methods, or ExecutionContext.SuppressFlow()); on the same flow the leaked ThreadAcquiredLocksCount makes the detector treat the second call as re-entrant and the bug is masked. That is probably why this was hard to reproduce in #22802.

Stack traces

The other half (how the disposer gets dropped in production) is the stack trace from #22802 and from our incident:

System.OutOfMemoryException
   at System.Threading.AsyncLocalValueMap.MultiElementAsyncLocalValueMap.Set(IAsyncLocal key, Object value, Boolean treatNullValueAsNonexistent)
   at System.Threading.ExecutionContext.SetLocalValue(IAsyncLocal local, Object newValue, Boolean needChangeNotifications)
   at System.Threading.AsyncLocal`1.set_Value(T value)
   at Microsoft.EntityFrameworkCore.Infrastructure.Internal.ConcurrencyDetector.EnterCriticalSection()
   at Microsoft.EntityFrameworkCore.Query.Internal.SingleQueryingEnumerable`1.AsyncEnumerator.MoveNextAsync()

Verbose output

No response

EF Core version

10.0.9 (also verified against current main; ConcurrencyDetector is unchanged since 8.x)

Database provider

Microsoft.EntityFrameworkCore.SqlServer (repro above uses Sqlite)

Target framework

.NET 10

Operating system

Linux (Kubernetes); #22802 reported the same on Windows

IDE

No response

Proposed fix

Two small, allocation-free changes; no behaviour change on the happy path:

  1. ConcurrencyDetector implements IResettableService and StateManager.ResetState() resets it (same way it already resets _changeDetector), so a pooled context always starts its next lease clean.
  2. Reorder the bookkeeping so the flag is only kept if the AsyncLocal write succeeds, and cleared before the AsyncLocal write on exit:
public virtual ConcurrencyDetectorCriticalSectionDisposer EnterCriticalSection()
{
    if (Interlocked.CompareExchange(ref _inCriticalSection, 1, 0) == 1
        && ThreadAcquiredLocksCount.Value == 0)
    {
        throw new InvalidOperationException(CoreStrings.ConcurrentMethodInvocation);
    }

    try
    {
        ThreadAcquiredLocksCount.Value++;
    }
    catch
    {
        if (_currentContextRefCount == 0)
        {
            _inCriticalSection = 0;
        }

        throw;
    }

    _currentContextRefCount++;
    return new ConcurrencyDetectorCriticalSectionDisposer(this);
}

public virtual void ExitCriticalSection()
{
    if (--_currentContextRefCount == 0)
    {
        _inCriticalSection = 0;
    }

    ThreadAcquiredLocksCount.Value--;
}

I have this implemented with tests (pooled context reusable after a leaked section — red before, green after; EFCore.Tests fully green) and can open a PR if the team is open to it.

Activity

  1. added this to the 12.0.0 milestone on Sep 14, 2026
  2. added theissue type on Sep 14, 2026
  3. AndriySvyryd commented on Sep 14, 2026

    @AndriySvyryd
    Member

    @LuKuanTsen While this is still an unsupported scenario the proposed fix is reasonable, so go ahead. Just add a comment new the new try/catch explaining its purpose

  4. LuKuanTsen commented on Sep 15, 2026

    @LuKuanTsen
    ContributorAuthor

    One design note on the "reset on return to pool" half, since there are two ways to wire it and I'd like the team's preference before the PR is reviewed.

    A. Reset from StateManager (what the PR does)

    ConcurrencyDetector implements IResettableService, and StateManager.ResetState() calls it the same way it already resets _changeDetector:

    _changeDetector?.ResetState();
    (_concurrencyDetector as IResettableService)?.ResetState();

    No public API change, so the same diff can be backported to release/10.0 if the fix qualifies for servicing. Downside: the cast probes for a capability that IConcurrencyDetector does not declare, so a replaced IConcurrencyDetector implementation would not be reset.

    B. Register it like the other resettable services

    This is the pattern the rest of the codebase uses (IStateManager, IDbContextTransactionManager, ILazyLoaderFactory all extend IResettableService and are registered in EntityFrameworkServicesBuilder):

    // IConcurrencyDetector.cs
    public interface IConcurrencyDetector : IResettableService
    
    // EntityFrameworkServicesBuilder.cs, next to the other three
    TryAdd<IResettableService, IConcurrencyDetector>(p => p.GetRequiredService<IConcurrencyDetector>());

    StateManager is then untouched and DbContext.ResetState() picks the detector up through GetResettableServices(). Cleaner, but adding a base interface to the public IConcurrencyDetector is a compile-time break for anyone implementing it, so it is main/EF 11 only. I have this variant implemented and green as well (EntityFrameworkServicesBuilderTest needs its resettable-service count bumped from 4 to 5); happy to switch the PR to it if you prefer, and keep A only for a servicing PR.

    The EnterCriticalSection/ExitCriticalSection half is the same in both.

  5. added a commit that references this issue on Sep 15, 2026
    1e4880b
  6. added a commit that references this issue on Sep 25, 2026
    ef96001
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions