From 730217de43ab215eb7fa203f2470336ff2e20458 Mon Sep 17 00:00:00 2001 From: NaCl-HCl Date: Thu, 15 Oct 2026 02:30:10 -0400 Subject: [PATCH] Fix false cache hit accounting on initial MultiMapAsync execution MultiMapAsync calls GetCacheInfo to setup the command and parameter reader. It then delegated to MultiMapImpl, which performed a redundant GetCacheInfo call for the same identity. Because GetCacheInfo records a cache hit on finding an existing entry, a single-execution async multi-map query ended up with hitCount = 1 instead of 0, preventing garbage collection by CollectCacheGarbage and causing one-off queries to linger in the cache. Pass the already resolved CacheInfo into MultiMapImpl so the first execution retains hitCount = 0 and normal cache cleanup can collect unreused queries. Fixes #2240 --- Dapper/SqlMapper.Async.cs | 4 +- Dapper/SqlMapper.cs | 8 ++-- tests/Dapper.Tests/Providers/SqliteTests.cs | 46 +++++++++++++++++++++ 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/Dapper/SqlMapper.Async.cs b/Dapper/SqlMapper.Async.cs index eade08cb2..07ca478f6 100644 --- a/Dapper/SqlMapper.Async.cs +++ b/Dapper/SqlMapper.Async.cs @@ -940,7 +940,7 @@ private static async Task> MultiMapAsync(null, CommandDefinition.ForCallback(command.Parameters, command.Flags), map, splitOn, reader, identity, true); + var results = MultiMapImpl(null, CommandDefinition.ForCallback(command.Parameters, command.Flags), map, splitOn, reader, identity, true, info); return command.Buffered ? results.ToList() : results; } finally @@ -988,7 +988,7 @@ private static async Task> MultiMapAsync(this IDbC if (wasClosed) await cnn.TryOpenAsync(command.CancellationToken).ConfigureAwait(false); using var cmd = command.TrySetupAsyncCommand(cnn, info.ParamReader); using var reader = await ExecuteReaderWithFlagsFallbackAsync(cmd, wasClosed, CommandBehavior.SequentialAccess | CommandBehavior.SingleResult, command.CancellationToken).ConfigureAwait(false); - var results = MultiMapImpl(null, default, types, map, splitOn, reader, identity, true); + var results = MultiMapImpl(null, CommandDefinition.ForCallback(command.Parameters, command.Flags), types, map, splitOn, reader, identity, true, info); return command.Buffered ? results.ToList() : results; } finally diff --git a/Dapper/SqlMapper.cs b/Dapper/SqlMapper.cs index 8f3956cab..f3abebcc5 100644 --- a/Dapper/SqlMapper.cs +++ b/Dapper/SqlMapper.cs @@ -1584,11 +1584,11 @@ private static IEnumerable MultiMap MultiMapImpl(this IDbConnection? cnn, CommandDefinition command, Delegate map, string splitOn, DbDataReader? reader, Identity? identity, bool finalize) + private static IEnumerable MultiMapImpl(this IDbConnection? cnn, CommandDefinition command, Delegate map, string splitOn, DbDataReader? reader, Identity? identity, bool finalize, CacheInfo? cacheInfo = null) { object? param = command.Parameters; identity ??= new Identity(command.CommandText, command.CommandTypeDirect, cnn!, typeof(TFirst), param?.GetType()); - CacheInfo cinfo = GetCacheInfo(identity, param, command.AddToCache); + CacheInfo cinfo = cacheInfo ?? GetCacheInfo(identity, param, command.AddToCache); IDbCommand? ownedCommand = null; DbDataReader? ownedReader = null; @@ -1650,7 +1650,7 @@ private static CommandBehavior GetBehavior(bool close, CommandBehavior @default) return (close ? (@default | CommandBehavior.CloseConnection) : @default) & Settings.AllowedCommandBehaviors; } - private static IEnumerable MultiMapImpl(this IDbConnection? cnn, CommandDefinition command, Type[] types, Func map, string splitOn, DbDataReader? reader, Identity? identity, bool finalize) + private static IEnumerable MultiMapImpl(this IDbConnection? cnn, CommandDefinition command, Type[] types, Func map, string splitOn, DbDataReader? reader, Identity? identity, bool finalize, CacheInfo? cacheInfo = null) { if (types.Length < 1) { @@ -1659,7 +1659,7 @@ private static IEnumerable MultiMapImpl(this IDbConnection? cn object? param = command.Parameters; identity ??= new IdentityWithTypes(command.CommandText, command.CommandTypeDirect, cnn!, types[0], param?.GetType(), types); - CacheInfo cinfo = GetCacheInfo(identity, param, command.AddToCache); + CacheInfo cinfo = cacheInfo ?? GetCacheInfo(identity, param, command.AddToCache); IDbCommand? ownedCommand = null; DbDataReader? ownedReader = null; diff --git a/tests/Dapper.Tests/Providers/SqliteTests.cs b/tests/Dapper.Tests/Providers/SqliteTests.cs index b0c5e0f1b..f14e9ebb3 100644 --- a/tests/Dapper.Tests/Providers/SqliteTests.cs +++ b/tests/Dapper.Tests/Providers/SqliteTests.cs @@ -158,5 +158,51 @@ private class PersonWithDob public int Id { get; set; } public DateTime DoB { get; set; } } + + [FactSqlite] + public async Task Issue2240_MultiMapAsync_CacheHitAccounting() + { + using var connection = GetSQLiteConnection(); + SqlMapper.PurgeQueryCache(); + + var sqlGeneric = "SELECT 1 AS Id, 'Alice' AS Name, 2 AS ExtraId, 'Bob' AS ExtraName WHERE 0 /* issue2240_generic */"; + await connection.QueryAsync( + sqlGeneric, + (a, b) => a.Id + b.Id, + splitOn: "ExtraId").ConfigureAwait(false); + + var genericHits = SqlMapper.GetCachedSQL().Single(x => x.Item2 == sqlGeneric).Item3; + Assert.Equal(0, genericHits); + + // Second execution of the same query should count as reuse (hit count 1) + await connection.QueryAsync( + sqlGeneric, + (a, b) => a.Id + b.Id, + splitOn: "ExtraId").ConfigureAwait(false); + + genericHits = SqlMapper.GetCachedSQL().Single(x => x.Item2 == sqlGeneric).Item3; + Assert.Equal(1, genericHits); + + var sqlTypes = "SELECT 1 AS Id, 'Alice' AS Name, 2 AS ExtraId, 'Bob' AS ExtraName WHERE 0 /* issue2240_types */"; + await connection.QueryAsync( + sqlTypes, + new[] { typeof(HazNameId), typeof(HazNameId) }, + objs => ((HazNameId)objs[0]).Id + ((HazNameId)objs[1]).Id, + splitOn: "ExtraId").ConfigureAwait(false); + + var typesHits = SqlMapper.GetCachedSQL().Single(x => x.Item2 == sqlTypes).Item3; + Assert.Equal(0, typesHits); + + // Second execution of the same query should count as reuse (hit count 1) + await connection.QueryAsync( + sqlTypes, + new[] { typeof(HazNameId), typeof(HazNameId) }, + objs => ((HazNameId)objs[0]).Id + ((HazNameId)objs[1]).Id, + splitOn: "ExtraId").ConfigureAwait(false); + + typesHits = SqlMapper.GetCachedSQL().Single(x => x.Item2 == sqlTypes).Item3; + Assert.Equal(1, typesHits); + } } } +