Efcore: Stack overflow when saving multiple modified entities with the same key

Created on 19 Oct 2020  路  5Comments  路  Source: dotnet/efcore

EF Core version: 5.0.0-rc.2.20475.6
Database provider: Microsoft.EntityFrameworkCore.Sqlite
Target framework: net5.0

Example.csproj

<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net5.0</TargetFramework>
    </PropertyGroup>

    <ItemGroup>
        <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.0-rc.2.20475.6" />
    </ItemGroup>
</Project>

Program.cs

public class BloggingContext : DbContext
{
      private DbConnection _connection;

      public DbSet<Blog> Blogs { get; set; }
      public DbSet<Post> Posts { get; set; }

      protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
      {
            optionsBuilder.UseSqlite(CreateDatabaseAndGetConnection());
      }

      private DbConnection CreateDatabaseAndGetConnection()
      {
            _connection = new SqliteConnection("Data Source=:memory:");
            _connection.Open();

            using (var context = new BloggingContext())
            {
                  context.GetService<IRelationalDatabaseCreator>().CreateTables();
            }

            return _connection;
      }

      public override void Dispose()
      {
            base.Dispose();
            _connection.Dispose();
      }
}

public class Blog
{
      public int BlogId { get; set; }
      public string Url { get; set; }
      public List<Post> Posts { get; set; }
}

public class Post
{
      public int PostId { get; set; }
      public string Title { get; set; }

      public int BlogId { get; set; }
}

class Program
{
      static void Main(string[] args)
      {
            using (var db = new BloggingContext())
            {
                  db.Database.Migrate();
            }

            using (var db = new BloggingContext())
            {
                  var blog = new Blog
                  {
                        BlogId = 1,
                        Url = "http://sample.com",
                        Posts = new List<Post>()
                        {
                              new Post()
                              {
                                    PostId = 1,
                                    Title = "title1"
                              },
                              new Post()
                              {
                                    PostId = 2,
                                    Title = "title2"
                              }
                        }
                  };

                  db.Blogs.Add(blog);
                  db.SaveChanges();
            }

            using (var db = new BloggingContext())
            {
                  var blog = db.Blogs.Include(x => x.Posts).First(x => x.BlogId == 1);

                  blog.Posts.Clear();

                  blog.Posts.AddRange(new List<Post>()
                  {
                        new Post()
                        {
                              PostId = 1,
                              Title = "title1"
                        },
                        new Post()
                        {
                              PostId = 2,
                              Title = "title2"
                        }
                  });

                  db.SaveChanges();
            }


            Console.WriteLine("Hello World!");
      }
}

dotnet build

C:\Example>dotnet build
Microsoft (R) Build Engine version 16.8.0-preview-20475-05+aed5e7ed0 for .NET
Copyright (C) Microsoft Corporation. All rights reserved.

  Determining projects to restore...
  All projects are up-to-date for restore.
  You are using a preview version of .NET. See: https://aka.ms/dotnet-core-preview
  Example -> C:\Example\bin\Debug\net5.0\Example.dll

Build succeeded.
    0 Warning(s)
    0 Error(s)

Time Elapsed 00:00:00.83

dotnet run


C:\Example>dotnet run
Stack overflow.

It seems that the AcceptChanges method caused the endless loop.

https://github.com/dotnet/efcore/blob/3199752b8785feec835e81455d12182306b1563f/src/EFCore/ChangeTracking/Internal/InternalEntityEntry.cs#L1293
https://github.com/dotnet/efcore/blob/3199752b8785feec835e81455d12182306b1563f/src/EFCore/ChangeTracking/Internal/InternalEntityEntry.cs#L1264

area-change-tracking area-save-changes customer-reported type-bug

All 5 comments

@maliming You're creating a new BloggingContext in OnConfiguring. Using this context in turn calls OnConfiguring, which then creates a new BloggingContext, and so on.

Note for triage: we detect and throw a better message when the _same context instance_ is used while it is being configured. In this case it is a different context instance each time. We may be able to detect and throw a better message in this case too.

hi @ajcvickers

I actually encountered another problem, I will try to reproduce it.

hi
The following code will cause an endless loop:

Output a lot of at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.InternalEntityEntry.AcceptChanges()

Although my code is unconventional, better error messages will help. : )

   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.InternalEntityEntry.AcceptChanges()
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.InternalEntityEntry.AcceptChanges()
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.AcceptAllChanges(System.Collections.Generic.IReadOnlyList`1<Microsoft.EntityFrameworkCore.Update.IUpdateEntry>)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChanges(Microsoft.EntityFrameworkCore.DbContext, Boolean)
   at Microsoft.EntityFrameworkCore.Storage.NonRetryingExecutionStrategy.Execute[[System.Boolean, System.Private.CoreLib, Version=5.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e],[System.Int32, System.Private.CoreLib, Version=5.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e]](Boolean, Syste
m.Func`3<Microsoft.EntityFrameworkCore.DbContext,Boolean,Int32>, System.Func`3<Microsoft.EntityFrameworkCore.DbContext,Boolean,Microsoft.EntityFrameworkCore.Storage.ExecutionResult`1<Int32>>)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChanges(Boolean)
   at Microsoft.EntityFrameworkCore.DbContext.SaveChanges(Boolean)
   at Microsoft.EntityFrameworkCore.DbContext.SaveChanges()
   at Example.Program.Main(System.String[])

public static class SqliteMemoryConnection
{
      public static readonly SqliteConnection Connection;

      static SqliteMemoryConnection()
      {
            Connection = new SqliteConnection("Data Source=:memory:");
            Connection.Open();
      }
}

public class BloggingContext : DbContext
{
      public static DbContextOptions Options;

      public BloggingContext(DbContextOptions options)
            :base(options)
      {

      }

      public BloggingContext()
      {

      }

      public DbSet<Blog> Blogs { get; set; }
      public DbSet<Post> Posts { get; set; }

      protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
      {
            optionsBuilder.UseSqlite(SqliteMemoryConnection.Connection);
      }
}

public class Blog
{
      public int BlogId { get; set; }
      public string Url { get; set; }
      public List<Post> Posts { get; set; }
}

public class Post
{
      public int PostId { get; set; }
      public string Title { get; set; }

      public int BlogId { get; set; }
}

class Program
{
      static void Main(string[] args)
      {
            using (var context = new BloggingContext(new DbContextOptionsBuilder().UseSqlite(SqliteMemoryConnection.Connection).Options))
            {
                  context.GetService<IRelationalDatabaseCreator>().CreateTables();
            }

            using (var db = new BloggingContext())
            {
                  var blog = new Blog
                  {
                        BlogId = 1,
                        Url = "http://sample.com",
                        Posts = new List<Post>()
                        {
                              new Post()
                              {
                                    PostId = 1,
                                    Title = "title1"
                              },
                              new Post()
                              {
                                    PostId = 2,
                                    Title = "title2"
                              }
                        }
                  };

                  db.Blogs.Add(blog);
                  db.SaveChanges();
            }

            using (var db = new BloggingContext())
            {
                  var blog = db.Blogs.Include(x => x.Posts).First(x => x.BlogId == 1);
                  var post1 = blog.Posts.First(x => x.PostId == 1);
                  var post2 = blog.Posts.First(x => x.PostId == 2);

                  blog.Posts.Clear();

                  blog.Posts.AddRange(new List<Post>()
                  {
                        new Post()
                        {
                              PostId = 1,
                              Title = "title1"
                        },
                        new Post()
                        {
                              PostId = 2,
                              Title = "title2"
                        }
                  });

                  db.Entry(post1).State = EntityState.Modified;
                  db.Entry(post2).State = EntityState.Modified;

                  db.SaveChanges();
            }

            Console.WriteLine("Hello World!");
      }
}

