From c0bd14faa22506ac29a5faa36c8b0ea7805197f5 Mon Sep 17 00:00:00 2001 From: jnm2 Date: Thu, 6 Aug 2026 21:34:09 -0400 Subject: [PATCH 1/4] Add failing repro for deserializer cache tearing between result shapes One SQL string, one connection string and one parameter type resolve to a single Identity, so every caller shares one CacheInfo slot. Only the value of @mode changes the result shape - the same thing a branching stored procedure does in production. CacheInfo.Deserializer holds a multi-field struct published by a plain field write, so a reader can observe a Hash from one shape paired with the Func compiled for another shape. The guard then passes and the wrong deserializer runs, throwing InvalidCastException or silently returning wrong values. One test per site that reads the cached deserializer. All eight fail on this commit. --- .../DeserializerCacheConcurrencyTests.cs | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 tests/Dapper.Tests/DeserializerCacheConcurrencyTests.cs diff --git a/tests/Dapper.Tests/DeserializerCacheConcurrencyTests.cs b/tests/Dapper.Tests/DeserializerCacheConcurrencyTests.cs new file mode 100644 index 000000000..bd7ffff34 --- /dev/null +++ b/tests/Dapper.Tests/DeserializerCacheConcurrencyTests.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Dapper.Tests +{ + /// + /// One SQL string, one connection string, one parameter type - so Dapper resolves a single + /// Identity and every caller shares one cache slot. Only the value of + /// @mode changes the result shape, which is what a branching stored procedure does in + /// production. Each test drives one of the sites that reads the cached deserializer. + /// + [Collection(NonParallelDefinition.Name)] + public class DeserializerCacheConcurrencyTests + { + private const string Sql = "select 1 as Id, case when @mode = 1 then 'abc' else 42 end as Value"; + + private const int Threads = 16, Iterations = 50_000; + + public class Row + { + public int Id { get; set; } + public string? Value { get; set; } + } + + [Fact] + public void QueryImpl_DoesNotReuseAnotherShapesDeserializer() + => AssertShapesNeverMix((cnn, mode) => Task.FromResult(cnn.Query(Sql, new { mode }).Single())); + + [Fact] + public void ReadRow_DoesNotReuseAnotherShapesDeserializer() + => AssertShapesNeverMix((cnn, mode) => Task.FromResult(cnn.QuerySingle(Sql, new { mode }))); + + [Fact] + public void QueryAsync_DoesNotReuseAnotherShapesDeserializer() + => AssertShapesNeverMix(async (cnn, mode) => (await cnn.QueryAsync(Sql, new { mode })).Single()); + + [Fact] + public void QueryUnbufferedAsync_DoesNotReuseAnotherShapesDeserializer() + => AssertShapesNeverMix(async (cnn, mode) => + { + await foreach (var row in cnn.QueryUnbufferedAsync(Sql, new { mode })) return row; + throw new InvalidOperationException("no rows"); + }); + + [Fact] + public void GridReaderReadImpl_DoesNotReuseAnotherShapesDeserializer() + => AssertShapesNeverMix((cnn, mode) => + { + using var grid = cnn.QueryMultiple(Sql, new { mode }); + return Task.FromResult(grid.Read().Single()); + }); + + [Fact] + public void GridReaderReadRow_DoesNotReuseAnotherShapesDeserializer() + => AssertShapesNeverMix((cnn, mode) => + { + using var grid = cnn.QueryMultiple(Sql, new { mode }); + return Task.FromResult(grid.ReadSingle()); + }); + + [Fact] + public void GridReaderReadAsyncImpl_DoesNotReuseAnotherShapesDeserializer() + => AssertShapesNeverMix(async (cnn, mode) => + { + using var grid = await cnn.QueryMultipleAsync(Sql, new { mode }); + return (await grid.ReadAsync()).Single(); + }); + + [Fact] + public void GridReaderReadRowAsyncImpl_DoesNotReuseAnotherShapesDeserializer() + => AssertShapesNeverMix(async (cnn, mode) => + { + using var grid = await cnn.QueryMultipleAsync(Sql, new { mode }); + return await grid.ReadSingleAsync(); + }); + + private static void AssertShapesNeverMix(Func> read) + { + var failures = new ConcurrentQueue(); + var connections = new List(); + var workers = new List(); + + for (int i = 0; i < Threads; i++) + { + int mode = (i % 2) + 1; + string expected = mode == 1 ? "abc" : "42"; + var connection = OpenConnection(); + connections.Add(connection); + workers.Add(Task.Factory.StartNew(() => + { + for (int n = 0; n < Iterations; n++) + { + try + { + var row = read(connection, mode).GetAwaiter().GetResult(); + if (row.Value != expected) failures.Enqueue($"mode {mode}: expected Value={expected}, got {row.Value}"); + } + catch (Exception ex) + { + failures.Enqueue($"mode {mode}: {ex.GetBaseException().Message}"); + } + } + }, TaskCreationOptions.LongRunning)); + } + + try + { + Task.WaitAll(workers.ToArray()); + } + finally + { + foreach (var connection in connections) connection.Dispose(); + } + + Assert.True(failures.IsEmpty, $"{failures.Count} corrupt read(s); first few:{Environment.NewLine}" + + string.Join(Environment.NewLine, failures.Take(5))); + } + + private static SqliteConnection OpenConnection() + { + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + return connection; + } + } +} From a97bce2bae6a7cc61da7fee6df20d08c61ded374 Mon Sep 17 00:00:00 2001 From: jnm2 Date: Thu, 6 Aug 2026 22:01:43 -0400 Subject: [PATCH 2/4] Refactor DeserializerState to a class to fix cache tearing DeserializerState was a struct with an int Hash and a Func reference, assigned into CacheInfo.Deserializer by a plain property write. A struct that size is never written or read atomically, so concurrent callers could pair a Hash from one result shape with the Func compiled for another. The Hash then matched the reader in hand, the guard passed, and the wrong deserializer ran. Making it a sealed class turns publication into one reference store, which is atomic. Writes only happen on a guard miss, so reads get cheaper: one load instead of two. --- Dapper/SqlMapper.Async.cs | 4 ++-- Dapper/SqlMapper.CacheInfo.cs | 2 +- Dapper/SqlMapper.DeserializerState.cs | 5 ++++- Dapper/SqlMapper.GridReader.Async.cs | 4 ++-- Dapper/SqlMapper.GridReader.cs | 4 ++-- Dapper/SqlMapper.cs | 12 ++++++------ 6 files changed, 17 insertions(+), 14 deletions(-) diff --git a/Dapper/SqlMapper.Async.cs b/Dapper/SqlMapper.Async.cs index eade08cb2..00ec81abe 100644 --- a/Dapper/SqlMapper.Async.cs +++ b/Dapper/SqlMapper.Async.cs @@ -435,7 +435,7 @@ private static async Task> QueryAsync(this IDbConnection cnn, var tuple = info.Deserializer; int hash = GetColumnHash(reader); - if (tuple.Func is null || tuple.Hash != hash) + if (tuple is null || tuple.Hash != hash) { if (reader.FieldCount == 0) return Enumerable.Empty(); @@ -1308,7 +1308,7 @@ static async IAsyncEnumerable Impl(IDbConnection cnn, Type effectiveType, Com var tuple = info.Deserializer; int hash = GetColumnHash(reader); - if (tuple.Func is null || tuple.Hash != hash) + if (tuple is null || tuple.Hash != hash) { if (reader.FieldCount == 0) { diff --git a/Dapper/SqlMapper.CacheInfo.cs b/Dapper/SqlMapper.CacheInfo.cs index 69edc4eea..db86c4f7c 100644 --- a/Dapper/SqlMapper.CacheInfo.cs +++ b/Dapper/SqlMapper.CacheInfo.cs @@ -9,7 +9,7 @@ public static partial class SqlMapper { private sealed class CacheInfo { - public DeserializerState Deserializer { get; set; } + public DeserializerState? Deserializer { get; set; } public Func[]? OtherDeserializers { get; set; } public Action? ParamReader { get; set; } private int hitCount; diff --git a/Dapper/SqlMapper.DeserializerState.cs b/Dapper/SqlMapper.DeserializerState.cs index 4b594e0f5..f7e24b9fb 100644 --- a/Dapper/SqlMapper.DeserializerState.cs +++ b/Dapper/SqlMapper.DeserializerState.cs @@ -6,7 +6,10 @@ namespace Dapper { public static partial class SqlMapper { - private readonly struct DeserializerState + // Reference type on purpose: this is published into CacheInfo by a plain field write, + // so it must be a single atomic reference store. A multi-field struct tears, pairing a + // Hash with the Func compiled for a different result shape. + private sealed class DeserializerState { public readonly int Hash; public readonly Func Func; diff --git a/Dapper/SqlMapper.GridReader.Async.cs b/Dapper/SqlMapper.GridReader.Async.cs index a32d53124..ae106aae5 100644 --- a/Dapper/SqlMapper.GridReader.Async.cs +++ b/Dapper/SqlMapper.GridReader.Async.cs @@ -186,7 +186,7 @@ private Func ValidateAndMarkConsumed(Type type, out int in var deserializer = cache.Deserializer; int hash = GetColumnHash(reader); - if (deserializer.Func is null || deserializer.Hash != hash) + if (deserializer is null || deserializer.Hash != hash) { deserializer = new DeserializerState(hash, GetDeserializer(type, reader, 0, -1, false)); cache.Deserializer = deserializer; @@ -206,7 +206,7 @@ private async Task ReadRowAsyncImpl(Type type, Row row) var deserializer = cache.Deserializer; int hash = GetColumnHash(reader); - if (deserializer.Func is null || deserializer.Hash != hash) + if (deserializer is null || deserializer.Hash != hash) { deserializer = new DeserializerState(hash, GetDeserializer(type, reader, 0, -1, false)); cache.Deserializer = deserializer; diff --git a/Dapper/SqlMapper.GridReader.cs b/Dapper/SqlMapper.GridReader.cs index 1370f7cc9..3bdb7bd5b 100644 --- a/Dapper/SqlMapper.GridReader.cs +++ b/Dapper/SqlMapper.GridReader.cs @@ -196,7 +196,7 @@ private IEnumerable ReadImpl(Type type, bool buffered) var deserializer = cache.Deserializer; int hash = GetColumnHash(reader); - if (deserializer.Func is null || deserializer.Hash != hash) + if (deserializer is null || deserializer.Hash != hash) { deserializer = new DeserializerState(hash, GetDeserializer(type, reader, 0, -1, false)); cache.Deserializer = deserializer; @@ -217,7 +217,7 @@ private T ReadRow(Type type, Row row) var deserializer = cache.Deserializer; int hash = GetColumnHash(reader); - if (deserializer.Func is null || deserializer.Hash != hash) + if (deserializer is null || deserializer.Hash != hash) { deserializer = new DeserializerState(hash, GetDeserializer(type, reader, 0, -1, false)); cache.Deserializer = deserializer; diff --git a/Dapper/SqlMapper.cs b/Dapper/SqlMapper.cs index 2fa0e72b7..69178b0f0 100644 --- a/Dapper/SqlMapper.cs +++ b/Dapper/SqlMapper.cs @@ -1221,7 +1221,7 @@ private static IEnumerable QueryImpl(this IDbConnection cnn, CommandDefini // in the connection closing itself var tuple = info.Deserializer; int hash = GetColumnHash(reader); - if (tuple.Func is null || tuple.Hash != hash) + if (tuple is null || tuple.Hash != hash) { if (reader.FieldCount == 0) //https://code.google.com/p/dapper-dot-net/issues/detail?id=57 yield break; @@ -1361,7 +1361,7 @@ private static T ReadRow(CacheInfo info, Identity identity, ref CommandDefini { var tuple = info.Deserializer; int hash = GetColumnHash(reader); - if (tuple.Func is null || tuple.Hash != hash) + if (tuple is null || tuple.Hash != hash) { tuple = info.Deserializer = new DeserializerState(hash, GetDeserializer(effectiveType, reader, 0, -1, false)); if (command.AddToCache) SetQueryCache(identity, info); @@ -1600,11 +1600,11 @@ private static IEnumerable MultiMapImpl[]? otherDeserializers; int hash = GetColumnHash(reader); - if ((deserializer = cinfo.Deserializer).Func is null || (otherDeserializers = cinfo.OtherDeserializers) is null || hash != deserializer.Hash) + if ((deserializer = cinfo.Deserializer) is null || (otherDeserializers = cinfo.OtherDeserializers) is null || hash != deserializer.Hash) { var deserializers = GenerateDeserializers(identity, splitOn, reader); deserializer = cinfo.Deserializer = new DeserializerState(hash, deserializers[0]); @@ -1671,11 +1671,11 @@ private static IEnumerable MultiMapImpl(this IDbConnection? cn ownedReader = ExecuteReaderWithFlagsFallback(ownedCommand, wasClosed, CommandBehavior.SequentialAccess | CommandBehavior.SingleResult); reader = ownedReader; } - DeserializerState deserializer; + DeserializerState? deserializer; Func[]? otherDeserializers; int hash = GetColumnHash(reader); - if ((deserializer = cinfo.Deserializer).Func is null || (otherDeserializers = cinfo.OtherDeserializers) is null || hash != deserializer.Hash) + if ((deserializer = cinfo.Deserializer) is null || (otherDeserializers = cinfo.OtherDeserializers) is null || hash != deserializer.Hash) { var deserializers = GenerateDeserializers(identity, splitOn, reader); deserializer = cinfo.Deserializer = new DeserializerState(hash, deserializers[0]); From a4aa0e9905f5e40e089e492f7de5ca41902b6500 Mon Sep 17 00:00:00 2001 From: jnm2 Date: Thu, 6 Aug 2026 22:01:44 -0400 Subject: [PATCH 3/4] Stale 'tuple' variable name, be consistent --- Dapper/SqlMapper.Async.cs | 16 ++++++++-------- Dapper/SqlMapper.cs | 16 ++++++++-------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Dapper/SqlMapper.Async.cs b/Dapper/SqlMapper.Async.cs index 00ec81abe..89d615341 100644 --- a/Dapper/SqlMapper.Async.cs +++ b/Dapper/SqlMapper.Async.cs @@ -433,17 +433,17 @@ private static async Task> QueryAsync(this IDbConnection cnn, if (wasClosed) await cnn.TryOpenAsync(cancel).ConfigureAwait(false); reader = await ExecuteReaderWithFlagsFallbackAsync(cmd, wasClosed, CommandBehavior.SequentialAccess | CommandBehavior.SingleResult, cancel).ConfigureAwait(false); - var tuple = info.Deserializer; + var deserializer = info.Deserializer; int hash = GetColumnHash(reader); - if (tuple is null || tuple.Hash != hash) + if (deserializer is null || deserializer.Hash != hash) { if (reader.FieldCount == 0) return Enumerable.Empty(); - tuple = info.Deserializer = new DeserializerState(hash, GetDeserializer(effectiveType, reader, 0, -1, false)); + deserializer = info.Deserializer = new DeserializerState(hash, GetDeserializer(effectiveType, reader, 0, -1, false)); if (command.AddToCache) SetQueryCache(identity, info); } - var func = tuple.Func; + var func = deserializer.Func; if (command.Buffered) { @@ -1306,19 +1306,19 @@ static async IAsyncEnumerable Impl(IDbConnection cnn, Type effectiveType, Com if (wasClosed) await cnn.TryOpenAsync(cancel).ConfigureAwait(false); reader = await ExecuteReaderWithFlagsFallbackAsync(cmd, wasClosed, CommandBehavior.SequentialAccess | CommandBehavior.SingleResult, cancel).ConfigureAwait(false); - var tuple = info.Deserializer; + var deserializer = info.Deserializer; int hash = GetColumnHash(reader); - if (tuple is null || tuple.Hash != hash) + if (deserializer is null || deserializer.Hash != hash) { if (reader.FieldCount == 0) { yield break; } - tuple = info.Deserializer = new DeserializerState(hash, GetDeserializer(effectiveType, reader, 0, -1, false)); + deserializer = info.Deserializer = new DeserializerState(hash, GetDeserializer(effectiveType, reader, 0, -1, false)); if (command.AddToCache) SetQueryCache(identity, info); } - var func = tuple.Func; + var func = deserializer.Func; var convertToType = Nullable.GetUnderlyingType(effectiveType) ?? effectiveType; while (await reader.ReadAsync(cancel).ConfigureAwait(false)) diff --git a/Dapper/SqlMapper.cs b/Dapper/SqlMapper.cs index 69178b0f0..3a3f5c5cf 100644 --- a/Dapper/SqlMapper.cs +++ b/Dapper/SqlMapper.cs @@ -1219,17 +1219,17 @@ private static IEnumerable QueryImpl(this IDbConnection cnn, CommandDefini // with the CloseConnection flag, so the reader will deal with the connection; we // still need something in the "finally" to ensure that broken SQL still results // in the connection closing itself - var tuple = info.Deserializer; + var deserializer = info.Deserializer; int hash = GetColumnHash(reader); - if (tuple is null || tuple.Hash != hash) + if (deserializer is null || deserializer.Hash != hash) { if (reader.FieldCount == 0) //https://code.google.com/p/dapper-dot-net/issues/detail?id=57 yield break; - tuple = info.Deserializer = new DeserializerState(hash, GetDeserializer(effectiveType, reader, 0, -1, false)); + deserializer = info.Deserializer = new DeserializerState(hash, GetDeserializer(effectiveType, reader, 0, -1, false)); if (command.AddToCache) SetQueryCache(identity, info); } - var func = tuple.Func; + var func = deserializer.Func; var convertToType = Nullable.GetUnderlyingType(effectiveType) ?? effectiveType; while (reader.Read()) { @@ -1359,15 +1359,15 @@ private static T QueryRowImpl(IDbConnection cnn, Row row, ref CommandDefiniti [MethodImpl(MethodImplOptions.AggressiveInlining)] private static T ReadRow(CacheInfo info, Identity identity, ref CommandDefinition command, Type effectiveType, DbDataReader reader) { - var tuple = info.Deserializer; + var deserializer = info.Deserializer; int hash = GetColumnHash(reader); - if (tuple is null || tuple.Hash != hash) + if (deserializer is null || deserializer.Hash != hash) { - tuple = info.Deserializer = new DeserializerState(hash, GetDeserializer(effectiveType, reader, 0, -1, false)); + deserializer = info.Deserializer = new DeserializerState(hash, GetDeserializer(effectiveType, reader, 0, -1, false)); if (command.AddToCache) SetQueryCache(identity, info); } - var func = tuple.Func; + var func = deserializer.Func; object? val = func(reader); return GetValue(reader, effectiveType, val); } From 1e5d62f06fdaacbc96ffe3a6e8e5d49f9853c775 Mon Sep 17 00:00:00 2001 From: jnm2 Date: Thu, 6 Aug 2026 21:39:05 -0400 Subject: [PATCH 4/4] Repro and fix another race with OtherDeserializers Making DeserializerState a reference type fixed tearing within the primary deserializer, but multi-map kept its rest-set in a second CacheInfo field. Two fields cannot be assigned together, and the gap between the two writes spans a GenerateDeserializers call and a Skip(1).ToArray(), so a reader could easily take one shape's Deserializer and another shape's OtherDeserializers. The Hash matched, the guard passed, and the mapper ran a rest-set built for a different result shape. Folding it into DeserializerState makes the whole set move under the single reference write, and removes a field. The two new tests fail without this change: 15721 and 9752 corrupt reads respectively, versus a handful for the single-type paths - the window here is far wider. --- Dapper/SqlMapper.CacheInfo.cs | 1 - Dapper/SqlMapper.DeserializerState.cs | 8 ++- Dapper/SqlMapper.cs | 20 +++----- .../DeserializerCacheConcurrencyTests.cs | 51 +++++++++++++------ 4 files changed, 50 insertions(+), 30 deletions(-) diff --git a/Dapper/SqlMapper.CacheInfo.cs b/Dapper/SqlMapper.CacheInfo.cs index db86c4f7c..7fe757fc0 100644 --- a/Dapper/SqlMapper.CacheInfo.cs +++ b/Dapper/SqlMapper.CacheInfo.cs @@ -10,7 +10,6 @@ public static partial class SqlMapper private sealed class CacheInfo { public DeserializerState? Deserializer { get; set; } - public Func[]? OtherDeserializers { get; set; } public Action? ParamReader { get; set; } private int hitCount; public int GetHitCount() { return Interlocked.CompareExchange(ref hitCount, 0, 0); } diff --git a/Dapper/SqlMapper.DeserializerState.cs b/Dapper/SqlMapper.DeserializerState.cs index f7e24b9fb..77e33bf7b 100644 --- a/Dapper/SqlMapper.DeserializerState.cs +++ b/Dapper/SqlMapper.DeserializerState.cs @@ -8,16 +8,20 @@ public static partial class SqlMapper { // Reference type on purpose: this is published into CacheInfo by a plain field write, // so it must be a single atomic reference store. A multi-field struct tears, pairing a - // Hash with the Func compiled for a different result shape. + // Hash with the Func compiled for a different result shape. OtherDeserializers lives + // here rather than in a second CacheInfo field for the same reason - two fields cannot + // be updated together, so a reader could pair one shape's Func with another's rest-set. private sealed class DeserializerState { public readonly int Hash; public readonly Func Func; + public readonly Func[]? OtherDeserializers; - public DeserializerState(int hash, Func func) + public DeserializerState(int hash, Func func, Func[]? otherDeserializers = null) { Hash = hash; Func = func; + OtherDeserializers = otherDeserializers; } } } diff --git a/Dapper/SqlMapper.cs b/Dapper/SqlMapper.cs index 3a3f5c5cf..bd9ced44c 100644 --- a/Dapper/SqlMapper.cs +++ b/Dapper/SqlMapper.cs @@ -1600,19 +1600,17 @@ private static IEnumerable MultiMapImpl[]? otherDeserializers; + var deserializer = cinfo.Deserializer; int hash = GetColumnHash(reader); - if ((deserializer = cinfo.Deserializer) is null || (otherDeserializers = cinfo.OtherDeserializers) is null || hash != deserializer.Hash) + if (deserializer?.OtherDeserializers is null || hash != deserializer.Hash) { var deserializers = GenerateDeserializers(identity, splitOn, reader); - deserializer = cinfo.Deserializer = new DeserializerState(hash, deserializers[0]); - otherDeserializers = cinfo.OtherDeserializers = deserializers.Skip(1).ToArray(); + deserializer = cinfo.Deserializer = new DeserializerState(hash, deserializers[0], deserializers.Skip(1).ToArray()); if (command.AddToCache) SetQueryCache(identity, cinfo); } - Func mapIt = GenerateMapper(deserializer.Func, otherDeserializers, map); + Func mapIt = GenerateMapper(deserializer.Func, deserializer.OtherDeserializers!, map); if (mapIt is not null) { @@ -1671,19 +1669,17 @@ private static IEnumerable MultiMapImpl(this IDbConnection? cn ownedReader = ExecuteReaderWithFlagsFallback(ownedCommand, wasClosed, CommandBehavior.SequentialAccess | CommandBehavior.SingleResult); reader = ownedReader; } - DeserializerState? deserializer; - Func[]? otherDeserializers; + var deserializer = cinfo.Deserializer; int hash = GetColumnHash(reader); - if ((deserializer = cinfo.Deserializer) is null || (otherDeserializers = cinfo.OtherDeserializers) is null || hash != deserializer.Hash) + if (deserializer?.OtherDeserializers is null || hash != deserializer.Hash) { var deserializers = GenerateDeserializers(identity, splitOn, reader); - deserializer = cinfo.Deserializer = new DeserializerState(hash, deserializers[0]); - otherDeserializers = cinfo.OtherDeserializers = deserializers.Skip(1).ToArray(); + deserializer = cinfo.Deserializer = new DeserializerState(hash, deserializers[0], deserializers.Skip(1).ToArray()); if (command.AddToCache) SetQueryCache(identity, cinfo); } - Func mapIt = GenerateMapper(types.Length, deserializer.Func, otherDeserializers, map); + Func mapIt = GenerateMapper(types.Length, deserializer.Func, deserializer.OtherDeserializers!, map); if (mapIt is not null) { diff --git a/tests/Dapper.Tests/DeserializerCacheConcurrencyTests.cs b/tests/Dapper.Tests/DeserializerCacheConcurrencyTests.cs index bd7ffff34..46ab41f18 100644 --- a/tests/Dapper.Tests/DeserializerCacheConcurrencyTests.cs +++ b/tests/Dapper.Tests/DeserializerCacheConcurrencyTests.cs @@ -9,16 +9,21 @@ namespace Dapper.Tests { /// - /// One SQL string, one connection string, one parameter type - so Dapper resolves a single - /// Identity and every caller shares one cache slot. Only the value of - /// @mode changes the result shape, which is what a branching stored procedure does in - /// production. Each test drives one of the sites that reads the cached deserializer. + /// One SQL string, one connection string and one parameter type resolve to a single + /// Identity, so every caller shares one CacheInfo slot. Only the value + /// of @mode changes the result shape, which is what a branching stored procedure does + /// in production. Each test drives one of the sites that reads the cached deserializer. /// [Collection(NonParallelDefinition.Name)] public class DeserializerCacheConcurrencyTests { + // @mode picks the type of Value: text in mode 1, integer in mode 2. private const string Sql = "select 1 as Id, case when @mode = 1 then 'abc' else 42 end as Value"; + // Same idea, but the shape only varies in the second mapped type, so this exercises the + // pairing of the primary deserializer with the rest of the set. + private const string MultiMapSql = "select 1 as Id, 'fixed' as Value, 2 as Id, case when @mode = 1 then 'abc' else 42 end as Label"; + private const int Threads = 16, Iterations = 50_000; public class Row @@ -27,23 +32,29 @@ public class Row public string? Value { get; set; } } + public class Tag + { + public int Id { get; set; } + public string? Label { get; set; } + } + [Fact] public void QueryImpl_DoesNotReuseAnotherShapesDeserializer() - => AssertShapesNeverMix((cnn, mode) => Task.FromResult(cnn.Query(Sql, new { mode }).Single())); + => AssertShapesNeverMix((cnn, mode) => Task.FromResult(cnn.Query(Sql, new { mode }).Single().Value)); [Fact] public void ReadRow_DoesNotReuseAnotherShapesDeserializer() - => AssertShapesNeverMix((cnn, mode) => Task.FromResult(cnn.QuerySingle(Sql, new { mode }))); + => AssertShapesNeverMix((cnn, mode) => Task.FromResult(cnn.QuerySingle(Sql, new { mode }).Value)); [Fact] public void QueryAsync_DoesNotReuseAnotherShapesDeserializer() - => AssertShapesNeverMix(async (cnn, mode) => (await cnn.QueryAsync(Sql, new { mode })).Single()); + => AssertShapesNeverMix(async (cnn, mode) => (await cnn.QueryAsync(Sql, new { mode })).Single().Value); [Fact] public void QueryUnbufferedAsync_DoesNotReuseAnotherShapesDeserializer() => AssertShapesNeverMix(async (cnn, mode) => { - await foreach (var row in cnn.QueryUnbufferedAsync(Sql, new { mode })) return row; + await foreach (var row in cnn.QueryUnbufferedAsync(Sql, new { mode })) return row.Value; throw new InvalidOperationException("no rows"); }); @@ -52,7 +63,7 @@ public void GridReaderReadImpl_DoesNotReuseAnotherShapesDeserializer() => AssertShapesNeverMix((cnn, mode) => { using var grid = cnn.QueryMultiple(Sql, new { mode }); - return Task.FromResult(grid.Read().Single()); + return Task.FromResult(grid.Read().Single().Value); }); [Fact] @@ -60,7 +71,7 @@ public void GridReaderReadRow_DoesNotReuseAnotherShapesDeserializer() => AssertShapesNeverMix((cnn, mode) => { using var grid = cnn.QueryMultiple(Sql, new { mode }); - return Task.FromResult(grid.ReadSingle()); + return Task.FromResult(grid.ReadSingle().Value); }); [Fact] @@ -68,7 +79,7 @@ public void GridReaderReadAsyncImpl_DoesNotReuseAnotherShapesDeserializer() => AssertShapesNeverMix(async (cnn, mode) => { using var grid = await cnn.QueryMultipleAsync(Sql, new { mode }); - return (await grid.ReadAsync()).Single(); + return (await grid.ReadAsync()).Single().Value; }); [Fact] @@ -76,10 +87,20 @@ public void GridReaderReadRowAsyncImpl_DoesNotReuseAnotherShapesDeserializer() => AssertShapesNeverMix(async (cnn, mode) => { using var grid = await cnn.QueryMultipleAsync(Sql, new { mode }); - return await grid.ReadSingleAsync(); + return (await grid.ReadSingleAsync()).Value; }); - private static void AssertShapesNeverMix(Func> read) + [Fact] + public void MultiMapImplGeneric_DoesNotPairDeserializersFromDifferentShapes() + => AssertShapesNeverMix((cnn, mode) => Task.FromResult( + cnn.Query(MultiMapSql, (_, tag) => tag.Label, new { mode }, splitOn: "Id").Single())); + + [Fact] + public void MultiMapImplTypeArray_DoesNotPairDeserializersFromDifferentShapes() + => AssertShapesNeverMix((cnn, mode) => Task.FromResult( + cnn.Query(MultiMapSql, new[] { typeof(Row), typeof(Tag) }, values => ((Tag)values[1]).Label, new { mode }, splitOn: "Id").Single())); + + private static void AssertShapesNeverMix(Func> read) { var failures = new ConcurrentQueue(); var connections = new List(); @@ -97,8 +118,8 @@ private static void AssertShapesNeverMix(Func> { try { - var row = read(connection, mode).GetAwaiter().GetResult(); - if (row.Value != expected) failures.Enqueue($"mode {mode}: expected Value={expected}, got {row.Value}"); + var actual = read(connection, mode).GetAwaiter().GetResult(); + if (actual != expected) failures.Enqueue($"mode {mode}: expected {expected}, got {actual}"); } catch (Exception ex) {