Skip to content

Fix in-memory corruption of nested owned entities after SaveChanges when navigation is replaced#37787

Merged
AndriySvyryd merged 8 commits intomainfrom
copilot/fix-in-memory-corruption
Feb 26, 2026
Merged

Fix in-memory corruption of nested owned entities after SaveChanges when navigation is replaced#37787
AndriySvyryd merged 8 commits intomainfrom
copilot/fix-in-memory-corruption

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Feb 24, 2026

Fixes #38095

When a JSON-mapped owned entity navigation is replaced with a new instance that shares nested child references (e.g., via C# record with expression), nested owned entities become null in memory after SaveChanges, despite being correctly persisted to the database.

product.Metadata = product.Metadata with { Color = "red" };
await context.SaveChangesAsync();
// product.Metadata.Weight is now null — throws NullReferenceException

Root cause

During the replace-and-save cycle, three things go wrong:

  1. The old owned entity tree is cascade-deleted, which marks shared child entities as Deleted
  2. EntityGraphAttacher.PaintAction only processes Detached entities, so shared children stuck in Deleted state are skipped when the new parent graph is attached
  3. StateManager.CascadeDelete re-cascades the old parent's deletion to the shared children, and DeleteFixup resolves the replacement principal via the identity map and nulls its navigation — corrupting the in-memory graph

Fix

  • EntityGraphAttacher.PaintAction/PaintActionAsync: Allow Deleted entities to be re-attached during graph traversal when the source entry has a SharedIdentityEntry (indicating the graph is being traversed from a replacement parent)
  • StateManager.CascadeDelete: Early-return when the entry has a SharedIdentityEntry, skipping cascade from old entries that have already been replaced
  • JsonUpdateTestBase: Add regression test Replace_json_reference_root_preserves_nested_owned_entities_in_memory
  • Provider test overrides: Add test overrides with SQL baselines in JsonUpdateJsonTypeSqlServerTest and JsonUpdateSqliteTest
Original prompt

This section details on the original issue you should resolve

<issue_title>In-Memory Corruption of Nested Owned Entities After SaveChanges with Record 'with' Expression</issue_title>
<issue_description>### Bug description

When using C# record's with expression on an owned entity stored as JSON, EF Core corrupts the in-memory representation of nested owned entities after calling SaveChanges(). The nested properties become null in memory, causing NullReferenceException when accessed, even though the data is correctly persisted to the database.

Steps to Reproduce

  1. Create an entity with an owned entity (C# record) stored as JSON that contains nested owned entities
  2. Add the entity to the context and call SaveChanges() (first save)
  3. Use the with expression to create a modified copy of the owned entity
  4. Call SaveChanges() again (second save)
  5. Access nested properties of the owned entity

Expected Behavior

The in-memory entity graph should remain intact after SaveChanges(). All nested properties of the owned entity should be accessible with their correct values.

In my reproduction code, product.Metadata.Weight.Value should remain 5 after the second SaveChanges().

Actual Behavior

After the second SaveChanges(), nested owned entities become null in memory. Accessing product.Metadata.Weight.Value throws NullReferenceException because Weight is now null.

Important: The data IS correctly persisted to the database. This is purely an in-memory corruption of the tracked entity.

Your code

Full reproduction code including failing test can be found here: https://github.com/gustavlarson/efcore-record-bug

using Microsoft.EntityFrameworkCore;

// Entity model
public class Product
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public ProductMetadata? Metadata { get; set; }
}

public record ProductMetadata
{
    public Weight? Weight { get; init; }
    public string? Color { get; init; }
}

public record Weight
{
    public int Value { get; init; }
    public string Unit { get; init; }
}

// DbContext configuration
public class TestDbContext : DbContext
{
    public TestDbContext(DbContextOptions<TestDbContext> options) : base(options) { }
    
    public DbSet<Product> Products => Set<Product>();
    
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Product>(entity =>
        {
            entity.HasKey(e => e.Id);
            entity.Property(e => e.Name).IsRequired().HasMaxLength(200);
            entity.OwnsOne(e => e.Metadata, metadata =>
            {
                metadata.ToJson();  // Key: stored as JSON
                metadata.OwnsOne(e => e.Weight);
            });
        });
    }
}

// Reproduction
var product = new Product 
{ 
    Name = "Test Product", 
    Metadata = new() 
    { 
        Weight = new() { Value = 5 } 
    }
};

context.Products.Add(product);
await context.SaveChangesAsync();  // First save - works fine

// Use 'with' expression to create modified copy
product.Metadata = product.Metadata with { Color = product.Metadata.Color };

// At this point, product.Metadata.Weight.Value is still 5
Console.WriteLine($"Before SaveChanges: {product.Metadata.Weight.Value}"); // Prints: 5

await context.SaveChangesAsync();  // Second save - triggers bug

// BUG: Weight is now null!
Console.WriteLine($"After SaveChanges: {product.Metadata.Weight.Value}"); // NullReferenceException!

Stack traces


Verbose output


EF Core version

10.0.2

Database provider

No response

Target framework

.NET 10

Operating system

Windows 11

IDE

No response</issue_description>

Comments on the Issue (you are @copilot in this section)


💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

Copilot AI and others added 2 commits February 24, 2026 06:41
…ith record with expression