@maliming Thanks; I am able to reproduce this now.

Notes for team triage: The behavior is the same in 3.1; not a regression. What is happening is this:

  • Posts with IDs 1 and 2 are queried from the database and so are tracked as Unchanged
  • These two post instances are then removed from the blog.Posts navigation. They are still tracked.
  • Two _new instances_ also with IDs 1 and 2 are added to the blog.Posts navigation. (These are tracked as Modified, since they are configured with generated keys and have key values set. However, the behavior is the same when the keys are not generated and hence the new entities are in the Added state.) So the state manager has:
Blog {BlogId: 1} Unchanged
  BlogId: 1 PK
  Url: 'http://sample.com'
  Posts: [{PostId: 1}, {PostId: 2}]
Post (Shared) {PostId: 1} Modified
  PostId: 1 PK
  BlogId: 1 FK Modified Originally 0
  Title: 'title1' Modified
Post (Shared) {PostId: 1} Deleted
  PostId: 1 PK
  BlogId: 1 FK
  Title: 'title1'
Post (Shared) {PostId: 2} Modified
  PostId: 2 PK
  BlogId: 1 FK Modified Originally 0
  Title: 'title2' Modified
Post (Shared) {PostId: 2} Deleted
  PostId: 2 PK
  BlogId: 1 FK
  Title: 'title2'
  • At this point the state of the _original two entities_ is changed to Modified. This results in a state manager like this:
Post (Shared) {PostId: 1} Modified
  PostId: 1 PK
  BlogId: 1 FK Modified Originally 0
  Title: 'title1' Modified
Post (Shared) {PostId: 1} Modified
  PostId: 1 PK
  BlogId: 1 FK Modified
  Title: 'title1' Modified
Post (Shared) {PostId: 2} Modified
  PostId: 2 PK
  BlogId: 1 FK Modified Originally 0
  Title: 'title2' Modified
Post (Shared) {PostId: 2} Modified
  PostId: 2 PK
  BlogId: 1 FK Modified
  Title: 'title2' Modified
  • SaveChanges then does this:
info: 10/21/2020 16:38:28.237 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command) 
      Executed DbCommand (1ms) [Parameters=[@p2='1' (DbType = String), @p0='1' (DbType = String), @p1='title1' (Size = 6)], CommandType='Text', CommandTimeout='30']
      UPDATE "Posts" SET "BlogId" = @p0, "Title" = @p1
      WHERE "PostId" = @p2;
      SELECT changes();
info: 10/21/2020 16:38:28.237 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command) 
      Executed DbCommand (0ms) [Parameters=[@p2='1' (DbType = String), @p0='1' (DbType = String), @p1='title1' (Size = 6)], CommandType='Text', CommandTimeout='30']
      UPDATE "Posts" SET "BlogId" = @p0, "Title" = @p1
      WHERE "PostId" = @p2;
      SELECT changes();
info: 10/21/2020 16:38:28.237 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command) 
      Executed DbCommand (0ms) [Parameters=[@p2='2' (DbType = String), @p0='1' (DbType = String), @p1='title2' (Size = 6)], CommandType='Text', CommandTimeout='30']
      UPDATE "Posts" SET "BlogId" = @p0, "Title" = @p1
      WHERE "PostId" = @p2;
      SELECT changes();
info: 10/21/2020 16:38:28.237 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command) 
      Executed DbCommand (0ms) [Parameters=[@p2='2' (DbType = String), @p0='1' (DbType = String), @p1='title2' (Size = 6)], CommandType='Text', CommandTimeout='30']
      UPDATE "Posts" SET "BlogId" = @p0, "Title" = @p1
      WHERE "PostId" = @p2;
      SELECT changes();
  • So we generate two updates for each entity, which is wrong but doesn't fail. AcceptChanges then enters an infinite loop.

Note from triage: this should throw as soon as the Deleted instances are changed back to Modified.

Was this page helpful?
0 / 5 - 0 ratings