dotnet / EntityFramework.Docs

Documentation for Entity Framework Core and Entity Framework 6
https://docs.microsoft.com/ef/
Creative Commons Attribution 4.0 International
1.62k stars 1.96k forks source link

Document that the DbContext is not rolled back when SaveChanges fails #3243

Open jaliyaudagedara opened 3 years ago

jaliyaudagedara commented 3 years ago

EF Core version: 5.0.5 Database provider: Microsoft.EntityFrameworkCore.SqlServer Target framework: .NET 5 Operating system: Windows 10 IDE: Microsoft Visual Studio Enterprise 2019 Preview, Version 16.10.0 Preview 2.1

Consider the below database context.

public class MyDbContext : DbContext
{
    public DbSet<Category> Categories { get; set; }
    public DbSet<Product> Products { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder
            .UseSqlServer(@"Data Source=RAVANA-TPP15\MSSQLSERVER2019;Initial Catalog=EfCore5;" +
                "Integrated Security=True;Persist Security Info=False;Pooling=False;Encrypt=False;TrustServerCertificate=False")
            .EnableSensitiveDataLogging()
            .LogTo(Console.WriteLine, LogLevel.Information);
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Category>(builder =>
        {
            builder
                .HasMany(x => x.Products)
                .WithOne()
                .HasForeignKey(x => x.CategoryId);
        });
    }
}

public class Category
{
    public int Id { get; set; }
    public string Name { get; set; }
    public ICollection<Product> Products { get; set; }
}

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    public int CategoryId { get; set; }
}

Now I am trying this.

class Program
{
    static async Task Main(string[] args)
    {
        using var context = new MyDbContext();
        await context.Database.EnsureDeletedAsync();
        await context.Database.EnsureCreatedAsync();

        Category category = null;

        using IDbContextTransaction transaction = context.Database.BeginTransaction();
        try
        {
            category = new Category() { Name = "Some Category" };
            context.Categories.Add(category);
            await context.SaveChangesAsync();

            await transaction.CreateSavepointAsync("CategoryCreated");

            var product1 = new Product { Name = "Some Product 1", CategoryId = category.Id };
            context.Products.Add(product1);
            await context.SaveChangesAsync();

            await transaction.CreateSavepointAsync("Product1Created");

            // This will throw an error, FOREIGN KEY constraint error because it's invalid CategoryId
            var product2 = new Product { Name = "Some Product 2", CategoryId = 10 };
            context.Products.Add(product2);
            await context.SaveChangesAsync();

            await transaction.CommitAsync();

        }
        catch (Exception)
        {
            await transaction.RollbackToSavepointAsync("Product1Created");

            // Setting the correct CategoryId FK 
            var product2 = new Product { Name = "Some Product 2 with Correct Category Id", CategoryId = category.Id };
            context.Products.Add(product2);
            await context.SaveChangesAsync();

            await transaction.CommitAsync();
        }
    }
}

In the catch here, I am rolling back to the SavePoint Product1Created and restarting adding the product2 with Correct FK for CategoryId (Note: different product name as well).

And inside the catch, again this is getting thrown,

The INSERT statement conflicted with the FOREIGN KEY constraint "FK_Products_Categories_CategoryId". The conflict occurred in database "EfCore5", table "dbo.Categories", column 'Id'.

Full stacktrace,

warn: 9/05/2021 15:46:29.247 CoreEventId.SensitiveDataLoggingEnabledWarning[10400] (Microsoft.EntityFrameworkCore.Infrastructure)
      Sensitive data logging is enabled. Log entries and exception messages may include sensitive application data; this mode should only be enabled during development.
info: 9/05/2021 15:46:29.338 CoreEventId.ContextInitialized[10403] (Microsoft.EntityFrameworkCore.Infrastructure)
      Entity Framework Core 5.0.5 initialized 'MyDbContext' using provider 'Microsoft.EntityFrameworkCore.SqlServer' with options: SensitiveDataLoggingEnabled
info: 9/05/2021 15:46:29.623 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (18ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SELECT 1
info: 9/05/2021 15:46:29.672 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (2ms) [Parameters=[], CommandType='Text', CommandTimeout='60']
      IF SERVERPROPERTY('EngineEdition') <> 5
      BEGIN
          ALTER DATABASE [EfCore5] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
      END;
info: 9/05/2021 15:46:29.687 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (15ms) [Parameters=[], CommandType='Text', CommandTimeout='60']
      DROP DATABASE [EfCore5];
info: 9/05/2021 15:46:30.069 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (213ms) [Parameters=[], CommandType='Text', CommandTimeout='60']
      CREATE DATABASE [EfCore5];
info: 9/05/2021 15:46:30.117 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (44ms) [Parameters=[], CommandType='Text', CommandTimeout='60']
      IF SERVERPROPERTY('EngineEdition') <> 5
      BEGIN
          ALTER DATABASE [EfCore5] SET READ_COMMITTED_SNAPSHOT ON;
      END;
info: 9/05/2021 15:46:30.127 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SELECT 1
info: 9/05/2021 15:46:30.205 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (7ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE [Categories] (
          [Id] int NOT NULL IDENTITY,
          [Name] nvarchar(max) NULL,
          CONSTRAINT [PK_Categories] PRIMARY KEY ([Id])
      );
info: 9/05/2021 15:46:30.209 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE [Products] (
          [Id] int NOT NULL IDENTITY,
          [Name] nvarchar(max) NULL,
          [CategoryId] int NOT NULL,
          CONSTRAINT [PK_Products] PRIMARY KEY ([Id]),
          CONSTRAINT [FK_Products_Categories_CategoryId] FOREIGN KEY ([CategoryId]) REFERENCES [Categories] ([Id]) ON DELETE CASCADE
      );
info: 9/05/2021 15:46:30.215 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE INDEX [IX_Products_CategoryId] ON [Products] ([CategoryId]);
info: 9/05/2021 15:46:30.340 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (17ms) [Parameters=[@p0='Some Category' (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [Categories] ([Name])
      VALUES (@p0);
      SELECT [Id]
      FROM [Categories]
      WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();
info: 9/05/2021 15:46:30.389 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (2ms) [Parameters=[@p0='1', @p1='Some Product 1' (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [Products] ([CategoryId], [Name])
      VALUES (@p0, @p1);
      SELECT [Id]
      FROM [Products]
      WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();
fail: 9/05/2021 15:47:15.426 RelationalEventId.CommandError[20102] (Microsoft.EntityFrameworkCore.Database.Command)
      Failed executing DbCommand (91ms) [Parameters=[@p0='10', @p1='Some Product 2' (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [Products] ([CategoryId], [Name])
      VALUES (@p0, @p1);
      SELECT [Id]
      FROM [Products]
      WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();
fail: 9/05/2021 15:47:15.697 CoreEventId.SaveChangesFailed[10000] (Microsoft.EntityFrameworkCore.Update)
      An exception occurred in the database while saving changes for context type 'EfCore5.MyDbContext'.
      Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while updating the entries. See the inner exception for details.
       ---> Microsoft.Data.SqlClient.SqlException (0x80131904): The INSERT statement conflicted with the FOREIGN KEY constraint "FK_Products_Categories_CategoryId". The conflict occurred in database "EfCore5", table "dbo.Categories", column 'Id'.
      The statement has been terminated.
         at Microsoft.Data.SqlClient.SqlCommand.<>c.<ExecuteDbDataReaderAsync>b__169_0(Task`1 result)
         at System.Threading.Tasks.ContinuationResultTaskFromResultTask`2.InnerInvoke()
         at System.Threading.Tasks.Task.<>c.<.cctor>b__277_0(Object obj)
         at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state)
      --- End of stack trace from previous location ---
         at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state)
         at System.Threading.Tasks.Task.ExecuteWithThreadLocal(Task& currentTaskSlot, Thread threadPoolThread)
      --- End of stack trace from previous location ---
         at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
         at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
         at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
      ClientConnectionId:4f84cb19-7582-4a86-96b5-d83e4f82b020
      Error Number:547,State:0,Class:16
         --- End of inner exception stack trace ---
         at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
         at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
         at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
         at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
         at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(IList`1 entriesToSave, CancellationToken cancellationToken)
         at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(DbContext _, Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
         at Microsoft.EntityFrameworkCore.SqlServer.Storage.Internal.SqlServerExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken)
         at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
fail: 9/05/2021 15:47:34.861 RelationalEventId.CommandError[20102] (Microsoft.EntityFrameworkCore.Database.Command)
      Failed executing DbCommand (86ms) [Parameters=[@p0='10', @p1='Some Product 2' (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [Products] ([CategoryId], [Name])
      VALUES (@p0, @p1);
      SELECT [Id]
      FROM [Products]
      WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();
fail: 9/05/2021 15:47:35.095 CoreEventId.SaveChangesFailed[10000] (Microsoft.EntityFrameworkCore.Update)
      An exception occurred in the database while saving changes for context type 'EfCore5.MyDbContext'.
      Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while updating the entries. See the inner exception for details.
       ---> Microsoft.Data.SqlClient.SqlException (0x80131904): The INSERT statement conflicted with the FOREIGN KEY constraint "FK_Products_Categories_CategoryId". The conflict occurred in database "EfCore5", table "dbo.Categories", column 'Id'.
      The statement has been terminated.
         at Microsoft.Data.SqlClient.SqlCommand.<>c.<ExecuteDbDataReaderAsync>b__169_0(Task`1 result)
         at System.Threading.Tasks.ContinuationResultTaskFromResultTask`2.InnerInvoke()
         at System.Threading.Tasks.Task.<>c.<.cctor>b__277_0(Object obj)
         at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state)
      --- End of stack trace from previous location ---
         at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state)
         at System.Threading.Tasks.Task.ExecuteWithThreadLocal(Task& currentTaskSlot, Thread threadPoolThread)
      --- End of stack trace from previous location ---
         at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
         at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
         at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
      ClientConnectionId:4f84cb19-7582-4a86-96b5-d83e4f82b020
      Error Number:547,State:0,Class:16
         --- End of inner exception stack trace ---
         at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
         at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
         at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
         at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
         at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(IList`1 entriesToSave, CancellationToken cancellationToken)
         at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(DbContext _, Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
         at Microsoft.EntityFrameworkCore.SqlServer.Storage.Internal.SqlServerExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken)
         at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)

When I check the log, found an interesting thing. On the catch, when I am inserting the correct product2 it's still using the previous product2 values. (I changed the product2 name on the insert inside catch to a different one to make it clear) image

HSchwichtenberg commented 3 years ago

The previous/wrong product2 is still in the context because the DBTransaction will roll back the database, but not the DBContext. So you are trying to insert the wrong product again. By design, the cache of the context will not be changed when you roll back a transaction.

You forgot to remove the previous/wrong product2 from the context before saving again: // Remove the wrong product first context.Remove(context.Products.Local.FirstOrDefault(x=>x.Name=="Some Product 2"));

In your case you could also just correct the CategoryId: // correct the Category on the existing object context.Products.Local.FirstOrDefault(x => x.Name == "Some Product 2").CategoryId = category.Id;

Either keep a reference to the previous/wrong product2 or look for it in the local cache.

Corrected Code:

...
catch (Exception)
 {
   await transaction.RollbackToSavepointAsync("Product1Created");

    // correct the CategoryId on the existing object
    context.Products.Local.FirstOrDefault(x => x.Name == "Some Product 2").CategoryId = category.Id;

    // OR: Remove the wrong product first
    //context.Remove(context.Products.Local.FirstOrDefault(x=>x.Name=="Some Product 2"));

     // Setting the correct CategoryId FK 
    var product2 = new Product { Name = "Some Product 2 with Correct Category Id", CategoryId = category.Id };
    context.Products.Add(product2);
    await context.SaveChangesAsync();

    await transaction.CommitAsync();
 }

Holger Schwichtenberg www.EFCore.net

jaliyaudagedara commented 3 years ago

Thanks @HSchwichtenberg!

I see, that makes sense now. I was under the impression when we do the Rollback to a given SavePoint, the context also getting rollbacked.

Guess, this is what confused me (https://docs.microsoft.com/en-us/ef/core/saving/transactions#savepoints). image

Maybe we might need to update the documentation?

Thanks again for the nice explanation.