Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use savepoints to roll back to before SaveChanges for user-managed transactions #20176

Closed
Lobosque opened this issue Mar 4, 2020 · 12 comments · Fixed by #21103
Closed

Use savepoints to roll back to before SaveChanges for user-managed transactions #20176

Lobosque opened this issue Mar 4, 2020 · 12 comments · Fixed by #21103
Assignees
Labels
area-save-changes closed-fixed The issue has been fixed and is/will be included in the release indicated by the issue milestone. customer-reported type-enhancement
Milestone

Comments

@Lobosque
Copy link

Lobosque commented Mar 4, 2020

In the following code, trying to call CreateMovementWithTransactionAsync is not working as expected when trying to get the DatabaseValue after a failed SaveChanges attempt.
The steps that lead to the problem are described in the comments of the code that follows.

Inside a transaction, we read two rows from the sabe table. the Entity FinancialAccount has a [ConcurrencyCheck] property.

We do some manipulation to force [ConcurrencyCheck] to throw an exception (all explained in the comments).

Since SaveChanges threw an exception, we expect the DatabaseValue to be equal OriginalValue, and not CurrentValue.

Important: If the transaction is removed, everything works as expected.

Steps to reproduce

using Microsoft.EntityFrameworkCore;
using System.Data;
using System.Threading.Tasks;
using Acme.Domain.Business;

namespace Acme.Services
{
    public class CreateMovementService : ICreateMovementService
    {
        private readonly DataContext _dataContext;

        public CreateMovementService(
            DataContext dataContext
        )
        {
            _dataContext = dataContext;
        }

        public async Task CreateMovementWithTransactionAsync()
        {
            using var transaction = _dataContext.Database.BeginTransaction(IsolationLevel.ReadUncommitted);
            //DB Value for sourceAccount is 1
            FinancialAccount sourceAccount = await _dataContext.FinancialAccount.FindAsync(1);
            //DB Value for targetAccount is also 1
            FinancialAccount targetAccount = await _dataContext.FinancialAccount.FindAsync(2);
            await CreateMovementAsync(sourceAccount, targetAccount, 1.0M);
            await transaction.CommitAsync();
        }

        public async Task CreateMovementAsync(FinancialAccount sourceAccount, FinancialAccount targetAccount, decimal amount)
        {
                try
                {
                    //CurrentBalance property has a [ConcurrencyCheck]
                    sourceAccount.CurrentBalance -= amount;
                    //With debugger paused at line 38, I access the Database and change sourceAccount value to 5
                    targetAccount.CurrentBalance += amount;
                    //As expected, trying to save will raise a DbUpdateConcurrencyException
                    await _dataContext.SaveChangesAsync();
                } catch(DbUpdateConcurrencyException)
                {
                    //Here I deal with the concurrency problem, but for explaining the bug I'm just getting values
                    var sourceEntry = _dataContext.Entry(sourceAccount);
                    var targetEntry = _dataContext.Entry(targetAccount);
                    //OK: expected to be 1
                    var originalSource = sourceEntry.OriginalValues.GetValue<decimal>("CurrentBalance");
                    //OK: expected to be 0
                    var currentSource = sourceEntry.CurrentValues.GetValue<decimal>("CurrentBalance");
                    //OK: expected to be 5
                    var databaseSource = sourceEntry.GetDatabaseValues().GetValue<decimal>("CurrentBalance");
                    //OK: expected to be 1
                    var originalTarget = targetEntry.OriginalValues.GetValue<decimal>("CurrentBalance");
                    //OK: expected to be 2
                    var currentTarget = targetEntry.CurrentValues.GetValue<decimal>("CurrentBalance");
                    //NOT OK: expected to be 1 and is actually 2 (CurrentValue)
                    var databaseTarget = targetEntry.GetDatabaseValues().GetValue<decimal>("CurrentBalance");
                }
        }
    }
}

Further technical details

EF Core version: 3.1.2
Database provider: Npgsql
Target framework: 3.1.2
Operating system: Windows
IDE: VSCode

@Lobosque
Copy link
Author

Lobosque commented Mar 6, 2020

I was able to reproduce this and another issue in a PoC.
Both scenarios work as expected using Mysql provider but both fail using Postgres Provider.
@roji should I add my findings to this issue or create another one elsewhere?

@Lobosque
Copy link
Author

Lobosque commented Mar 6, 2020

@roji
Copy link
Member

roji commented Mar 6, 2020

@Lobosque thanks for digging into this and submitting the repro. After some investigation the root cause seems to be an EF Core issue rather than anything Npgsql-related.

Our update pipeline does batching, so multiple updates get sent in a single DbCommand - e.g. an INSERT and an UPDATE. If the UPDATE triggers a failure (optimistic concurrency in this case), that failure doesn't prevent other batched statements (e.g. the INSERT) from getting executed. Since the update pipeline doesn't roll back the transaction in case of an exception, any statement that happened to get batched with the problematic statement will get committed.

The only reason this is visible on Npgsql and not on Pomelo MySQL (or SQL Server) is that EF Core's default batching size is 4, and since there are only two statements here they do not get batched (so the UPDATE gets sent alone, and the optimistic concurrency exception prevents the INSERT from ever getting sent). Npgsql overrides this and sets the batching size to 2; if you set the minimum batching size to 2 (see sample below), MySQL and SQL Server behave in the same way.

@AndriySvyryd @ajcvickers shouldn't we be rolling back the transaction on any exception during update?

Simplified repro:

class Program
{
    static void Main()
    {
        using (var ctx1 = new BlogContext())
        {
            ctx1.Database.EnsureDeleted();
            ctx1.Database.EnsureCreated();
            ctx1.Blogs.Add(new Blog { Name = "InitialName" });
            ctx1.SaveChanges();
        }

        using var ctx = new BlogContext();
        using var transaction = ctx.Database.BeginTransaction();

        try
        {
            ctx.Blogs.Add(new Blog { Name = "SomeUnrelatedBlog" });

            var blog = ctx.Blogs.Find(1);
            blog.Name = "AlteredName";

            // Modify value in another connection, to trigger optimistic concurrency exception below
            using (var ctx2 = new BlogContext())
            {
                var blog2 = ctx2.Blogs.Find(1);
                blog2.Name = "PreemptedName";
                ctx2.SaveChanges();
            }

            ctx.SaveChanges();
            throw new Exception("Should not be here");
        }
        catch (DbUpdateConcurrencyException)
        {
            transaction.Commit();
            if (ctx.Blogs.Count() != 1)
                throw new Exception("Did not roll back");
        }
    }
}

public class BlogContext : DbContext
{
    public DbSet<Blog> Blogs { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder.UseSqlServer(..., o => o.MinBatchSize(2))  // SQL Server defaults to min batch size of 4, which hides this
}

public class Blog
{
    public int Id { get; set; }
    [ConcurrencyCheck]
    public string Name { get; set; }
}

@AndriySvyryd
Copy link
Member

I agree that rolling it back would be the most pragmatic solution even though it might be a small breaking change.

@Lobosque
Copy link
Author

Lobosque commented Mar 7, 2020

@AndriySvyryd that makes sense but for my specific case would also need to be able to deal with savepoints to handle it well.
I opened a new feature request: #20211

@roji
Copy link
Member

roji commented Mar 7, 2020

@Lobosque you should not require savepoints to deal with this. Assuming we implement the transaction rollback being proposed here, the pattern would be to catch the exception and restart the entire transaction.

One note: this issue isn't really specific to batching. Since an optimistic concurrency exception stops the update process, all updates that have been previously updated (not just in the current batch) currently also remain committed; and since updates are reordered by EF Core, it's very hard to tell what got committed and what didn't. I wonder what EF6 did here.

@roji
Copy link
Member

roji commented Mar 9, 2020

Note that it's possible to manually use savepoints today to achieve what you want: you can call GetDbTransaction to get the DbTransaction, cast it down to NpgsqlTransaction and use the savepoint API. This allows you to create a savepoint before calling SaveChanges, and roll it back if SaveChanges throws.

Note that I've confirmed with @ajcvickers, and the change tracker state is only modified if SaveChanges completes successfully; so if an exception is thrown you can safely retry to call SaveChanges without worrying about the change tracker state.

@roji
Copy link
Member

roji commented Mar 9, 2020

Discussion from triage:

  • There seem to be agreement that if EF creates the transaction for SaveChanges, that transaction should be rolled back.
  • There's also some agreement (non-final) that even a user-created transaction should be rolled back in case of exception, by default. This is because in the general case, it is difficult to tell exactly what updates went through before the exception occurred and which did not, and so the transaction state is to a large extent unknown. We could expose the information on what went through on the exception, and the user could reconstruct the state from that, but that doesn't seem like what we want for the default, basic experience. However, it was also said that an opt-out from auto-rollback should be provided for user-managed transactions.
  • Finally, if savepoints are introduced in ADO.NET (Expose transaction savepoints in ADO.NET runtime#33397), we could automatically generate a savepoint before SaveChanges, and roll back to that if an error occurred. This would add a network roundtrip (although see Savepoint creation should prepend rather than doing a network roundtrip npgsql/npgsql#2892), so we should do this only for user-generated transactions (EF-generated ones can just be rolled back).

@ajcvickers
Copy link
Member

Note for team. I've been playing with this on SQL Server. First, when EF controls the transaction, then the normal optimistic concurrency pattern works fine. Specifically, the first SaveChanges can fail, this can be fixed, and the whole of SaveChanges can be run again without explicitly doing anything. In other words, at least on SQL Server in this case, the transaction is effectively rolled back anyway.

public static class ThreeOneApp
{
    public static void Main()
    {
        using (var context = new SomeDbContext())
        {
            context.Database.EnsureDeleted();
            context.Database.EnsureCreated();

            context.AddRange(
                new Customer {Name = "Olive"},
                new Customer {Name = "Smokey"},
                new Customer {Name = "Baxter"},
                new Customer {Name = "Alice"},
                new Customer {Name = "Toast"},
                new Customer {Name = "Mac"}); 

            context.SaveChanges();
        }

        using (var context = new SomeDbContext())
        {
            //context.Database.BeginTransaction();
            
            var all = context.Set<Customer>().ToList();
            foreach (var customer in all)
            {
                customer.Name += "+";
            }

            context.AddRange(
                new Bug {Id = 1, Name = "Retrovirus"},
                new Bug {Id = 2, Name = "Rotavirus"}); 

            context.Database.ExecuteSqlRaw(@"UPDATE [Customer] SET [Name] = '-------' WHERE [Id] = 3");

            try
            {
                context.SaveChanges();
                throw new Exception("Bang!");
            }
            catch (DbUpdateConcurrencyException e)
            {
                var entry = context.Entry(context.Find<Customer>(3));
                entry.OriginalValues.SetValues(entry.GetDatabaseValues());
            }
            
            context.SaveChanges();
            
            //context.Database.CurrentTransaction.Commit();
        }
    }
}

public class SomeDbContext : DbContext
{
    private static readonly ILoggerFactory
        Logger = LoggerFactory.Create(x => x.AddConsole()); //.SetMinimumLevel(LogLevel.Debug));

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Customer>();
        modelBuilder.Entity<Bug>();
    }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder
            .UseLoggerFactory(Logger)
            .EnableSensitiveDataLogging()
            .UseSqlServer(Your.SqlServerConnectionString);
}

public class Bug
{
    [DatabaseGenerated(DatabaseGeneratedOption.None)]
    public int Id { get; set; }
    public string Name { get; set; }
}

public class Customer
{
    public int Id { get; set; }
    public string Name { get; set; }
    
    [Timestamp]
    public byte[] RowVersion { get; set; }
}

The log output is:

ome/ajcvickers/AllTogetherNow/ThreeOne/bin/Debug/netcoreapp3.1/ThreeOne.dll
warn: Microsoft.EntityFrameworkCore.Model.Validation[10400]
      Sensitive data logging is enabled. Log entries and exception messages may include sensitive application data, this mode should only be enabled during development.
info: Microsoft.EntityFrameworkCore.Infrastructure[10403]
      Entity Framework Core 3.1.3 initialized 'SomeDbContext' using provider 'Microsoft.EntityFrameworkCore.SqlServer' with options: SensitiveDataLoggingEnabled 
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (9ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SELECT 1
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (20ms) [Parameters=[], CommandType='Text', CommandTimeout='60']
      IF SERVERPROPERTY('EngineEdition') <> 5
      BEGIN
          ALTER DATABASE [Test] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
      END;
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (53ms) [Parameters=[], CommandType='Text', CommandTimeout='60']
      DROP DATABASE [Test];
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (359ms) [Parameters=[], CommandType='Text', CommandTimeout='60']
      CREATE DATABASE [Test];
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (148ms) [Parameters=[], CommandType='Text', CommandTimeout='60']
      IF SERVERPROPERTY('EngineEdition') <> 5
      BEGIN
          ALTER DATABASE [Test] SET READ_COMMITTED_SNAPSHOT ON;
      END;
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SELECT 1
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (4ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE [Bug] (
          [Id] int NOT NULL,
          [Name] nvarchar(max) NULL,
          CONSTRAINT [PK_Bug] PRIMARY KEY ([Id])
      );
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE [Customer] (
          [Id] int NOT NULL IDENTITY,
          [Name] nvarchar(max) NULL,
          [RowVersion] rowversion NULL,
          CONSTRAINT [PK_Customer] PRIMARY KEY ([Id])
      );
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (28ms) [Parameters=[@p0='Olive' (Size = 4000), @p1='Smokey' (Size = 4000), @p2='Baxter' (Size = 4000), @p3='Alice' (Size = 4000), @p4='Toast' (Size = 4000), @p5='Mac' (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      DECLARE @inserted0 TABLE ([Id] int, [_Position] [int]);
      MERGE [Customer] USING (
      VALUES (@p0, 0),
      (@p1, 1),
      (@p2, 2),
      (@p3, 3),
      (@p4, 4),
      (@p5, 5)) AS i ([Name], _Position) ON 1=0
      WHEN NOT MATCHED THEN
      INSERT ([Name])
      VALUES (i.[Name])
      OUTPUT INSERTED.[Id], i._Position
      INTO @inserted0;
      
      SELECT [t].[Id], [t].[RowVersion] FROM [Customer] t
      INNER JOIN @inserted0 i ON ([t].[Id] = [i].[Id])
      ORDER BY [i].[_Position];
info: Microsoft.EntityFrameworkCore.Infrastructure[10403]
      Entity Framework Core 3.1.3 initialized 'SomeDbContext' using provider 'Microsoft.EntityFrameworkCore.SqlServer' with options: SensitiveDataLoggingEnabled 
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SELECT [c].[Id], [c].[Name], [c].[RowVersion]
      FROM [Customer] AS [c]
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (2ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      UPDATE [Customer] SET [Name] = '-------' WHERE [Id] = 3
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (6ms) [Parameters=[@p0='1', @p1='Retrovirus' (Size = 4000), @p2='2', @p3='Rotavirus' (Size = 4000), @p5='1', @p4='Olive+' (Size = 4000), @p6='0x00000000000007D1' (Size = 8), @p8='2', @p7='Smokey+' (Size = 4000), @p9='0x00000000000007D2' (Size = 8), @p11='3', @p10='Baxter+' (Size = 4000), @p12='0x00000000000007D3' (Size = 8), @p14='4', @p13='Alice+' (Size = 4000), @p15='0x00000000000007D4' (Size = 8), @p17='5', @p16='Toast+' (Size = 4000), @p18='0x00000000000007D5' (Size = 8), @p20='6', @p19='Mac+' (Size = 4000), @p21='0x00000000000007D6' (Size = 8)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [Bug] ([Id], [Name])
      VALUES (@p0, @p1),
      (@p2, @p3);
      UPDATE [Customer] SET [Name] = @p4
      WHERE [Id] = @p5 AND [RowVersion] = @p6;
      SELECT [RowVersion]
      FROM [Customer]
      WHERE @@ROWCOUNT = 1 AND [Id] = @p5;
      
      UPDATE [Customer] SET [Name] = @p7
      WHERE [Id] = @p8 AND [RowVersion] = @p9;
      SELECT [RowVersion]
      FROM [Customer]
      WHERE @@ROWCOUNT = 1 AND [Id] = @p8;
      
      UPDATE [Customer] SET [Name] = @p10
      WHERE [Id] = @p11 AND [RowVersion] = @p12;
      SELECT [RowVersion]
      FROM [Customer]
      WHERE @@ROWCOUNT = 1 AND [Id] = @p11;
      
      UPDATE [Customer] SET [Name] = @p13
      WHERE [Id] = @p14 AND [RowVersion] = @p15;
      SELECT [RowVersion]
      FROM [Customer]
      WHERE @@ROWCOUNT = 1 AND [Id] = @p14;
      
      UPDATE [Customer] SET [Name] = @p16
      WHERE [Id] = @p17 AND [RowVersion] = @p18;
      SELECT [RowVersion]
      FROM [Customer]
      WHERE @@ROWCOUNT = 1 AND [Id] = @p17;
      
      UPDATE [Customer] SET [Name] = @p19
      WHERE [Id] = @p20 AND [RowVersion] = @p21;
      SELECT [RowVersion]
      FROM [Customer]
      WHERE @@ROWCOUNT = 1 AND [Id] = @p20;
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (1ms) [Parameters=[@__p_0='3'], CommandType='Text', CommandTimeout='30']
      SELECT TOP(1) [c].[Id], [c].[Name], [c].[RowVersion]
      FROM [Customer] AS [c]
      WHERE [c].[Id] = @__p_0
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[@p0='1', @p1='Retrovirus' (Size = 4000), @p2='2', @p3='Rotavirus' (Size = 4000), @p5='1', @p4='Olive+' (Size = 4000), @p6='0x00000000000007D1' (Size = 8), @p8='2', @p7='Smokey+' (Size = 4000), @p9='0x00000000000007D2' (Size = 8), @p11='3', @p10='Baxter+' (Size = 4000), @p12='0x00000000000007D7' (Size = 8), @p14='4', @p13='Alice+' (Size = 4000), @p15='0x00000000000007D4' (Size = 8), @p17='5', @p16='Toast+' (Size = 4000), @p18='0x00000000000007D5' (Size = 8), @p20='6', @p19='Mac+' (Size = 4000), @p21='0x00000000000007D6' (Size = 8)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [Bug] ([Id], [Name])
      VALUES (@p0, @p1),
      (@p2, @p3);
      UPDATE [Customer] SET [Name] = @p4
      WHERE [Id] = @p5 AND [RowVersion] = @p6;
      SELECT [RowVersion]
      FROM [Customer]
      WHERE @@ROWCOUNT = 1 AND [Id] = @p5;
      
      UPDATE [Customer] SET [Name] = @p7
      WHERE [Id] = @p8 AND [RowVersion] = @p9;
      SELECT [RowVersion]
      FROM [Customer]
      WHERE @@ROWCOUNT = 1 AND [Id] = @p8;
      
      UPDATE [Customer] SET [Name] = @p10
      WHERE [Id] = @p11 AND [RowVersion] = @p12;
      SELECT [RowVersion]
      FROM [Customer]
      WHERE @@ROWCOUNT = 1 AND [Id] = @p11;
      
      UPDATE [Customer] SET [Name] = @p13
      WHERE [Id] = @p14 AND [RowVersion] = @p15;
      SELECT [RowVersion]
      FROM [Customer]
      WHERE @@ROWCOUNT = 1 AND [Id] = @p14;
      
      UPDATE [Customer] SET [Name] = @p16
      WHERE [Id] = @p17 AND [RowVersion] = @p18;
      SELECT [RowVersion]
      FROM [Customer]
      WHERE @@ROWCOUNT = 1 AND [Id] = @p17;
      
      UPDATE [Customer] SET [Name] = @p19
      WHERE [Id] = @p20 AND [RowVersion] = @p21;
      SELECT [RowVersion]
      FROM [Customer]
      WHERE @@ROWCOUNT = 1 AND [Id] = @p20;

Notice that the explicit key values for the insert are the same each time, but that SQL Server doesn't throw.

If I instead comment out the lines to create an explicit transaction, then the retry fails:

/share/dotnet/dotnet /home/ajcvickers/AllTogetherNow/ThreeOne/bin/Debug/netcoreapp3.1/ThreeOne.dll
warn: Microsoft.EntityFrameworkCore.Model.Validation[10400]
      Sensitive data logging is enabled. Log entries and exception messages may include sensitive application data, this mode should only be enabled during development.
info: Microsoft.EntityFrameworkCore.Infrastructure[10403]
      Entity Framework Core 3.1.3 initialized 'SomeDbContext' using provider 'Microsoft.EntityFrameworkCore.SqlServer' with options: SensitiveDataLoggingEnabled 
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (9ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SELECT 1
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (13ms) [Parameters=[], CommandType='Text', CommandTimeout='60']
      IF SERVERPROPERTY('EngineEdition') <> 5
      BEGIN
          ALTER DATABASE [Test] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
      END;
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (56ms) [Parameters=[], CommandType='Text', CommandTimeout='60']
      DROP DATABASE [Test];
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (339ms) [Parameters=[], CommandType='Text', CommandTimeout='60']
      CREATE DATABASE [Test];
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (141ms) [Parameters=[], CommandType='Text', CommandTimeout='60']
      IF SERVERPROPERTY('EngineEdition') <> 5
      BEGIN
          ALTER DATABASE [Test] SET READ_COMMITTED_SNAPSHOT ON;
      END;
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SELECT 1
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (5ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE [Bug] (
          [Id] int NOT NULL,
          [Name] nvarchar(max) NULL,
          CONSTRAINT [PK_Bug] PRIMARY KEY ([Id])
      );
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (2ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE [Customer] (
          [Id] int NOT NULL IDENTITY,
          [Name] nvarchar(max) NULL,
          [RowVersion] rowversion NULL,
          CONSTRAINT [PK_Customer] PRIMARY KEY ([Id])
      );
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (33ms) [Parameters=[@p0='Olive' (Size = 4000), @p1='Smokey' (Size = 4000), @p2='Baxter' (Size = 4000), @p3='Alice' (Size = 4000), @p4='Toast' (Size = 4000), @p5='Mac' (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      DECLARE @inserted0 TABLE ([Id] int, [_Position] [int]);
      MERGE [Customer] USING (
      VALUES (@p0, 0),
      (@p1, 1),
      (@p2, 2),
      (@p3, 3),
      (@p4, 4),
      (@p5, 5)) AS i ([Name], _Position) ON 1=0
      WHEN NOT MATCHED THEN
      INSERT ([Name])
      VALUES (i.[Name])
      OUTPUT INSERTED.[Id], i._Position
      INTO @inserted0;
      
      SELECT [t].[Id], [t].[RowVersion] FROM [Customer] t
      INNER JOIN @inserted0 i ON ([t].[Id] = [i].[Id])
      ORDER BY [i].[_Position];
info: Microsoft.EntityFrameworkCore.Infrastructure[10403]
      Entity Framework Core 3.1.3 initialized 'SomeDbContext' using provider 'Microsoft.EntityFrameworkCore.SqlServer' with options: SensitiveDataLoggingEnabled 
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SELECT [c].[Id], [c].[Name], [c].[RowVersion]
      FROM [Customer] AS [c]
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (2ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      UPDATE [Customer] SET [Name] = '-------' WHERE [Id] = 3
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (10ms) [Parameters=[@p0='1', @p1='Retrovirus' (Size = 4000), @p2='2', @p3='Rotavirus' (Size = 4000), @p5='1', @p4='Olive+' (Size = 4000), @p6='0x00000000000007D1' (Size = 8), @p8='2', @p7='Smokey+' (Size = 4000), @p9='0x00000000000007D2' (Size = 8), @p11='3', @p10='Baxter+' (Size = 4000), @p12='0x00000000000007D3' (Size = 8), @p14='4', @p13='Alice+' (Size = 4000), @p15='0x00000000000007D4' (Size = 8), @p17='5', @p16='Toast+' (Size = 4000), @p18='0x00000000000007D5' (Size = 8), @p20='6', @p19='Mac+' (Size = 4000), @p21='0x00000000000007D6' (Size = 8)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [Bug] ([Id], [Name])
      VALUES (@p0, @p1),
      (@p2, @p3);
      UPDATE [Customer] SET [Name] = @p4
      WHERE [Id] = @p5 AND [RowVersion] = @p6;
      SELECT [RowVersion]
      FROM [Customer]
      WHERE @@ROWCOUNT = 1 AND [Id] = @p5;
      
      UPDATE [Customer] SET [Name] = @p7
      WHERE [Id] = @p8 AND [RowVersion] = @p9;
      SELECT [RowVersion]
      FROM [Customer]
      WHERE @@ROWCOUNT = 1 AND [Id] = @p8;
      
      UPDATE [Customer] SET [Name] = @p10
      WHERE [Id] = @p11 AND [RowVersion] = @p12;
      SELECT [RowVersion]
      FROM [Customer]
      WHERE @@ROWCOUNT = 1 AND [Id] = @p11;
      
      UPDATE [Customer] SET [Name] = @p13
      WHERE [Id] = @p14 AND [RowVersion] = @p15;
      SELECT [RowVersion]
      FROM [Customer]
      WHERE @@ROWCOUNT = 1 AND [Id] = @p14;
      
      UPDATE [Customer] SET [Name] = @p16
      WHERE [Id] = @p17 AND [RowVersion] = @p18;
      SELECT [RowVersion]
      FROM [Customer]
      WHERE @@ROWCOUNT = 1 AND [Id] = @p17;
      
      UPDATE [Customer] SET [Name] = @p19
      WHERE [Id] = @p20 AND [RowVersion] = @p21;
      SELECT [RowVersion]
      FROM [Customer]
      WHERE @@ROWCOUNT = 1 AND [Id] = @p20;
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (1ms) [Parameters=[@__p_0='3'], CommandType='Text', CommandTimeout='30']
      SELECT TOP(1) [c].[Id], [c].[Name], [c].[RowVersion]
      FROM [Customer] AS [c]
      WHERE [c].[Id] = @__p_0
fail: Microsoft.EntityFrameworkCore.Database.Command[20102]
      Failed executing DbCommand (1ms) [Parameters=[@p0='1', @p1='Retrovirus' (Size = 4000), @p2='2', @p3='Rotavirus' (Size = 4000), @p5='1', @p4='Olive+' (Size = 4000), @p6='0x00000000000007D1' (Size = 8), @p8='2', @p7='Smokey+' (Size = 4000), @p9='0x00000000000007D2' (Size = 8), @p11='3', @p10='Baxter+' (Size = 4000), @p12='0x00000000000007D7' (Size = 8), @p14='4', @p13='Alice+' (Size = 4000), @p15='0x00000000000007D4' (Size = 8), @p17='5', @p16='Toast+' (Size = 4000), @p18='0x00000000000007D5' (Size = 8), @p20='6', @p19='Mac+' (Size = 4000), @p21='0x00000000000007D6' (Size = 8)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [Bug] ([Id], [Name])
      VALUES (@p0, @p1),
      (@p2, @p3);
      UPDATE [Customer] SET [Name] = @p4
      WHERE [Id] = @p5 AND [RowVersion] = @p6;
      SELECT [RowVersion]
      FROM [Customer]
      WHERE @@ROWCOUNT = 1 AND [Id] = @p5;
      
      UPDATE [Customer] SET [Name] = @p7
      WHERE [Id] = @p8 AND [RowVersion] = @p9;
      SELECT [RowVersion]
      FROM [Customer]
      WHERE @@ROWCOUNT = 1 AND [Id] = @p8;
      
      UPDATE [Customer] SET [Name] = @p10
      WHERE [Id] = @p11 AND [RowVersion] = @p12;
      SELECT [RowVersion]
      FROM [Customer]
      WHERE @@ROWCOUNT = 1 AND [Id] = @p11;
      
      UPDATE [Customer] SET [Name] = @p13
      WHERE [Id] = @p14 AND [RowVersion] = @p15;
      SELECT [RowVersion]
      FROM [Customer]
      WHERE @@ROWCOUNT = 1 AND [Id] = @p14;
      
      UPDATE [Customer] SET [Name] = @p16
      WHERE [Id] = @p17 AND [RowVersion] = @p18;
      SELECT [RowVersion]
      FROM [Customer]
      WHERE @@ROWCOUNT = 1 AND [Id] = @p17;
      
      UPDATE [Customer] SET [Name] = @p19
      WHERE [Id] = @p20 AND [RowVersion] = @p21;
      SELECT [RowVersion]
      FROM [Customer]
      WHERE @@ROWCOUNT = 1 AND [Id] = @p20;
fail: Microsoft.EntityFrameworkCore.Update[10000]
      An exception occurred in the database while saving changes for context type 'SomeDbContext'.
      Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while updating the entries. See the inner exception for details.
       ---> Microsoft.Data.SqlClient.SqlException (0x80131904): Violation of PRIMARY KEY constraint 'PK_Bug'. Cannot insert duplicate key in object 'dbo.Bug'. The duplicate key value is (1).
      The statement has been terminated.
         at Microsoft.Data.SqlClient.SqlConnection.OnError(SqlException exception, Boolean breakConnection, Action`1 wrapCloseInAction)
         at Microsoft.Data.SqlClient.SqlInternalConnection.OnError(SqlException exception, Boolean breakConnection, Action`1 wrapCloseInAction)
         at Microsoft.Data.SqlClient.TdsParser.ThrowExceptionAndWarning(TdsParserStateObject stateObj, Boolean callerHasConnectionLock, Boolean asyncClose)
         at Microsoft.Data.SqlClient.TdsParser.TryRun(RunBehavior runBehavior, SqlCommand cmdHandler, SqlDataReader dataStream, BulkCopySimpleResultSet bulkCopyHandler, TdsParserStateObject stateObj, Boolean& dataReady)
         at Microsoft.Data.SqlClient.SqlDataReader.TryConsumeMetaData()
         at Microsoft.Data.SqlClient.SqlDataReader.get_MetaData()
         at Microsoft.Data.SqlClient.SqlCommand.FinishExecuteReader(SqlDataReader ds, RunBehavior runBehavior, String resetOptionsString, Boolean isInternal, Boolean forDescribeParameterEncryption, Boolean shouldCacheForAlwaysEncrypted)
         at Microsoft.Data.SqlClient.SqlCommand.RunExecuteReaderTds(CommandBehavior cmdBehavior, RunBehavior runBehavior, Boolean returnStream, Boolean isAsync, Int32 timeout, Task& task, Boolean asyncWrite, Boolean inRetry, SqlDataReader ds, Boolean describeParameterEncryptionRequest)
         at Microsoft.Data.SqlClient.SqlCommand.RunExecuteReader(CommandBehavior cmdBehavior, RunBehavior runBehavior, Boolean returnStream, TaskCompletionSource`1 completion, Int32 timeout, Task& task, Boolean& usedCache, Boolean asyncWrite, Boolean inRetry, String method)
         at Microsoft.Data.SqlClient.SqlCommand.RunExecuteReader(CommandBehavior cmdBehavior, RunBehavior runBehavior, Boolean returnStream, String method)
         at Microsoft.Data.SqlClient.SqlCommand.ExecuteReader(CommandBehavior behavior)
         at Microsoft.Data.SqlClient.SqlCommand.ExecuteDbDataReader(CommandBehavior behavior)
         at System.Data.Common.DbCommand.ExecuteReader()
         at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReader(RelationalCommandParameterObject parameterObject)
         at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.Execute(IRelationalConnection connection)
      ClientConnectionId:3f52c679-74ce-4b30-89a7-6cad002ca7ef
      Error Number:2627,State:1,Class:14
         --- End of inner exception stack trace ---
         at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.Execute(IRelationalConnection connection)
         at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.Execute(IEnumerable`1 commandBatches, IRelationalConnection connection)
         at Microsoft.EntityFrameworkCore.Storage.RelationalDatabase.SaveChanges(IList`1 entries)
         at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChanges(IList`1 entriesToSave)
         at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChanges(DbContext _, Boolean acceptAllChangesOnSuccess)
         at Microsoft.EntiUnhandled exception.tyFrameworkCore.SqlServer.Storage.Internal.SqlServerExecutionStrategy.Execute[TState,TResult](TState state, Func`3 operation, Func`3 verifySucceeded)
         at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChanges(Boolean acceptA llChangesOnSuccess)
         at Microsoft.EntityFrameworkCore.DbContext.SaveChanges(Boolean acceptAllChangesOnSuccess)
Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while updating the entries. See the inner exception for details.
 ---> Microsoft.Data.SqlClient.SqlException (0x80131904): Violation of PRIMARY KEY constraint 'PK_Bug'. Cannot insert duplicate key in object 'dbo.Bug'. The duplicate key value is (1).
The statement has been terminated.
   at Microsoft.Data.SqlClient.SqlConnection.OnError(SqlException exception, Boolean breakConnection, Action`1 wrapCloseInAction)
   at Microsoft.Data.SqlClient.SqlInternalConnection.OnError(SqlException exception, Boolean breakConnection, Action`1 wrapCloseInAction)
   at Microsoft.Data.SqlClient.TdsParser.ThrowExceptionAndWarning(TdsParserStateObject stateObj, Boolean callerHasConnectionLock, Boolean asyncClose)
   at Microsoft.Data.SqlClient.TdsParser.TryRun(RunBehavior runBehavior, SqlCommand cmdHandler, SqlDataReader dataStream, BulkCopySimpleResultSet bulkCopyHandler, TdsParserStateObject stateObj, Boolean& dataReady)
   at Microsoft.Data.SqlClient.SqlDataReader.TryConsumeMetaData()
   at Microsoft.Data.SqlClient.SqlDataReader.get_MetaData()
   at Microsoft.Data.SqlClient.SqlCommand.FinishExecuteReader(SqlDataReader ds, RunBehavior runBehavior, String resetOptionsString, Boolean isInternal, Boolean forDescribeParameterEncryption, Boolean shouldCacheForAlwaysEncrypted)
   at Microsoft.Data.SqlClient.SqlCommand.RunExecuteReaderTds(CommandBehavior cmdBehavior, RunBehavior runBehavior, Boolean returnStream, Boolean isAsync, Int32 timeout, Task& task, Boolean asyncWrite, Boolean inRetry, SqlDataReader ds, Boolean describeParameterEncryptionRequest)
   at Microsoft.Data.SqlClient.SqlCommand.RunExecuteReader(CommandBehavior cmdBehavior, RunBehavior runBehavior, Boolean returnStream, TaskCompletionSource`1 completion, Int32 timeout, Task& task, Boolean& usedCache, Boolean asyncWrite, Boolean inRetry, String method)
   at Microsoft.Data.SqlClient.SqlCommand.RunExecuteReader(CommandBehavior cmdBehavior, RunBehavior runBehavior, Boolean returnStream, String method)
   at Microsoft.Data.SqlClient.SqlCommand.ExecuteReader(CommandBehavior behavior)
   at Microsoft.Data.SqlClient.SqlCommand.ExecuteDbDataReader(CommandBehavior behavior)
   at System.Data.Common.DbCommand.ExecuteReader()
   at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReader(RelationalCommandParameterObject parameterObject)
   at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.Execute(IRelationalConnection connection)
ClientConnectionId:3f52c679-74ce-4b30-89a7-6cad002ca7ef
Error Number:2627,State:1,Class:14
   --- End of inner exception stack trace ---
   at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.Execute(IRelationalConnection connection)
   at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.Execute(IEnumerable`1 commandBatches, IRelationalConnection connection)
   at Microsoft.EntityFrameworkCore.Storage.RelationalDatabase.SaveChanges(IList`1 entries)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChanges(IList`1 entriesToSave)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChanges(DbContext _, Boolean acceptAllChangesOnSuccess)
   at Microsoft.EntityFrameworkCore.SqlServer.Storage.Internal.SqlServerExecutionStrategy.Execute[TState,TResult](TState state, Func`3 operation, Func`3 verifySucceeded)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChanges(Boolean acceptAllChangesOnSuccess)
   at Microsoft.EntityFrameworkCore.DbContext.SaveChanges(Boolean acceptAllChangesOnSuccess)
Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while updating the entries. See the inner exception for details.
 ---> Microsoft.Data.SqlClient.SqlException (0x80131904): Violation of PRIMARY KEY constraint 'PK_Bug'. Cannot insert duplicate key in object 'dbo.Bug'. The duplicate key value is (1).
The statement has been terminated.
   at Microsoft.Data.SqlClient.SqlConnection.OnError(SqlException exception, Boolean breakConnection, Action`1 wrapCloseInAction)
   at Microsoft.Data.SqlClient.SqlInternalConnection.OnError(SqlException exception, Boolean breakConnection, Action`1 wrapCloseInAction)
   at Microsoft.Data.SqlClient.TdsParser.ThrowExceptionAndWarning(TdsParserStateObject stateObj, Boolean callerHasConnectionLock, Boolean asyncClose)
   at Microsoft.Data.SqlClient.TdsParser.TryRun(RunBehavior runBehavior, SqlCommand cmdHandler, SqlDataReader dataStream, BulkCopySimpleResultSet bulkCopyHandler, TdsParserStateObject stateObj, Boolean& dataReady)
   at Microsoft.Data.SqlClient.SqlDataReader.TryConsumeMetaData()
   at Microsoft.Data.SqlClient.SqlDataReader.get_MetaData()
   at Microsoft.Data.SqlClient.SqlCommand.FinishExecuteReader(SqlDataReader ds, RunBehavior runBehavior, String resetOptionsString, Boolean isInternal, Boolean forDescribeParameterEncryption, Boolean shouldCacheForAlwaysEncrypted)
   at Microsoft.Data.SqlClient.SqlCommand.RunExecuteReaderTds(CommandBehavior cmdBehavior, RunBehavior runBehavior, Boolean returnStream, Boolean isAsync, Int32 timeout, Task& task, Boolean asyncWrite, Boolean inRetry, SqlDataReader ds, Boolean describeParameterEncryptionRequest)
   at Microsoft.Data.SqlClient.SqlCommand.RunExecuteReader(CommandBehavior cmdBehavior, RunBehavior runBehavior, Boolean returnStream, TaskCompletionSource`1 completion, Int32 timeout, Task& task, Boolean& usedCache, Boolean asyncWrite, Boolean inRetry, String method)
   at Microsoft.Data.SqlClient.SqlCommand.RunExecuteReader(CommandBehavior cmdBehavior, RunBehavior runBehavior, Boolean returnStream, String method)
   at Microsoft.Data.SqlClient.SqlCommand.ExecuteReader(CommandBehavior behavior)
   at Microsoft.Data.SqlClient.SqlCommand.ExecuteDbDataReader(CommandBehavior behavior)
   at System.Data.Common.DbCommand.ExecuteReader()
   at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReader(RelationalCommandParameterObject parameterObject)
   at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.Execute(IRelationalConnection connection)
ClientConnectionId:3f52c679-74ce-4b30-89a7-6cad002ca7ef
Error Number:2627,State:1,Class:14
   --- End of inner exception stack trace ---
   at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.Execute(IRelationalConnection connection)
   at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.Execute(IEnumerable`1 commandBatches, IRelationalConnection connection)
   at Microsoft.EntityFrameworkCore.Storage.RelationalDatabase.SaveChanges(IList`1 entries)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChanges(IList`1 entriesToSave)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChanges(DbContext _, Boolean acceptAllChangesOnSuccess)
   at Microsoft.EntityFrameworkCore.SqlServer.Storage.Internal.SqlServerExecutionStrategy.Execute[TState,TResult](TState state, Func`3 operation, Func`3 verifySucceeded)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChanges(Boolean acceptAllChangesOnSuccess)
   at Microsoft.EntityFrameworkCore.DbContext.SaveChanges(Boolean acceptAllChangesOnSuccess)
   at Microsoft.EntityFrameworkCore.DbContext.SaveChanges()
   at ThreeOneApp.Main() in /home/ajcvickers/AllTogetherNow/ThreeOne/ThreeOne.cs:line 152

Process finished with exit code 134.

@ajcvickers
Copy link
Member

Notes on Shay's notes:

There seem to be agreement that if EF creates the transaction for SaveChanges, that transaction should be rolled back.

This is already happening. In my testing, there is nothing that needs to change here.

There's also some agreement (non-final) that even a user-created transaction should be rolled back in case of exception, by default. This is because in the general case, it is difficult to tell exactly what updates went through before the exception occurred and which did not, and so the transaction state is to a large extent unknown. We could expose the information on what went through on the exception, and the user could reconstruct the state from that, but that doesn't seem like what we want for the default, basic experience. However, it was also said that an opt-out from auto-rollback should be provided for user-managed transactions.

We're not going to roll back user-initiated transactions to before where SaveChanges started. So we would need savepoints or equivalent to be able to do anything with user-initiated transactions.

Finally, if savepoints are introduced in ADO.NET (dotnet/runtime#33397), we could automatically generate a savepoint before SaveChanges, and roll back to that if an error occurred. This would add a network roundtrip (although see npgsql/npgsql#2892), so we should do this only for user-generated transactions (EF-generated ones can just be rolled back).

We should do this, since it makes the behavior for user-initiated transactions the same as for EF created transactions given a single call to SaveChanges.

@ajcvickers ajcvickers added this to the 5.0.0 milestone Mar 20, 2020
@roji
Copy link
Member

roji commented Mar 22, 2020

Additional note: we should introduce hooks for managing savepoints in EF Core - this could be important to support cases where the ADO.NET provider hasn't yet implemented support. It would also act as sugar for users to manually manage savepoints more easily, without dropping down to ADO.NET.

@roji roji changed the title GetDatabaseValues() return wrong value inside transaction Use savepoints to roll back to before SaveChanges for user-managed transactions Apr 20, 2020
roji added a commit that referenced this issue Apr 21, 2020
* Add APIs for supporting transaction savepoints.
* Support is implemented at the EF level only, no System.Data support
  yet (this should come soon).
* Use savepoints in the update pipeline when a user-managed transaction
  is used, to roll back to before SaveChanges in case of exception.

Part of #20176
roji added a commit that referenced this issue Apr 24, 2020
* Add APIs for supporting transaction savepoints.
* Support is implemented at the EF level only, no System.Data support
  yet (this should come soon).
* Use savepoints in the update pipeline when a user-managed transaction
  is used, to roll back to before SaveChanges in case of exception.

Part of #20176
roji added a commit that referenced this issue Apr 24, 2020
* Add APIs for supporting transaction savepoints.
* Support is implemented at the EF level only, no System.Data support
  yet (this should come soon).
* Use savepoints in the update pipeline when a user-managed transaction
  is used, to roll back to before SaveChanges in case of exception.

Part of #20176
@roji
Copy link
Member

roji commented Apr 24, 2020

Merged support. Keeping open for further work:

  • If for some reason we decide to target .NET 5.0 only, we should change RelationalTransaction to just delegate to the new System.Data support (Expose transaction savepoints in ADO.NET runtime#33397). FWIW I don't think we should drop support for .NET Standard 2.1 unless there's a very good reason for that.
  • If we do keep .NET Standard 2.1, we need to keep the support without the new System.Data (as now), and there's probably no value in supporting via System.Data if we do multi-target. So I'd just keep the current implementation, and one day switch to System.Data when our minimum TFM becomes .NET 5.0.
  • Either way logging and interception is missing - deferring that for now, until a decision is made for the TFMs.

roji added a commit that referenced this issue Jun 1, 2020
roji added a commit that referenced this issue Jun 1, 2020
roji added a commit that referenced this issue Jun 2, 2020
roji added a commit that referenced this issue Jun 2, 2020
roji added a commit that referenced this issue Jun 2, 2020
@roji roji added the closed-fixed The issue has been fixed and is/will be included in the release indicated by the issue milestone. label Jun 2, 2020
roji added a commit that referenced this issue Jun 3, 2020
@ajcvickers ajcvickers modified the milestones: 5.0.0, 5.0.0-preview7 Jun 22, 2020
@ajcvickers ajcvickers modified the milestones: 5.0.0-preview7, 5.0.0 Nov 7, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-save-changes closed-fixed The issue has been fixed and is/will be included in the release indicated by the issue milestone. customer-reported type-enhancement
Projects
None yet
Development

Successfully merging a pull request may close this issue.

4 participants