Connection open count survives the reset of a pooled DbContext, so later leases keep the connection open

Aperta
#39,327 0 commenti 0 reazioni 0 assegnatari Vedi su GitHub

I maintainer di solito rispondono entro 2 giorni

Nessuno ha ancora preso questa issue.

Valutazione

Difficoltà
4/5
Tempo stimato
3-5 giorni
Idoneità per principianti
42/100
Tipo di issue
Bug
Chiarezza
Specificata chiaramente
Stato di attività
Attiva
Stack tecnologico
csharp
Ambito
backend, database

Direzione di ricerca

Start in src/EFCore.Relational/Storage/RelationalConnection.cs and compare ResetState with ResetStateAsync, especially the handling of _openedCount and openedInternally. Then read the existing RelationalConnectionTest cases and the DbContextPoolingTest cases for Handle_open_connection_when_returning_to_pool_for*. Add coverage for a later lease after returning a context with the connection open; done means both reset paths clear EF's open count while preserving the expected state of owned and external connections.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Descrizione

area-dbcontext customer-reported
Bug description

When a pooled context goes back to the pool while EF still counts its connection as open (for example after Database.OpenConnectionAsync() without a matching CloseConnectionAsync()), the reset closes the DbConnection but leaves RelationalConnection's _openedCount and _openedInternally unchanged. Every later lease of that context instance then starts with the count at 1: its first command opens the connection and raises the count to 2, and the close after the command only lowers it to 1, so the connection stays open until the lease ends. As a result:

  • each later lease holds a physical connection for its whole duration instead of per command;
  • DbConnectionInterceptor.ConnectionOpening/ConnectionOpened run only for the first command of such a lease instead of for every command;
  • the stale count carries over from lease to lease, so every later lease of that instance is affected (in 10.0, until a lease is returned through the synchronous path).

One lease that misses the close, for example after an exception, is enough. A connection opened directly with GetDbConnection().Open() does not trigger it, since EF does not count that open.

Which reset path is affected depends on the version:

  • 10.0 (10.0.13; 8.0 and 9.0 have the same code): only the asynchronous path. ResetState(disposeDbConnection: false) resets both fields (release/10.0, lines 1043-1044), but ResetStateAsync(disposeDbConnection: false) only does so inside if (disposeDbConnection) (lines 1068-1092). So the context is affected after DisposeAsync() / await using, not after Dispose(). With AddDbContextPool in ASP.NET Core the asynchronous path is the usual one, since request scopes are disposed asynchronously. ResetStateAsync has not reset the count since #24207 separated it from Dispose in 6.0; since 7.0 (#28519, for #27308) it closes an owned connection, which leads to this symptom.
  • main and 11.0 (since 11.0.0-preview.3, including rc.2): both paths. #37916 removed the unconditional _openedCount = 0; _openedInternally = false; from ResetState (main, lines 1027-1047), so Dispose() is now affected too, a regression from 10.0. The Copilot review on that PR pointed out this consequence. The same reset runs when SetDbConnection replaces a connection that the context does not own, so in main and 11.0 a count EF still holds for the old connection carries over to the new one.

A command's close only closes the connection when ShouldClose() brings the count to zero (lines 994-998), which no longer happens once a lease starts with the count at 1.

The repro below prints, with 10.0.13 and DisposeAsync(), and with 11.0.0-rc.2 and either DisposeAsync() or Dispose():

Lease 2, command 1: Open, ConnectionOpened calls: 2
Lease 2, command 2: Open, ConnectionOpened calls: 2
Lease 3, command 1: Open, ConnectionOpened calls: 3
Lease 3, command 2: Open, ConnectionOpened calls: 3

Expected, as 10.0.13 prints with Dispose():

Lease 2, command 1: Closed, ConnectionOpened calls: 2
Lease 2, command 2: Closed, ConnectionOpened calls: 3
Lease 3, command 1: Closed, ConnectionOpened calls: 4
Lease 3, command 2: Closed, ConnectionOpened calls: 5

Workaround: call CloseConnection(Async) for every OpenConnection(Async) before the context is returned to the pool (closing the DbConnection directly does not lower EF's count); in 10.0, returning it with Dispose() also clears the count.

Proposed fix: reset _openedCount and _openedInternally in both ResetState and ResetStateAsync regardless of disposeDbConnection and of whether the context owns the connection, as ResetState did from 6.0 until #37916. I have this change with tests in RelationalConnectionTest and, in DbContextPoolingTest, a query in the second lease of the Handle_open_connection_when_returning_to_pool_for_* tests added for #27308: afterwards an owned connection must be closed and an external one still open. Those tests checked the state on return, but not what a later lease does. I'd be happy to send a pull request against main if this approach works for you. Since the synchronous part is a regression from 10.0 in 11.0, which hasn't shipped yet, you may want the fix in 11.0 as well; if you also fix this in 10.0, only the two lines in ResetStateAsync are needed there.

Related but different: #36218 (disposing a connection calls no closing interceptor) and #38983 (the concurrency detector was not reset when a pooled context was returned).

Your code
// Console app: <TargetFramework>net10.0</TargetFramework>, <ImplicitUsings>enable</ImplicitUsings>,
// <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.13" />
// (for 11.0: net11.0 and 11.0.0-rc.2.26504.105)
using System.Data.Common;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Infrastructure;

// true: DisposeAsync (affected in 10.0 and 11.0); false: Dispose (affected in 11.0)
var disposeAsync = true;

var interceptor = new ConnectionOpenedCounter();
var options = new DbContextOptionsBuilder<BlogContext>()
    .UseSqlite("Data Source=:memory:")
    .AddInterceptors(interceptor)
    .Options;
var factory = new PooledDbContextFactory<BlogContext>(options);

// Lease 1: open the connection and return the context to the pool without closing it.
var context = factory.CreateDbContext();
await context.Database.OpenConnectionAsync();
await DisposeContext(context);

// Leases 2 and 3 get the same instance; each command should open and close the connection.
for (var lease = 2; lease <= 3; lease++)
{
    context = factory.CreateDbContext();
    var connection = context.Database.GetDbConnection();
    for (var command = 1; command <= 2; command++)
    {
        await context.Database.ExecuteSqlRawAsync("SELECT 1");
        Console.WriteLine($"Lease {lease}, command {command}: {connection.State}, ConnectionOpened calls: {interceptor.Count}");
    }

    await DisposeContext(context);
}

async Task DisposeContext(DbContext context)
{
    if (disposeAsync)
    {
        await context.DisposeAsync();
    }
    else
    {
        context.Dispose();
    }
}

public class BlogContext(DbContextOptions<BlogContext> options) : DbContext(options);

public class ConnectionOpenedCounter : DbConnectionInterceptor
{
    public int Count { get; private set; }

    public override void ConnectionOpened(DbConnection connection, ConnectionEndEventData eventData)
        => Count++;

    public override Task ConnectionOpenedAsync(
        DbConnection connection, ConnectionEndEventData eventData, CancellationToken cancellationToken = default)
    {
        Count++;
        return Task.CompletedTask;
    }
}
Stack traces

Verbose output

EF Core version

10.0.13, 11.0.0-rc.2.26504.105

Database provider

Microsoft.EntityFrameworkCore.Sqlite (the code is in Microsoft.EntityFrameworkCore.Relational)

Target framework

.NET 10.0, .NET 11.0 (rc.1)

Operating system

Windows 11

IDE

dotnet CLI

Lingua principale
C#
Stelle
14.8k
Fork
3.4k
Merge medio
3g 18h
PR unite (30g)
159

Preparare l'ambiente

Apri in Codespaces

Avvia il container di sviluppo del progetto nel browser, con il tuo account GitHub.

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di dotnet/efcore

Tutte le issue di dotnet/efcore

Issue simili

Altre issue su C#

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.