1

I'm having trouble with a LINQ query after joining a new table to it. Actually, it returns the data I'm expecting and it runs fast in testing. However, it seems that, as more users connect to the database, the query begins to timeout. For example, everything was working fine for the first 30 or 45 minutes in Production, but then at about 8:20 AM, it started to timeout. Again, I assume this is due to increased usage of the database on the whole.

Here is a little background on the ASP.NET MVC (5) application, in case that helps.

  • A user submits a referral to our clinic
  • The referral contains one or more orders
  • If the person information supplied does not match an existing person, I do several things, including inserting records in an "orders" table (one record for each order selected on the referral).
  • If the person information supplied does match an existing person in our system, then I "hold" the referral in a queue until it is manually resolved by either matching it to an existing person or by overriding it and creating a new person in the system. At this time, any orders selected in the referral are created in the table.

So, the two main tables to think about in this scenario are the "referral" (named "Referrals" in my code) and "order" (named "ReferralPPAs" in my code) tables. Until now, I have not needed to link the query in question from the Referrals table to the ReferralPPAs table (linking the query to the ReferralPPAs table seems to be what is slowing the query down once database/application usage increases).

Also, in case this helps, the referrals are entered by external users, while the orders I created from the referral are worked in a separate application with internal staff as the users, though it's all in the same database. The ReferralPPAs table is probably being used pretty heavily most of the day.

The query looks like this:

            IQueryable<ReferralListViewModel> referrals = (from r in _context.Referrals
                                                           join cu in _context.ClinicUsers on r.ClinicId equals cu.ClinicId
                                                           /* Here is the seemingly problematic join */ 
                                                           from ppa in _context.ReferralPPAs
                                                                        .Where(p => p.ref_id == r.seq_no.ToString())
                                                                        .DefaultIfEmpty()
                                                           /* End of seemingly problematic join */
                                                           join ec in _context.EnrolledClinics on r.ClinicId equals ec.ClinicId
                                                           join pm in _context.ProviderMasters on ec.ClinicId equals pm.ClinicId
                                                           join ml in _context.MasterLists on pm.HealthSystemGuid equals ml.Id
                                                           join au in _context.Users on r.ApplicationUserId equals au.Id
                                                           where cu.UserId == userId
                                                           select new ReferralListViewModel()
                                                              {
                                                                  ClinicName = pm.Description,
                                                                  ClinicId = r.ClinicId,
                                                                  ReferralId = r.seq_no,
                                                                  EnteredBy = (au.FirstName ?? string.Empty) + " " + (au.LastName ?? string.Empty),
                                                                  PatientName = (r.LastName ?? string.Empty) + ", " + (r.FirstName ?? string.Empty),
                                                                  DateEntered = r.create_timestamp,
                                                                  Status = ppa != null ? ppa.Status : string.Empty
                                                              });

So, without the join I make reference to above, I experience no problems and it runs quite fast. Adding the join also appears to be fast, again, until a certain number of users are on the system (at least that's my assumption).

A couple of other things I've tried to help improve performance and prevent the problem. I set the UseDatabaseNullSemantics to True, which seems to make a big difference in the overall performace.

_context.Configuration.UseDatabaseNullSemantics = true;

I also wondered if the problem was an issue of locking on the table in question, so I tried wrapping the query in a transaction to do a ReadUncommitted.

            using (var transaction = _context.Database.BeginTransaction(System.Data.IsolationLevel.ReadUncommitted))
            {
              //query
            }

Again, while this improves the overall performance a little bit, it didn't seem to ultimately resolve the problem.

If anyone has any thoughts, ideas, or suggestions on how to tackle this, I would greatly appreciate it.

14
  • I'm not super familiar with LINQ in this form, but why are you doing a .Where instead of another join? Does it not have left joins? Other thought would be that you appear to be changing data types by calling .ToString which gets real expensive real quick Commented Apr 17, 2018 at 21:28
  • dont be afraid to put some of these complex sql queries into the database as procs. they can be pre-compiled and run a lot faster since the execution plan is already built. Just because you can build a complex query with Linq doesnt mean is the best approach. Commented Apr 17, 2018 at 21:31
  • In order to get a LEFT OUTER JOIN (which is what I need here), you need to call DefaultIfEmpty() on the join. This is one example of the syntax that seemed to work. Regarding the .ToString(), I know that is expensive, but that is being done all over our system and believe it or not, it has not been a problem. I'm even doing it in another query that gets called on the same page load and it works quickly. Thanks for chiming in! Commented Apr 17, 2018 at 21:32
  • @Kevbo - I could do that if I wasn't doing searching, sorting, and paging in the controller this query lives inside. This is just the core query, but gets modified if any of those features are used. Commented Apr 17, 2018 at 21:34
  • 1
    No problem, that's why we are here :) Now, the problem is that there is no official way to remove the cast. I'm thinking of some hackery, will let you know when having something concrete. Commented Apr 19, 2018 at 8:48

1 Answer 1

3

Based on the additional information from the comments, looks like the Guid to String conversion in the join condition

p.ref_id == r.seq_no.ToString()

translated to

t1.ref_id = LOWER(CAST(t2.seq_no AS nvarchar(max))))

makes the query not sargable, while the implicit SqlServer conversion

t1.ref_id = t2.seq_no

works just fine.

So the question is how to remove that cast. There is no option for that and also query expression tree does not allow removing it. It would be nice if the SqlServer provider sql generator was doing that optimization, but it doesn't and there is no easy way to hook into it.

As a workaround I can offer the following solution. It uses a custom IDbCommandTreeInterceptor and DbExpressionVisitor to modify the DbCommandTree of the query.

Here is the interception code:

using System;
using System.Data.Entity.Core.Common.CommandTrees;
using System.Data.Entity.Core.Common.CommandTrees.ExpressionBuilder;
using System.Data.Entity.Core.Metadata.Edm;
using System.Data.Entity.Infrastructure.Interception;
using System.Linq.Expressions;
using System.Reflection;

namespace EFHacks
{
    public class MyDbCommandTreeInterceptor : IDbCommandTreeInterceptor
    {
        public void TreeCreated(DbCommandTreeInterceptionContext interceptionContext)
        {
            if (interceptionContext.OriginalResult.DataSpace != DataSpace.SSpace) return;
            var queryCommand = interceptionContext.Result as DbQueryCommandTree;
            if (queryCommand != null)
            {
                var newQuery = queryCommand.Query.Accept(new GuidToStringComparisonRewriter());
                if (newQuery != queryCommand.Query)
                {
                    interceptionContext.Result = new DbQueryCommandTree(
                        queryCommand.MetadataWorkspace,
                        queryCommand.DataSpace,
                        newQuery);
                }
            }
        }
    }

    class GuidToStringComparisonRewriter : DefaultExpressionVisitor
    {
        public override DbExpression Visit(DbComparisonExpression expression)
        {
            if (IsString(expression.Left.ResultType) && IsString(expression.Right.ResultType))
            {
                var left = expression.Left;
                var right = expression.Right;
                if (RemoveCast(ref right) || RemoveCast(ref left))
                    return CreateComparison(expression.ExpressionKind, left, right);
            }
            return base.Visit(expression);
        }

        static bool IsGuid(TypeUsage type)
        {
            var pt = type.EdmType as PrimitiveType;
            return pt != null && pt.PrimitiveTypeKind == PrimitiveTypeKind.Guid;
        }

        static bool IsString(TypeUsage type)
        {
            var pt = type.EdmType as PrimitiveType;
            return pt != null && pt.PrimitiveTypeKind == PrimitiveTypeKind.String;
        }

        static bool RemoveCast(ref DbExpression expr)
        {
            var funcExpr = expr as DbFunctionExpression;
            if (funcExpr != null &&
                funcExpr.Function.BuiltInTypeKind == BuiltInTypeKind.EdmFunction &&
                funcExpr.Function.FullName == "Edm.ToLower" &&
                funcExpr.Arguments.Count == 1)
            {
                var castExpr = funcExpr.Arguments[0] as DbCastExpression;
                if (castExpr != null && IsGuid(castExpr.Argument.ResultType))   
                {
                    expr = castExpr.Argument;
                    return true;
                }
            }
            return false;
        }

        static readonly Func<DbExpressionKind, DbExpression, DbExpression, DbComparisonExpression> CreateComparison = BuildCreateComparisonFunc();

        static Func<DbExpressionKind, DbExpression, DbExpression, DbComparisonExpression> BuildCreateComparisonFunc()
        {
            var kind = Expression.Parameter(typeof(DbExpressionKind), "kind");
            var booleanResultType = Expression.Field(null, typeof(DbExpressionBuilder), "_booleanType");
            var left = Expression.Parameter(typeof(DbExpression), "left");
            var right = Expression.Parameter(typeof(DbExpression), "right");
            var result = Expression.New(
                typeof(DbComparisonExpression).GetConstructor(BindingFlags.NonPublic | BindingFlags.Instance, null,
                new[] { kind.Type, booleanResultType.Type, left.Type, right.Type }, null),
                kind, booleanResultType, left, right);
            var expr = Expression.Lambda<Func<DbExpressionKind, DbExpression, DbExpression, DbComparisonExpression>>(
                result, kind, left, right);
            return expr.Compile();
        }
    }
}

and DbConfiguration to install it:

class MyDbConfiguration : DbConfiguration
{
    public MyDbConfiguration()
    {
        AddInterceptor(new EFHacks.MyDbCommandTreeInterceptor());
    }
}

Tested and working in EF6.1.3 and EF6.2 with SqlServer database.

But use it with care.

First, it works only for SqlServer.

Second, it's hackish because I had to use internal field and internal class constructor in order to skip the check for equal types of the comparison operation operands. So some future EF6 update might break it.

Sign up to request clarification or add additional context in comments.

10 Comments

This looks promising, but the code is not compiling for me. I have to admit--this looks a little intimidating, so maybe I'm just being a little gun shy about it. Also, does it matter where I put the MyDbConfiguration class? I am using EF 6.1.3 with MVC 5, if that makes any difference.
Yes, it does. It should be in the same assembly as the db context. The code itself is no more than intelligent find and replace - find ToLower(Cast(guidExpr, string)) and replace it with guidExpr :) What about compilation, the code is using C#7, but can easily be made to use old style constructs if you tell me what doesn't compile for you.
Ah, that must be it. Inside TreeCreated, queryCommand is underlined (first saying that it's expecting a closing parenthesis and all other instances state that it is not recognized in this context). None of the static bools below the Visit method compile, either. It must just be a C# 7 thing because every line is underlined. Would it be helpful to have screen shots? Let me know how I can give you better information about it.
Once you start using these new little language sugars, it's hard to go back :) Anyway, now it should compile fine in older C#.
I was able to resolve that issue and now I am off and running with this solution that you provided. I hate to use the word amazing, but the impact of this "hack" is actually amazing. Because we have this issue with UNIQUEIDENTIFIERs stored as GUIDs all over the system, this seems to vastly improve the overall performance of the application. Honestly, performance was pretty good before (other than this new issue), but now page loads are happening in milliseconds. I'd love to know a little bit more about how it works if you're interested in walking through the code. This is fantastic.
|

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.