Search code examples
c#asp.netsql-serverdapper

Dapper: Invalid attempt to call NextResult when reader is closed


I am getting the following error - sometimes - when trying to execute multiple statements using .NET 5.0, Dapper 2.0.78 with async and MSSQL Server:

System.InvalidOperationException: Invalid attempt to call NextResult when reader is closed. at Microsoft.Data.SqlClient.SqlDataReader.TryNextResult(Boolean& more)
at Microsoft.Data.SqlClient.SqlDataReader.NextResult() at Dapper.SqlMapper.GridReader.NextResult() in //Dapper/SqlMapper.GridReader.cs:line 414 at Dapper.SqlMapper.GridReader.ReadDeferred[T](Int32 index, Func 2 deserializer, Type effectiveType)+System.IDisposable.Dispose() at Dapper.SqlMapper.GridReader.ReadDeferred[T](Int32 index, Func 2 deserializer, Type effectiveType)+MoveNext() in //Dapper/SqlMapper.GridReader.cs:line 384 at System.Collections.Generic.List 1..ctor(IEnumerable 1 collection)
at System.Linq.Enumerable.ToList[TSource](IEnumerable`1 source)

It does not happen all the time. I suspect the SQL connection is being closed unintentionally. What can be wrong?

To debug I have tested the SQL statements and looked at the Execution Plan in SSMS - nothing here is flagged, i.e. proper indexes, primary keys are configured, it executes in very short time. SQL Server has

  • 16 GB RAM
  • SQL Server 2016 latest updates
  • Intel Xeon E5-2630 v4 @ 2.2 GHz, 6 virtual processors.

Here is my code which is relatively simple.

private async Task<RecipeListModel> GetRecipesByIngredientAsync(int id)
    {

        string sql = @"SELECT Id,Title FROM dbo.[Ingredients] WHERE Id = @id;
                       SELECT ID,Title FROM dbo.[Recipes] WHERE IngredientId = @Id;" // simplified for the example

        RecipeListModel model = new() { };

        using (SqlConnection conn = new("my connection here"))
        {
            var data = await conn.QueryMultipleAsync(sql, new { id }).ConfigureAwait(false);

            model = new RecipeListModel
            {
                Ingredient = data.ReadAsync<Ingredient>().Result.SingleOrDefault(),
                Recipes = data.ReadAsync<Recipe>().Result.ToList()
            };
        }
        return model; // exception happens here.
    }


public class Ingredient
{
    public int Id { get; set; }
    public string Title { get; set; }
    // ...
}

public class Recipe
{
    public int Id { get; set; }
    public string Title { get; set; }
    // ...
}

public class RecipeListModel
{
    public IEnumerable<Recipe> Recipes { get; set; }
    public Ingredient Ingredient { get; set; }
    // ...
}

UPDATE WITH CHANGES FROM COMMENTS:

    [Route("~/{lang}/ingredient/{title}-{id}", Name = "ingredient-view")]
    public async Task<IActionResult> Ingredient(string title, int id, string q, int page = 1)
    {

        if (ModelState.IsValid)
        {
            var model = await GetRecipesByIngredientAsync(page, id, title, q).ConfigureAwait(false);
            if (model.Ingredient == null || Common.ClearUrl(model.Ingredient.Title) != title) // for SEO purposes, make sure that we do not have a tampered URL
            {
                return NotFound();
            }
            if (model.Recipes.Any())
            {
                var total = model.Recipes.First().TotalRows;
                model.TotalRows = total;
                model.Pager = new Pager(total, page, 8);
                model.q = string.IsNullOrEmpty(q) ? "" : q.ToString();
                return View(model);
            }
            else
            {
                RecipeListModel empty = new()
                {
                    Recipes = new List<Recipe>() { new Recipe() { Title = "" } },
                    Pager = new Pager(0, 0, 1),
                    q = q
                };
                return View(empty);
            }
        }
        return NotFound();
    }

    private async Task<RecipeListModel> GetRecipesByIngredientAsync(int p, int id, string title, string q)
    {

        string searchSql = @" -- optional (search phrase can be empty)    
                                    INNER JOIN FREETEXTTABLE([dbo].[FT_Recipes_v], *, @SearchPhrase, LANGUAGE 'English') AS recipesFullSearch
                                    ON(r.Id = recipesFullSearch.[Key])";

        string sql = string.Format(@"SELECT Id,Title FROM dbo.[Ingredients] WHERE Id = @id;

                                ;WITH RowCounter AS (
                                SELECT COUNT(r.Id) as TotalRows
                                FROM
                                    [dbo].[Recipes] r
                                    INNER JOIN [dbo].[RecipeIngredients] RI ON RI.Recipe_Id = r.Id
                                    INNER JOIN [dbo].[Ingredients] I ON RI.Ingredient_Id = I.Id
                                            {0} -- inject search phrase here if not empty

                                WHERE 
                                    [Active] = 1 AND [Approved] = 1 
                                    AND I.Id = @Id
                                ),
                               
                                DataRows AS (
                                SELECT 
                                    r.Id
                                    ,STUFF( -- combine all tags into a 'csv list' like pepper[i:123]salt[i:124]...
                                            (
                                                SELECT TOP 3
                                                    ']' + Title + '[i:' + CAST(Ingredient_Id AS nvarchar(11)) [text()]
                                                FROM 
                                                    (
                                                        SELECT 
                                                            RI.Recipe_Id
                                                            ,RI.Ingredient_Id
                                                            ,I.Title
                                                        FROM dbo.RecipeIngredients AS RI
                                                        INNER JOIN dbo.Ingredients AS I 
                                                            ON RI.Ingredient_Id = I.Id
                                                        -- here's the relation to the main query
                                                        WHERE RI.Recipe_Id = r.Id
                                                    ) TempTable
                                                FOR XML PATH(''), TYPE
                                            ).value('.','nvarchar(max)'),1,1,''
                                        ) IngredientsCSV
                                ,r.Title
                                ,LEFT(r.Description,260) as Description
                                ,d.Title AS DishTypeTitle
                                ,I.Title AS IngredientTitle
                                ,RF.[file]
                            FROM
                                    [dbo].[Recipes] r
                                            INNER JOIN [dbo].[DishTypes] d ON r.DishType_Id = d.Id
                                            INNER JOIN [dbo].[RecipeIngredients] RI ON RI.Recipe_Id = r.Id
                                            INNER JOIN [dbo].[Ingredients] I ON RI.Ingredient_Id = I.Id
                                        {0} -- inject search phrase here if not empty
                            OUTER APPLY 
                            (SELECT TOP 1 recipe_id,[file] FROM dbo.RecipeFiles WHERE recipe_id = r.id ) RF
                            WHERE
                                    [Active] = 1 AND [Approved] = 1 AND I.[Id] = @Id
                            ORDER BY 
                                r.Id DESC
                            OFFSET (@PageNumber) ROWS 
                            FETCH FIRST (@RowsPerPage) ROWS ONLY
                        )
                            SELECT 
                                    dr.*,
                                    (select TotalRows from rowcounter) as TotalRows
                                FROM 
                                    DataRows dr;"
                            , !string.IsNullOrEmpty(q) ? searchSql : ""
                            );

        using (SqlConnection conn = new("data source=someip;initial catalog=mydb;persist security info=True;user id=u;password=p"))
        {
            using (var data = await conn.QueryMultipleAsync(sql, new
            {
                SearchPhrase = q,
                id,
                PageNumber = (p - 1) * 8,
                RowsPerPage = 8

            }).ConfigureAwait(false))
            {
                return new RecipeListModel
                {
                    Ingredient = await data.ReadSingleOrDefaultAsync<Ingredient>().ConfigureAwait(false),
                    Recipes = await data.ReadAsync<Recipe>().ConfigureAwait(false)
                };
            }          
        }
    }

Here is the full event log (notice the NextResultAsync instead of NextResult previously):

RequestPath: /ingredient/pepper-123

An unhandled exception has occurred while executing the request.

Exception: System.InvalidOperationException: Invalid attempt to call NextResultAsync when reader is closed. at Microsoft.Data.Common.ADP.ExceptionWithStackTrace(Exception e) --- End of stack trace from previous location --- at Dapper.SqlMapper.GridReader.NextResultAsync() in /_/Dapper/SqlMapper.GridReader.Async.cs:line 157 at Dapper.SqlMapper.GridReader.ReadBufferedAsync[T](Int32 index, Func2 deserializer) in /_/Dapper/SqlMapper.GridReader.Async.cs:line 241 at myproject.Controllers.HomeController.GetRecipesByIngredientAsync(Int32 p, Int32 id, String title, String q) in C:\Web\myproject\myproject-net\Controllers\HomeController.cs:line 502 at myproject.Controllers.HomeController.Ingredient(String title, Int32 id, String q, Int32 page) in C:\Web\myproject\myproject-net\Controllers\HomeController.cs:line 125 at Microsoft.AspNetCore.Mvc.Infrastructure.ActionMethodExecutor.TaskOfIActionResultExecutor.Execute(IActionResultTypeMapper mapper, ObjectMethodExecutor executor, Object controller, Object[] arguments) at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.<InvokeActionMethodAsync>g__Awaited|12_0(ControllerActionInvoker invoker, ValueTask1 actionResultValueTask) at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.g__Awaited|10_0(ControllerActionInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted) at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Rethrow(ActionExecutedContextSealed context) at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted) at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.g__Awaited|13_0(ControllerActionInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted) at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.g__Awaited|24_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted) at Microsoft.AspNetCore.Mvc.Filters.MiddlewareFilterBuilder.<>c.<b__8_0>d.MoveNext() --- End of stack trace from previous location --- at Microsoft.AspNetCore.Localization.RequestLocalizationMiddleware.Invoke(HttpContext context) at Microsoft.AspNetCore.Localization.RequestLocalizationMiddleware.Invoke(HttpContext context) at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.g__Awaited|24_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted) at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Rethrow(ResourceExecutedContextSealed context) at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted) at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.g__Awaited|19_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted) at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.g__Awaited|17_0(ResourceInvoker invoker, Task task, IDisposable scope) at Microsoft.AspNetCore.Routing.EndpointMiddleware.g__AwaitRequestTask|6_0(Endpoint endpoint, Task requestTask, ILogger logger) at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context) at Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context) at SixLabors.ImageSharp.Web.Middleware.ImageSharpMiddleware.Invoke(HttpContext context) at Microsoft.AspNetCore.Diagnostics.StatusCodePagesMiddleware.Invoke(HttpContext context) at WebMarkupMin.AspNetCore2.WebMarkupMinMiddleware.ProcessAsync(HttpContext context, Boolean useMinification, Boolean useCompression) at WebMarkupMin.AspNetCore2.WebMarkupMinMiddlewareBase.Invoke(HttpContext context) at Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware.g__Awaited|6_0(ExceptionHandlerMiddleware middleware, HttpContext context, Task task)


Solution

  • I can't say for sure, but it appears to be a bug in ReadSingleOrDefaultAsync which causes it to close the connection.

    The idea is to fully read all resultsets before you close the connection, so you need to use something that will do that. This could be foreach, ToList, ToDictionary or SingleOrDefault.

    So instead use ReadAsync and feed it into the standard SingleOrDefault.

    model = new RecipeListModel
    {
        Ingredient = (await data.ReadAsync<Ingredient>().ConfigureAwait(false)).SingleOrDefault(),
        Recipes = (await data.ReadAsync<Recipe>().ConfigureAwait(false)).ToList(),
    }
    

    I hope to get a chance to debug the Dapper source code, and update this answer with exactly what is going wrong.