dotnet / efcore

EF Core is a modern object-database mapper for .NET. It supports LINQ queries, change tracking, updates, and schema migrations.
https://docs.microsoft.com/ef/
MIT License
13.63k stars 3.15k forks source link

Adding multi-tenancy causes Deadlock #20896

Closed granthoff1107 closed 1 year ago

granthoff1107 commented 4 years ago

I recently upgraded from 2.1 - 3.1, the functionality was existing and working before the upgrade an now it's broken. I'll post the full code at the bottom, but I'll keep the important details at the top.

When I add constraints to the DB I get a deadlock. When making any request against the table with constraints. e.g context.Conversations.ToList();

My db context sets the constraints from the authorizationOptions passed in.

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            var constraintOptions = this._authorizationOptions.ConstraintOptions;
            constraintOptions.ApplyStaticConstraint(modelBuilder, this);
            base.OnModelCreating(modelBuilder);
        }

I have a class which allows DB constraint registration from the start up.

    public static class FilterExtensions
    {
        public static T Variable<T>(this DbContext context, T value) => value;
    }

    public class ContextAuthorizationOptions : DbAuthorizationOptions<AstootContext>
    {
        protected IUserAuthenticationManager _userManager;
        protected int _userId => this._userManager.GetUserId();

        public ContextAuthorizationOptions(IUserAuthenticationManager authenticationManager, 
            IValidator<AstootContext> contextValidator) 
            : base(contextValidator)
        {
            this._userManager = authenticationManager;

            ConstraintOptions.SetConstraint<StripeConnectAccount>(c =>
                x => x.UserId == c.Variable(this._userId));

            ConstraintOptions.SetConstraint<Transaction>(c =>
                x => x.SenderAccount.UserId == c.Variable(this._userId));

            ConstraintOptions.SetConstraint<Conversation>(c => 
                x => x.ConversationSubscriptions.Select(cs => cs.UserId)
                      .Any(userId => userId == c.Variable(this._userId))
            );

            ConstraintOptions.SetConstraint<ConversationSubscription>(c =>
                x => x.Conversation.ConversationSubscriptions.Any(cs => cs.UserId == c.Variable(this._userId)));

            ConstraintOptions.SetConstraint<UserMeeting>(c =>
                x => x.Meeting.UserMeetings.Any(um => um.UserId == c.Variable(this._userId)));

            ConstraintOptions.SetConstraint<Message>(c => 
                x => x.Conversation.ConversationSubscriptions.Select(cs => cs.UserId)
                      .Any(userId => userId == c.Variable(this._userId))
            );
        }        
    }

    public class DbAuthorizationOptions
    {
        public DbAuthorizationOptions(IValidator<DbContext> contextValidator) : this()
        {
            this.ContextValidator = contextValidator;
        }

        protected DbAuthorizationOptions()
        {
            this.ConstraintOptions = new ConstraintOptions();
        }

        public ConstraintOptions ConstraintOptions { get; set; } = new ConstraintOptions();
        internal IValidator ContextValidator { get; set; }
    }

    public class DbAuthorizationOptions<T> : DbAuthorizationOptions
        where T : DbContext
    {
        public DbAuthorizationOptions(IValidator<T> contextValidator) 
            : base()
        {
            this.ContextValidator = contextValidator;
        }
    }

And in case you need the Db structure:

public partial class Conversation
{
    public Conversation()
    {
        ConversationSubscriptions = new HashSet<ConversationSubscription>();
        Messages = new HashSet<Message>();
    }

    public int Id { get; set; }
    public DateTime Created { get; set; }

    public ICollection<ConversationSubscription> ConversationSubscriptions { get; set; }
    public ICollection<Message> Messages { get; set; }
}

public partial class ConversationSubscription
{
    public int Id { get; set; }
    public int ConversationId { get; set; }
    public int UserId { get; set; }
    public DateTime SubscribeTime { get; set; }
    public bool IsSubscribed { get; set; }
    public DateTime? UnsubscribeTime { get; set; }
    public Conversation Conversation { get; set; }
    public User User { get; set; }
}
ajcvickers commented 4 years ago

@granthoff1107 Please attach a small, runnable project or post a small, runnable code listing that reproduces what you are seeing so that we can investigate.

granthoff1107 commented 4 years ago

@ajcvickers, it might take me a bit, for now I'll target which constraint is causing it to fail

granthoff1107 commented 4 years ago

I'm pretty sure I've figured out what's going on, I'm just trying to figure out a work around now. Basically I need a way to ignore QueryFilters from the ModelBuilder.

There's a lot of layered architecture so I'll try to translated the query into it's basic form and explain what's going on.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
      modelBuilder.Entity<ConversationSubscription>()
           .HasQueryFilter(x => x.Conversation.ConversationSubscriptions
                .Select(c => c.UserId).Contains(315));
}

I have subscription based entities, In order to know if you have access to a subscription, you need to check if the the parent has any subscriptions matching your Id. e.g(you should have access to another users subscription if you are subscribed to the same event as the other user).

When EF attempts to resolve the subscriptions, the filter needs to access the subscriptions which then it looks to resolve the subscription filter, and continues in an endless loop.

granthoff1107 commented 4 years ago

I've attempted to work around this by querying the context however it throws an

modelBuilder.Entity<ConversationSubscription>().HasQueryFilter(x => 
        this.Set<ConversationSubscription().AsNoTracking().IgnoreQueryFilters()
            .Where(cs => cs.ConversationId == x.ConversationId)
                  .Select(c => c.UserId)
                  .Contains(315));

InvalidOperationException

System.InvalidOperationException: 'Processing of the LINQ expression '(NavigationExpansionExpression Source: DbSet PendingSelector: c1 => (NavigationTreeExpression Value: (EntityReference: ConversationSubscription) Expression: c1) ) .AsNoTracking()' by 'NavigationExpandingExpressionVisitor' failed. This may indicate either a bug or a limitation in EF Core. See https://go.microsoft.com/fwlink/?linkid=2101433 for more detailed information.'

I feel like there is a way I can hack around it if I trick the context into thinking what I'm selecting isn't the entity, some how select the ConversationSubsriptions into an AnonymousType, so they're unfiltered.

granthoff1107 commented 4 years ago

Apparently I can user DBSet, except I need to remove the AsNoTracking and IgnoreQueryFilters since they're not supported.

modelBuilder.Entity<ConversationSubscription>().HasQueryFilter(x => 
        this.Set<ConversationSubscription()
            .Where(cs => cs.ConversationId == x.ConversationId)
                  .Select(c => c.UserId)
                  .Contains(315));