From 604ac664f2301cf6a066d5f6d9268856b617a11d Mon Sep 17 00:00:00 2001 From: Aaron Williams Date: Tue, 20 Oct 2026 11:17:57 -0400 Subject: [PATCH] Don't cancel the command after QueryUnbufferedAsync completes normally QueryUnbufferedAsync's finally block calls DbCommand.Cancel() whenever the reader is still open, and on the success path it always is. Npgsql sends that cancel over a new connection, so every completed query pays for an extra connection to cancel a command that has already finished. Close the reader and clear it after reading to the end, as the sync Query and QueryRow paths already do. Do the same on the early exit for statements with no columns; sync QueryImpl still cancels there. Cancel() now only runs when the caller stops early or an error is thrown. Fixes #2188 --- Dapper/SqlMapper.Async.cs | 17 ++++ tests/Dapper.Tests/Providers/SqliteTests.cs | 90 +++++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/Dapper/SqlMapper.Async.cs b/Dapper/SqlMapper.Async.cs index eade08cb2..a4c8f84bd 100644 --- a/Dapper/SqlMapper.Async.cs +++ b/Dapper/SqlMapper.Async.cs @@ -1312,6 +1312,14 @@ static async IAsyncEnumerable Impl(IDbConnection cnn, Type effectiveType, Com { if (reader.FieldCount == 0) { + // no result set; close the reader cleanly - no + // need for "Cancel" etc +#if NET5_0_OR_GREATER + await reader.DisposeAsync(); +#else + reader.Dispose(); +#endif + reader = null; yield break; } tuple = info.Deserializer = new DeserializerState(hash, GetDeserializer(effectiveType, reader, 0, -1, false)); @@ -1327,6 +1335,15 @@ static async IAsyncEnumerable Impl(IDbConnection cnn, Type effectiveType, Com yield return GetValue(reader, effectiveType, val); } while (await reader.NextResultAsync(cancel).ConfigureAwait(false)) { /* ignore subsequent result sets */ } + // happy path; close the reader cleanly - no + // need for "Cancel" etc +#if NET5_0_OR_GREATER + await reader.DisposeAsync(); +#else + reader.Dispose(); +#endif + reader = null; + command.OnCompleted(); } finally diff --git a/tests/Dapper.Tests/Providers/SqliteTests.cs b/tests/Dapper.Tests/Providers/SqliteTests.cs index b0c5e0f1b..2765b7a7c 100644 --- a/tests/Dapper.Tests/Providers/SqliteTests.cs +++ b/tests/Dapper.Tests/Providers/SqliteTests.cs @@ -1,5 +1,7 @@ using Microsoft.Data.Sqlite; using System; +using System.Collections.Generic; +using System.Data; using System.Data.Common; using System.Linq; using System.Threading; @@ -158,5 +160,93 @@ private class PersonWithDob public int Id { get; set; } public DateTime DoB { get; set; } } + + [FactSqlite] + public async Task QueryUnbufferedAsync_ReadToEnd_DoesNotCancel() + { + using var connection = new CancelCountingConnection(GetSQLiteConnection()); + var results = new List(); + await foreach (var value in connection.QueryUnbufferedAsync("select 1 union all select 2")) + { + results.Add(value); + } + Assert.Equal(new[] { 1, 2 }, results); + Assert.Equal(0, connection.Cancels); + } + + [FactSqlite] + public async Task QueryUnbufferedAsync_StopEarly_Cancels() + { + using var connection = new CancelCountingConnection(GetSQLiteConnection()); + await foreach (var value in connection.QueryUnbufferedAsync("select 1 union all select 2")) + { + Assert.Equal(1, value); + break; + } + Assert.Equal(1, connection.Cancels); + } + + [FactSqlite] + public async Task QueryUnbufferedAsync_NoColumns_DoesNotCancel() + { + using var connection = new CancelCountingConnection(GetSQLiteConnection()); + await foreach (var value in connection.QueryUnbufferedAsync("create temp table UnbufferedNoColumns (Id int)")) + { + Assert.Fail("no rows expected"); + } + Assert.Equal(0, connection.Cancels); + } + + // counts DbCommand.Cancel calls; everything else is passed through + private sealed class CancelCountingConnection(DbConnection inner) : DbConnection + { + public int Cancels; +#pragma warning disable CS8765 // nullability differs between target frameworks + public override string ConnectionString { get => inner.ConnectionString; set => inner.ConnectionString = value; } +#pragma warning restore CS8765 + public override string Database => inner.Database; + public override string DataSource => inner.DataSource; + public override string ServerVersion => inner.ServerVersion; + public override ConnectionState State => inner.State; + public override void ChangeDatabase(string databaseName) => inner.ChangeDatabase(databaseName); + public override void Close() => inner.Close(); + public override void Open() => inner.Open(); + protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) => inner.BeginTransaction(isolationLevel); + protected override DbCommand CreateDbCommand() => new CountingCommand(this, inner.CreateCommand()); + protected override void Dispose(bool disposing) + { + if (disposing) inner.Dispose(); + base.Dispose(disposing); + } + + private sealed class CountingCommand(CancelCountingConnection owner, DbCommand inner) : DbCommand + { +#pragma warning disable CS8765 // nullability differs between target frameworks + public override string CommandText { get => inner.CommandText; set => inner.CommandText = value; } +#pragma warning restore CS8765 + public override int CommandTimeout { get => inner.CommandTimeout; set => inner.CommandTimeout = value; } + public override CommandType CommandType { get => inner.CommandType; set => inner.CommandType = value; } + public override bool DesignTimeVisible { get => inner.DesignTimeVisible; set => inner.DesignTimeVisible = value; } + public override UpdateRowSource UpdatedRowSource { get => inner.UpdatedRowSource; set => inner.UpdatedRowSource = value; } + protected override DbConnection? DbConnection { get => owner; set { } } + protected override DbParameterCollection DbParameterCollection => inner.Parameters; + protected override DbTransaction? DbTransaction { get => inner.Transaction; set => inner.Transaction = value; } + public override void Cancel() + { + owner.Cancels++; + inner.Cancel(); + } + public override int ExecuteNonQuery() => inner.ExecuteNonQuery(); + public override object? ExecuteScalar() => inner.ExecuteScalar(); + public override void Prepare() => inner.Prepare(); + protected override DbParameter CreateDbParameter() => inner.CreateParameter(); + protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) => inner.ExecuteReader(behavior); + protected override void Dispose(bool disposing) + { + if (disposing) inner.Dispose(); + base.Dispose(disposing); + } + } + } } }