jleach
jleach

Reputation: 7792

EF Core tracking child objects unnecessarily

The Problem

Entity Framework Core v3.1.4

I'm working on some forum software, and want to add a new Topic to a Board. The Board has tens of thousands of topics, and when I use board.Topics.Add(topic), it's iterating and tracking all topics of that board, which is taking forever (e.g., minutes).

var board = _commandContext.Boards.Find(boardID);

var topic = new Topic() { 
    BoardID = boardID,
    Board = board
    Title = ...
    ...
};

// takes minutes, tracks all topics in the Board instance
board.Topics.Add(topic);

... in the above code, when I hit the board.Topics.Add(topic) line, the debugger spits out one of these for each topic in the board (which is tens of thousands...):

Microsoft.EntityFrameworkCore.ChangeTracking: Debug: Context 'CommandDataContext' started tracking 'Topic' entity. Consider using 'DbContextOptionsBuilder.EnableSensitiveDataLogging' to see key values.

The Question

Why is EF deciding it needs to track all board topics when I run _commandContext.Topics.Add(topic);? I make subsequent calls to things like board.GetLatestTopic(), which I fully expect it to execute a query for, but a) that shouldn't have to track all topics, and b) this tracking happens prior to any other calls.

This seems unnecessary and I don't recall it being a particular issue in EF6.

Alternate Method

If I add the entity using this method:

_commandContext.Topics.Add(topic);

... the process if fast (all board topics are not tracked), but then subsequent calls to things like board.GetLatestTopic() don't work because that topic wasn't tracked.

Entities and EF Config

My Board and Topic entities are straightforward, nothing I haven't done in dozens of other projects:

public class Board
{
    public int ID { get; set; }
    public BoardStatus Status { get; set; } = BoardStatus.Inactive;
    public BoardType Type { get; set; } = BoardType.Standard;
    public string Name { get; set; }
    public string Description { get; set; }
    ...
    
    public virtual ICollection<Topic> Topics { get; set; } = new List<Topic>();

    public Topic LatestTopic {
        get {
            return this.Topics.Where(x => x.IsStandard).OrderByDescending(x => x.DateStartedUTC).FirstOrDefault();
        }
    }
}

My EF configuration is also straightforward:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    if (!optionsBuilder.IsConfigured)
    {
        optionsBuilder.UseLoggerFactory(_loggerFactory);
        optionsBuilder.UseLazyLoadingProxies();
        optionsBuilder.UseSqlServer(connectionString);
    }
}
...
modelBuilder.Entity<Board>(e => {
    e.ToTable("Boards", "forum");
    e.HasOne(x => x.Category)
        .WithMany(x => x.Boards)
        .HasForeignKey(x => x.CategoryID);
    e.HasMany(x => x.Topics)
        .WithOne(x => x.Board)
        .HasForeignKey(x => x.BoardID);
});

Upvotes: 2

Views: 1930

Answers (1)

lauxjpn
lauxjpn

Reputation: 5254

From Saving Related Data: Adding a graph of new entities:

If you create several new related entities, adding one of them to the context will cause the others to be added too.

So this is the default behavior and is the way the Add() and AddRange() (and other) methods are implemented.

However, you can just set the entity's state by yourself, or even override how EF Core tracks entire graphs (for the latter one, see Disconnected entities: TrackGraph):

_commandContext.Entry(topic).State = EntityState.Added;

This just adds the topic object without related entities.


Here is a simple but fully working sample console project, that demonstrates this:

using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace IssueConsoleTemplate
{
    public class IceCream
    {
        public int IceCreamId { get; set; }
        public string Name { get; set; }
        public int IceCreamShopId { get; set; }

        public IceCreamShop IceCreamShop { get; set; }
    }

    public class IceCreamShop
    {
        public int IceCreamShopId { get; set; }
        public string Name { get; set; }
        
        public virtual ICollection<IceCream> IceCreams { get; set; } = new HashSet<IceCream>();
    }

    public class Context : DbContext
    {
        public DbSet<IceCream> IceCreams { get; set; }
        public DbSet<IceCreamShop> IceCreamShops { get; set; }

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            optionsBuilder
                .UseSqlServer(@"Data Source=.\MSSQL14;Integrated Security=SSPI;Initial Catalog=So63094891")
                .UseLoggerFactory(
                    LoggerFactory.Create(
                        b => b
                            .AddConsole()
                            .AddFilter(level => level >= LogLevel.Information)))
                .EnableSensitiveDataLogging()
                .EnableDetailedErrors();
        }
    }

    internal static class Program
    {
        private static void Main()
        {
            AddShopWithIceCreams();
            CheckIceCreams(2);
            
            AddShopWithoutIceCreams();
            CheckIceCreams(0);
        }

        private static void AddShopWithIceCreams()
        {
            using var context = new Context();

            context.Database.EnsureDeleted();
            context.Database.EnsureCreated();

            var newShop = new IceCreamShop
            {
                Name = "My Shop",
                IceCreams =
                {
                    new IceCream {Name = "Vanilla", IceCreamShopId = 1},
                    new IceCream {Name = "Chocolate", IceCreamShopId = 1},
                }
            };

            context.IceCreamShops.Add(newShop);
            context.SaveChanges();
        }

        private static void AddShopWithoutIceCreams()
        {
            using var context = new Context();

            context.Database.EnsureDeleted();
            context.Database.EnsureCreated();

            var newShop = new IceCreamShop
            {
                Name = "My Shop",
                IceCreams =
                {
                    new IceCream {Name = "Vanilla", IceCreamShopId = 1},
                    new IceCream {Name = "Chocolate", IceCreamShopId = 1},
                }
            };

            context.Entry(newShop).State = EntityState.Added;
            context.SaveChanges();
        }

        public static void CheckIceCreams(int iceCreamCount)
        {
            using var context = new Context();
            
            var shops = context.IceCreamShops
                .Include(s => s.IceCreams)
                .ToList();

            Debug.Assert(shops.Count == 1);
            Debug.Assert(shops[0].IceCreams.Count == iceCreamCount);
        }
    }
}

How change tracking traversal works

Since this came up in the comments, here is an except from the internal ChangeTracker.TrackGraph() method's description summary, that sums up pretty well how the change tracking traversal works:

Begins tracking an entity and any entities that are reachable by traversing it's navigation properties.

Traversal is recursive so the navigation properties of any discovered entities will also be scanned.

[...]

If an entity is discovered that is already tracked by the context, that entity is not processed (and it's navigation properties are not traversed).

Upvotes: 3

Related Questions