Skip to content

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

Merged
AndriySvyryd merged 8 commits intomainfrom
copilot/fix-rowversion-concurrency-issue
Feb 25, 2026
Merged

Fix RowVersion concurrency issue when replacing entities with TPH inheritance and owned types#37788
AndriySvyryd merged 8 commits intomainfrom
copilot/fix-rowversion-concurrency-issue

Conversation

Copy link
Contributor

Copilot AI commented Feb 24, 2026

Fixes #37588

When performing a TPH entity replacement (deleting an entity and inserting a new one of a different derived type with the same primary key), the presence of an owned type combined with a RowVersion concurrency token caused a DbUpdateConcurrencyException.

The root cause was in SharedTableEntryMap.GetMainEntry(): for a deleted owned entry, FindPrincipal() returned null because the identity map contained the replacing entity (an incompatible sibling type). This caused the owned entry to generate its own separate DELETE command targeting the same row, which conflicted with the main UPDATE command.

The fix adds a fallback in GetMainEntry() that, when FindPrincipal() returns null, looks up the entry in the identity map by key values. If that entry has a SharedIdentityEntry whose entity type is compatible with the foreign key's principal type, it uses the identity map entry as the main entry, correctly grouping the owned entry under the replacement command.

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
Copilot AI and others added 2 commits February 25, 2026 01:29
…eritance and owned types

When deleting an entity with an owned type and inserting a sibling entity with
the same PK in TPH, the owned entry's principal can't be found via FindPrincipal
because the identity map contains the replacing entry (incompatible sibling type).

Fix GetMainEntry to check SharedIdentityEntry on the replacing entry found in the
identity map, and use the replacing entry as the main entry so the owned entry is
merged into the same modification command.

Fixes #37169

Co-authored-by: AndriySvyryd <6539701+AndriySvyryd@users.noreply.github.com>
Co-authored-by: AndriySvyryd <6539701+AndriySvyryd@users.noreply.github.com>
Co-authored-by: AndriySvyryd <6539701+AndriySvyryd@users.noreply.github.com>
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes an EF Core relational update batching edge case where TPH entity replacement (delete + add same PK) combined with owned types and a RowVersion concurrency token can result in multiple modification commands targeting the same row, leading to a DbUpdateConcurrencyException.

Changes:

  • Add a fallback in SharedTableEntryMap.GetMainEntry() to resolve the “main” entry via the identity map + SharedIdentityEntry when FindPrincipal() returns null due to incompatible sibling types during TPH replacement.
  • Add a relational update pipeline regression test covering TPH replacement with an owned type and concurrency token.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
src/EFCore.Relational/Update/Internal/SharedTableEntryMap.cs Adds identity-map fallback logic to keep owned entries grouped under the replacement entry for command generation.
test/EFCore.Relational.Tests/Update/CommandBatchPreparerTest.cs Adds regression coverage ensuring batching produces a single valid modification command and includes RowVersion as a condition.

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
…ffixes to 37588

Co-authored-by: AndriySvyryd <6539701+AndriySvyryd@users.noreply.github.com>
@AndriySvyryd AndriySvyryd marked this pull request as ready for review February 25, 2026 02:46
@AndriySvyryd AndriySvyryd requested a review from a team as a code owner February 25, 2026 02:46
Copilot AI review requested due to automatic review settings February 25, 2026 02:46
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

@AndriySvyryd
Copy link
Member

@copilot Update the PR description with a readable summary

Copy link
Contributor Author

Copilot AI commented Feb 25, 2026

@copilot Update the PR description with a readable summary

Updated the PR description with a readable summary covering the issue, root cause, and fix.

@AndriySvyryd AndriySvyryd merged commit fbf051c into main Feb 25, 2026
19 checks passed
@AndriySvyryd AndriySvyryd deleted the copilot/fix-rowversion-concurrency-issue branch February 25, 2026 16:40
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.

RowVersion concurrency issue when replacing entities with inheritance and owned types

4 participants