When an owned entity stored as JSON is replaced with a new instance that shares
nested owned entity references (e.g., via C# record 'with' expression), the
change tracker now properly handles the shared child entities:

1. EntityGraphAttacher.PaintAction: Allow entities in Deleted state to be
   re-attached when encountered during graph traversal of a new parent entity.
2. StateManager.CascadeDelete: Skip cascade deletion for dependents whose
   current principal (from identity map) has been replaced by a new entity
   via shared identity.

Fixes #36017

Co-authored-by: AndriySvyryd <6539701+AndriySvyryd@users.noreply.github.com>
Co-authored-by: AndriySvyryd <6539701+AndriySvyryd@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix in-memory corruption of nested owned entities after SaveChanges Fix in-memory corruption of nested owned entities after SaveChanges when navigation is replaced Feb 24, 2026
Comment thread src/EFCore/ChangeTracking/Internal/StateManager.cs Outdated
…feedback

- StateManager.CascadeDelete: Early-return when entry.SharedIdentityEntry is set,
  skipping cascade entirely since the replacement handles its own dependents.
- EntityGraphAttacher.PaintAction/PaintActionAsync: Allow Deleted entries only when
  the source entry has SharedIdentityEntry (indicating traversal from a replacement
  entity), rather than broadly allowing all Deleted entries.

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

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 a change-tracking bug where replacing a JSON-mapped owned reference with a new instance (while reusing nested owned instances) could leave nested owned navigations null in-memory after SaveChanges, despite correct persistence.

Changes:

  • Allow EntityGraphAttacher to re-attach Deleted entries during traversal when coming from a replacement graph (SharedIdentityEntry path).
  • Skip cascading deletes from entries participating in shared-identity replacement in StateManager.CascadeDelete.
  • Add a regression test covering replace-with-shared-nested-owned behavior for JSON-owned graphs.

Reviewed changes

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

File Description
test/EFCore.Relational.Specification.Tests/Update/JsonUpdateTestBase.cs Adds regression test for replacing a JSON-owned root while reusing a nested owned leaf instance.
src/EFCore/ChangeTracking/Internal/StateManager.cs Avoids cascading delete from entries involved in shared-identity replacement scenarios.
src/EFCore/ChangeTracking/Internal/EntityGraphAttacher.cs Expands graph attachment to include certain Deleted entries when traversing from a replacement graph.

Comment thread src/EFCore/ChangeTracking/Internal/StateManager.cs
Comment thread src/EFCore/ChangeTracking/Internal/EntityGraphAttacher.cs
AndriySvyryd and others added 2 commits February 24, 2026 18:54
…stBase.cs

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

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

@copilot Update the PR description with a readable summary

Copy link
Copy Markdown
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 reproduction scenario, root cause breakdown, and the fix applied to each file.

@AndriySvyryd AndriySvyryd enabled auto-merge (squash) February 25, 2026 16:48
@AndriySvyryd
Copy link
Copy Markdown
Member

@copilot
JsonUpdateJsonTypeSqlServerTest
-- Missing test overrides --

public override async Task Replace_json_reference_root_preserves_nested_owned_entities_in_memory(bool async)
{
await base.Replace_json_reference_root_preserves_nested_owned_entities_in_memory(async);

AssertSql();

}

…nested_owned_entities_in_memory

Co-authored-by: AndriySvyryd <6539701+AndriySvyryd@users.noreply.github.com>
auto-merge was automatically disabled February 26, 2026 01:45

Head branch was pushed to by a user without write access

Copy link
Copy Markdown
Contributor Author

Copilot AI commented Feb 26, 2026

@copilot
JsonUpdateJsonTypeSqlServerTest
-- Missing test overrides --
...

Added the missing test override in JsonUpdateJsonTypeSqlServerTest with AssertSql() in ace8853. Also added the override in JsonUpdateSqliteTest with proper SQL baselines (verified passing).

@AndriySvyryd
Copy link
Copy Markdown
Member

AndriySvyryd commented Feb 26, 2026

@copilot You need to actually specify the correct baseline for in the Replace_json_reference_root_preserves_nested_owned_entities_in_memory override assert:

  Error Message:
   Assert.Empty() Failure: Collection was not empty
Collection: ["@p0='{\"Id\":0,\"Name\":\"Modified\",\"Names\":[\""···, "SELECT TOP(2) [j].[Id], [j].[EntityBasicId], [j].["···]

…sted_owned_entities_in_memory in JsonUpdateJsonTypeSqlServerTest

Co-authored-by: AndriySvyryd <6539701+AndriySvyryd@users.noreply.github.com>
auto-merge was automatically disabled February 26, 2026 05:14

Head branch was pushed to by a user without write access

Copy link
Copy Markdown
Contributor Author

Copilot AI commented Feb 26, 2026

@copilot You need to actually specify the correct baseline for in the Replace_json_reference_root_preserves_nested_owned_entities_in_memory override assert:

  Error Message:

...

Fixed in 0232ce2 — added the correct SQL baseline with the expected UPDATE and SELECT statements for JsonUpdateJsonTypeSqlServerTest.Replace_json_reference_root_preserves_nested_owned_entities_in_memory.

@AndriySvyryd AndriySvyryd enabled auto-merge (squash) February 26, 2026 05:23
@AndriySvyryd AndriySvyryd merged commit 3eabbaf into main Feb 26, 2026
12 checks passed
@AndriySvyryd AndriySvyryd deleted the copilot/fix-in-memory-corruption branch February 26, 2026 06:17
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.

In-Memory Corruption of Nested Owned Entities After SaveChanges with Record 'with' Expression

4 participants