Skip to content

Comments

Fix RowVersion concurrency issue when replacing entities with TPH inheritance and owned types#37788

Draft
Copilot wants to merge 3 commits intomainfrom
copilot/fix-rowversion-concurrency-issue
Draft

Fix RowVersion concurrency issue when replacing entities with TPH inheritance and owned types#37788
Copilot wants to merge 3 commits intomainfrom
copilot/fix-rowversion-concurrency-issue

Conversation

Copy link
Contributor

Copilot AI commented Feb 24, 2026

When deleting an entity with an owned type and inserting a sibling entity with the same PK in a single SaveChanges (TPH + concurrency token), EF Core generates two UPDATE statements for the same row, causing DbUpdateConcurrencyException.

Root Cause

In SharedTableEntryMap.GetMainEntry(), when entity replacement occurs (delete EntityA + add EntityB, same PK), FindPrincipal() locates EntityB in the identity map (it replaced EntityA) but FilterIncompatiblePrincipal() filters it out because EntityA.IsAssignableFrom(EntityB) is false—they're sibling types. This causes the owned entity to fall out of GetMainEntry() as its own main entry, producing a separate ModificationCommand (initialized as Modified by the constructor). Two UPDATEs then target the same row; the second fails because the first already changed the RowVersion.

Changes

  • Reproduction test in CommandBatchPreparerTest: sets up TPH model with EntityBase (RowVersion concurrency token), EntityA (owns OwnedEntity), and EntityB; verifies that deleting EntityA and adding EntityB with the same PK produces a single Modified command rather than two conflicting ones.
// Scenario that triggers the bug
var entityA = await dbContext.Set<EntityA>().SingleAsync(x => x.Id == id);
dbContext.Remove(entityA);
await dbContext.AddAsync(new EntityB(id, "Any"));
await dbContext.SaveChangesAsync(); // DbUpdateConcurrencyException

Note: The fix itself is not yet complete—only the root cause analysis and failing reproduction test are included. The fix needs to teach GetMainEntry() to resolve through SharedIdentityEntry when FindPrincipal() returns null due to entity type incompatibility during replacement.

Original prompt

This section details on the original issue you should resolve

<issue_title>RowVersion concurrency issue when replacing entities with inheritance and owned types</issue_title>
<issue_description>### Bug description

The issue can best be demonstrated using a simple sample application. Explaining it purely in text is relatively difficult.

ConsoleApp.zip

The structure of the sample application is as follows:

  • There is a base class EntityBase which contains a RowVersion property used as a concurrency token.
  • EntityA and EntityB inherit from EntityBase and add their own additional properties.
  • EntityB additionally contains a complex type OwnedEntity, which is mapped as an owned type.
  • The DbContext configuration is relatively simple.

When the following steps are performed:

  • Load an existing EntityA
  • Delete that entity
  • Create and save a new EntityB using the same primary key

the following exception is thrown:
'Unhandled exception. Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException: The database operation was expected to affect 1 row(s), but actually affected 0 row(s); data may have been modified or deleted since entities were loaded. See https://go.microsoft.com/fwlink/?LinkId=527962 for information on understanding and handling optimistic concurrency exceptions.
at Microsoft.EntityFrameworkCore.Update.AffectedCountModificationCommandBatch.ThrowAggregateUpdateConcurrencyExceptionAsync(RelationalDataReader reader, Int32 commandIndex, Int32 expectedRowsAffected, Int32 rowsAffected, CancellationToken cancellationToken)'

The issue only occurs with this specific combination of:

  • inheritance
  • RowVersion configured as a concurrency token, and
  • the presence of an owned entity.

The problem can be worked around by either:

  • removing the OwnedEntity from EntityB (including its mapping), or
  • commenting out the b.Property(x => x.RowVersion) configuration in the DbContext.

Your code

public abstract class EntityBase(string id)
{
    public string Id { get; } = id;

    public long RowVersion { get; }
}


public record OwnedEntity(DateTime CreationDate);

public class EntityA(string id, bool someValue,  OwnedEntity? owned) : EntityBase(id)
{
    protected EntityA()
        : this(null!, false, null)
    {
    }

    public bool SomeValue { get; } = someValue;

    public OwnedEntity? Owned { get; private set; } = owned;
}

public class EntityB(
    string id,
    string name) : EntityBase(id)
{
    protected EntityB() : this(null!, null!)
    {
    }

    public string Name { get; } = name;
}

public class MyDbContext(DbContextOptions<MyDbContext> options) : DbContext(options)
{
    protected override void OnModelCreating(
        ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<EntityBase>(
            b =>
            {
                b.HasDiscriminator<string>("Type")
                    .HasValue<EntityA>(nameof(EntityA))
                    .HasValue<EntityB>(nameof(EntityB));

                b.HasKey(x => x.Id);
                b.Property(x => x.Id).HasMaxLength(10);

                b.Property(x => x.RowVersion)
                    .IsRequired()
                    .IsRowVersion()
                    .IsConcurrencyToken()
                    .HasConversion<byte[]>();
            });

        modelBuilder.Entity<EntityA>(
            b =>
            {
                b.Property(x => x.SomeValue);
                
                b.OwnsOne(
                    x => x.Owned,
                    x =>
                    {
                        x.Property(x => x.CreationDate);
                    });
            });

         modelBuilder.Entity<EntityB>(
            b =>
            {
                b.Property(x => x.Name).HasMaxLength(100);
            });
    }
}

const string id = "SOMEID";

await using var dbContext = MyContextFactory.Create();
var entityA = await dbContext.Set<EntityA>().Where(x => x.Id == id).SingleAsync();
   
dbContext.Remove(entityA);

var entityB = new EntityB(id, "Any");

await dbContext.AddAsync(entityB);

await dbContext.SaveChangesAsync();

Stack traces

Unhandled exception. Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException: The database operation was expected to affect 1 row(s), but actually affected 0 row(s); data may have been modified or deleted since entities were loaded. See https://go.microsoft.com/fwlink/?LinkId=527962 for information on understanding and handling optimistic concurrency exceptions.
   at Microsoft.EntityFrameworkCore.Update.AffectedCountModificationCommandBatch.ThrowAggregateUpdateConcurrencyExceptionAsync(RelationalDataReader reader, Int32 commandIndex, Int32 expectedRowsAffected, Int32 rowsAffected, CancellationToken cancellationToken)
   at Microsoft.EntityFrameworkCore.Update.AffectedCountModificationCommandBatch.ConsumeResultSetAsync(Int32 startCommandIndex, RelationalDataReader reader, Cancellatio...

</details>



<!-- START COPILOT CODING AGENT SUFFIX -->

- Fixes dotnet/efcore#37588

<!-- START COPILOT CODING AGENT TIPS -->
---

🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. [Learn more about Advanced Security.](https://gh.io/cca-advanced-security)

Copilot AI and others added 2 commits February 24, 2026 06:26
…n bug

Add test reproducing a bug where deleting EntityA (with owned type) and
adding EntityB (same PK) in TPH generates an UPDATE command that may
interfere with RowVersion concurrency token handling when table sharing
is involved due to owned entity types.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…t for issue #37169

Co-authored-by: AndriySvyryd <6539701+AndriySvyryd@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix RowVersion concurrency issue with inheritance and owned types Fix RowVersion concurrency issue when replacing entities with TPH inheritance and owned types Feb 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants