Skip to content

BulkSaveChanges corrupts the EF identity map for owned entities with shadow composite keys, causing "already tracked" exceptions on subsequent operations #648

@mr-miles

Description

@mr-miles

Environment

  • Z.EntityFramework.Extensions.EFCore 9.105.4
  • Microsoft.EntityFrameworkCore / SqlServer 9.0.6
  • .NET 8 / 9

Summary

After calling BulkSaveChangesAsync to insert an owner entity thta has "OwnsMany(...).ToTable(...)" children with shadow composite keys, the owned entities are still present in the ChangeTracker.Entries() (state = Unchanged) but IStateManager.TryGetValue(key, values) returns null for them.

Because the EF identity map is no longer consistent, any subsequent attempt to track a different instance of the same owned entity (same shadow key values) throws:

InvalidOperationException: The instance of entity type 'OrderLine' cannot be tracked because another instance with the same key value for { 'OrderId', 'Index' } is already being tracked

This does not happen with standard SaveChangesAsync()

Minimal repro

public class Order
{
  public Guid Id {get;set;}
  public List<OrderLine> Lines {get;set;} = new();
}

public class OrderLine
{
  public string Description {get; set;} = "";
  // keyed by shadow properties - OrderID FK and Index position
}

modelBuilder.Entity<Order>(b =>
{
  b.HasKey(o => o.Id);
  b.OwnsMany<OrderLine>(o => o.Lines, x=> {
    x.ToTable("OrderLine");
    x.Property<Guid>("OrderId");
    x.Property<int>("Index");
    x.HasKey("OrderId", "Index");
    x.WithOwner().HasForeignKey("OrderId")
  });
});

await using var cts = bnew MyDbContext(options);

var orderId = Guid.NewGuid();
var order = new Order { Id = orderId };
order.Lines.AddRange(new[] {
  new OrderLine { Description = "Line1" },
  new OrderLine { Description = "Line2" }
});

int lineIdx = 0;
ctx.ChangeTracker.TrackGraph(order, node => {
  if (node.Entry.Entity is Order) {
    node.Entry.State = EntityState.Added;
  }
  else if (node.Entry.Entity is OrderLine) {
    node.Entry.Property("OrderId").CurrentValue =orderId;
    node.Entry.Propertyy("Index").CurrentValue = ++lineIdx;
    node.Entry.State = EntityState.Added;
  }
  return true;;
});

await ctx.BulkSaveChangesAsync();

var tracked = ctx.ChangeTracker.Entries<OrderLine>().ToList();
Console.WriteLine($"Tracked entries: {tracked.Count}"); // prints 2 - tick!

var stateManager = ctx.GetService<IStateManager>();
var key = ctx.Model.FindEntityType(typeof(OrderLine))!.GetKeys().First();

var found = stateManager.TryGetEntry(key, new object[]{ orderId, 1 });
Console.WriteLine($"TryGetEntry result; {found}"); // prints NULL - bug

// consequence
var line1Updated = new OrderLine{ Description = "Line 1 Updated" };
ctx.ChangeTracker.TrackGraph(line1Updated, node => {
  node.Entry.Property("OrderId").CurrentValue = orderId;
  node.Entry.Property("Index").CurrentValue = 1;
  node.Entry.State = EntityState.Modified // <-- throws
  return true;
});

Expected behaviour

After BulkSaveChangesAsync, entries that were inserted should be in state Unchanged and findable through IStateManager.TryGetEntry - consisten with SaveChangesAsync()

Actual behaviour

ChangeTracker.Entries() still returns the inserted entries (so they are not detatched) but IStateManager.TryGetEntry returns null for each. The internal identiy map (IStateManager's key -> entry dict) is no longer consistent with the tracked entry list.

NB

Possibly related to #568?

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions