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

Sharing of transaction fails on execution strategy retry #25946

Closed
stevendarby opened this issue Sep 8, 2021 · 0 comments · Fixed by #25979
Closed

Sharing of transaction fails on execution strategy retry #25946

stevendarby opened this issue Sep 8, 2021 · 0 comments · Fixed by #25979
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-bug
Milestone

Comments

@stevendarby
Copy link
Contributor

stevendarby commented Sep 8, 2021

Within an "ExecuteInTransaction" operation I want to share a transaction between two contexts using Database.UseTransaction(...). This works the first time, but if the execution block fails and is retried, the call to UseTransaction throws the below exception.

Exception
System.InvalidOperationException
  HResult=0x80131509
  Message=The connection is already in a transaction and cannot participate in another transaction.
  Source=Microsoft.EntityFrameworkCore.Relational
  StackTrace:
   at Microsoft.EntityFrameworkCore.Storage.RelationalConnection.EnsureNoTransactions()
   at Microsoft.EntityFrameworkCore.Storage.RelationalConnection.ShouldUseTransaction(DbTransaction transaction)
   at Microsoft.EntityFrameworkCore.Storage.RelationalConnection.UseTransaction(DbTransaction transaction, Guid transactionId)
   at Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions.UseTransaction(DatabaseFacade databaseFacade, DbTransaction transaction, Guid transactionId)
   at Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions.UseTransaction(DatabaseFacade databaseFacade, DbTransaction transaction)
   at <Program>$.<>c__DisplayClass0_0.<<Main>$>b__0(ValueTuple`2 c) in [redacted] 31
   at Microsoft.EntityFrameworkCore.ExecutionStrategyExtensions.<>c__DisplayClass19_0`1.<ExecuteInTransaction>b__0(TState s)
   at Microsoft.EntityFrameworkCore.ExecutionStrategyExtensions.<>c__DisplayClass23_0`2.<ExecuteInTransaction>b__0(DbContext c, ExecutionState`2 s)
   at Microsoft.EntityFrameworkCore.Storage.ExecutionStrategy.<>c__DisplayClass31_0`2.<Execute>b__0(DbContext context, TState state)
   at Microsoft.EntityFrameworkCore.Storage.ExecutionStrategy.ExecuteImplementation[TState,TResult](Func`3 operation, Func`3 verifySucceeded, TState state)

There appear to be two simple workarounds, though I'm not certain about their reliability:

  1. Call Database.UseTransaction(null) just before calling again with the actual transaction to share
  2. Call Database.CurrentTransaction?.Dispose() just before UseTransaction(...)

However, it would be better - if possible - not to require these workarounds. especially because of the slight difficulty in discovering the need for them due to it only happening on a retry. Perhaps the underlying transaction should be recognised as disposed / no longer in use?

Here is a repo, tried to keep it minimal while conveying a sense of the use-case for this scenario.

Code
using System;
using System.Data.Common;
using System.Linq;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using System.Reflection;

{
    using var mainContext = new MainContext();
    mainContext.Database.EnsureDeleted();
    mainContext.Database.EnsureCreated();

    using var auditContext = new AuditContext();

    mainContext.Posts.Add(new Post());

    var throwTransientError = true;
    mainContext.Database.CreateExecutionStrategy().ExecuteInTransaction(
        (MainContext: mainContext, AuditContext: auditContext),
        c =>
        {
            // Save main changes to get store-generated values, don't accept changes so can be retried
            c.MainContext.SaveChanges(false);

            // May be retrying so clear audit change tracker
            c.AuditContext.ChangeTracker.Clear();

            // Share connection and transaction
            c.AuditContext.Database.SetDbConnection(c.MainContext.Database.GetDbConnection());

            // Workaround 1:
            //c.AuditContext.Database.UseTransaction(null);
            // Workaround 2:
            //c.AuditContext.Database.CurrentTransaction?.Dispose();
            c.AuditContext.Database.UseTransaction(c.MainContext.Database.CurrentTransaction.GetDbTransaction());

            // Audit original values, new values etc.
            c.AuditContext.Audits.Add(new Audit());
            c.AuditContext.SaveChanges();

            if (throwTransientError)
            {
                throwTransientError = false;
                throw SqlExceptionBuilder.CreateTransientError();
            }
        },
        _ => false);

    mainContext.ChangeTracker.AcceptAllChanges();
}

public class MainContext : DbContext
{
    public DbSet<Post> Posts { get; set; }
    public DbSet<Audit> Audits { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder
            .UseSqlServer(@"Server=.;Database=Transactions;Integrated Security=True", x => x.EnableRetryOnFailure());
}

public class AuditContext : DbContext
{
    public DbSet<Audit> Audits { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder.UseSqlServer();
}

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

public class Audit
{
    public int AuditId { get; set; }
}

public static class SqlExceptionBuilder
{
    public static SqlException CreateTransientError()
    {
        var errorCtors = typeof(SqlError)
            .GetTypeInfo()
            .DeclaredConstructors;

        var error = (SqlError) errorCtors.First(c => c.GetParameters().Length == 8)
            .Invoke(new object[] { 10928, (byte) 0, (byte) 0, "Server", "ErrorMessage", "Procedure", 0, null});
        var errors = (SqlErrorCollection) typeof(SqlErrorCollection)
            .GetTypeInfo()
            .DeclaredConstructors
            .Single()
            .Invoke(null);

        typeof(SqlErrorCollection).GetRuntimeMethods().Single(m => m.Name == "Add")
            .Invoke(errors, new object[] {error});

        var exceptionCtors = typeof(SqlException)
            .GetTypeInfo()
            .DeclaredConstructors;

        return (SqlException) exceptionCtors.First(c => c.GetParameters().Length == 4)
            .Invoke(new object[] {"Bang!", errors, null, Guid.NewGuid()});
    }
}

Include provider and version information

EF Core version: 5.0.9, 6.0 daily
Database provider: Microsoft.EntityFrameworkCore.SqlServer

@ajcvickers ajcvickers added this to the 6.0.0 milestone Sep 11, 2021
@ajcvickers ajcvickers added the closed-fixed The issue has been fixed and is/will be included in the release indicated by the issue milestone. label Sep 12, 2021
ajcvickers added a commit that referenced this issue Sep 15, 2021
* Allow UseTransaction to replace existing transaction

Fixes #25946

* Don't dispose current transaction when replaced with new one.
@ajcvickers ajcvickers modified the milestones: 6.0.0, 6.0.0-rc2 Sep 15, 2021
@ajcvickers ajcvickers modified the milestones: 6.0.0-rc2, 6.0.0 Nov 8, 2021
@ajcvickers ajcvickers removed their assignment Sep 1, 2024
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-bug
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants