简体   繁体   中英

Understanding Entity Framework transactions and their behavior

I am using Entity Framework code-first and it's behaving very strangely when I use transactions.

In the code below you will find a simple example. As you can see - even though transactions are created - they are never committed. Despite NOT having a commit - my first transaction is being saved to the database in about 50% of unit test runs.

Sometimes data only saved in DB in 10% of runs. I have no reasonable explanation and loosing my mind trying to solve it..

Code:

[TestMethod]
public void TestConcurrentTransactions2()
{
    int invoiceId = 1560;

    var context1 = new ApplicationDbContext();
    var transaction1 = context1.Database.BeginTransaction();
    var invoice1 = new OracleDatabaseService.Models.Invoice()
    {
        InvoiceId = (++invoiceId).ToString(),
        ClientId = "2",
        ExternalSystemId = "0"
    };
    context1.Invoices.Add(invoice1);
    context1.SaveChanges();

    var context2 = new ApplicationDbContext();
    var transaction2 = context2.Database.BeginTransaction();
    var invoice2 = new OracleDatabaseService.Models.Invoice()
    {
        InvoiceId = (++invoiceId).ToString(),
        ClientId = "2",
        ExternalSystemId = "0"
    };
    context2.Invoices.Add(invoice2);
    context2.SaveChanges();

    //transaction2.Commit();
}

I'm using EF 6.2.0 and an Oracle-managed data access provider.

This behavior seems to be very situational and the only way to reproduce it is by re-running the test case over and over again. I seem to be able to repro the issue more often if I go and change invoiceId while the previous test hasn't finished executing. In this case - the test will still finish executing successfully but a record in DB will appear. Though in most cases, this doesn't really matter and data will be pushed to DB randomly.

So my questions are:

  • Is this an OK behavior that transaction is being committed automatically?
  • How do I force EF to only commit transactions when I need to?
  • Is there an alternative mechanism to handle transactions in EF?

UPDATE 23.08.2018

So, here is a code which almost certainly will repro the issue at least once per run:

[TestMethod]
public void Test1()
{
    int invoiceId = 3350;

    Parallel.For(0, 30, i =>
    {
        var context1 = new ApplicationDbContext();
        var transaction1 = context1.Database.BeginTransaction();
        var invoice1 = new OracleDatabaseService.Models.Invoice()
        {
            InvoiceId = (invoiceId + i).ToString(),
            ClientId = "2",
            ExternalSystemId = "0"
        };
        context1.Invoices.Add(invoice1);
        context1.SaveChanges();

        //transaction1.Commit();
    });
}

Here is a fix attempt that seems to be working, though I don't fit into a service code pattern very well. I need to be able to come back to a service later and either roll-back or commit a transaction.

[TestMethod]
public void Test2()
{
    int invoiceId = 3350;

    Parallel.For(0, 30, i =>
    {
        using (var context = new ApplicationDbContext())
        {
            var transaction = context.Database.BeginTransaction();
            var invoice = new OracleDatabaseService.Models.Invoice()
            {
                InvoiceId = (invoiceId + i).ToString(),
                ClientId = "3",
                ExternalSystemId = "0"
            };
            context.Invoices.Add(invoice);
            context.SaveChanges();

            //transaction.Commit();
        }
    });
}

This is my attempt to implement a service that uses DBContext. As you will see in the code - a descructor will check whether there is a context or transaction present and dispose of them. Seems to be working OK. Though, what will happen if any of the parallel processes fail? Is the descructor called then? I need somebody to review the code, possibly.

public class DbService
{
    ApplicationDbContext _context;
    DbContextTransaction _transaction;

    public DbService()
    {
        _context = new ApplicationDbContext();
        _transaction = _context.Database.BeginTransaction();
    }

    public void InsertInvoice(int invoiceId)
    {
        var invoice1 = new OracleDatabaseService.Models.Invoice()
        {
            InvoiceId = (invoiceId).ToString(),
            ClientId = "3",
            ExternalSystemId = "0"
        };

        _context.Invoices.Add(invoice1);
        _context.SaveChanges();
    }

    ~DbService()
    {
        if (_transaction != null)
        {
            _transaction.Rollback();
            _transaction.Dispose();
        }

        if (_context != null)
        {
            _context.Dispose();
        }
    }
}

and test:

[TestMethod]
public void Test3()
{
    int invoiceId = 3350;

    Parallel.For(0, 30, i =>
    {
        var srvc = new DbService();
        srvc.InsertInvoice(invoiceId + i);
    });
}

As was suggested by @WynDysel in a comment section - the problem is resolvable by putting a context in a using block.

The actual reasons for the issue are still unknown to me. It looks logical, that unless something is explicitly said to be committed - to be committed. Well, I guess I have to live with this solution for now.

Perhaps I should make some clarifications about the reasons why I was not using the using block to begin with. It's because the DbContext is used from within a service. Within a service there are multiple operations being done in scope of the same transaction. To multiple entities of database. So when the code is ready for commit - a Commit() method is executed and all of the changes done are pushed to DB at once. Otherwise if something goes wrong along the way, then all of the changes are rolled back. So for this I needed a service and normally am not allowed to use a using block by design.

To make a long story short - I will be using following service for managing context and transaction .

public class DbService : IDisposable
{
    private bool _isDisposed = false;
    private ApplicationDbContext _context;
    private DbContextTransaction _transaction;

    public DbService()
    {
        _context = new ApplicationDbContext();
        _transaction = _context.Database.BeginTransaction();
    }

    public void InsertInvoice(int invoiceId)
    {
        try
        {
            var invoice1 = new OracleDatabaseService.Models.Invoice()
            {
                InvoiceId = (invoiceId).ToString(),
                ClientId = "3",
                ExternalSystemId = "0"
            };

            _context.Invoices.Add(invoice1);
            _context.SaveChanges();
        }
        catch (Exception)
        {
            Dispose(false);
            throw;
        }
    }

    public void Commit(bool isFinal)
    {
        if (!_isDisposed)
        {
            _transaction.Commit();

            if (isFinal)
            {
                Dispose(false);
            }
            else
            {
                _transaction.Dispose();
                _transaction = _context.Database.BeginTransaction();
            }
        }
    }

    public void Rollback(bool isFinal)
    {
        if (!_isDisposed)
        {
            if (isFinal)
            {
                Dispose(false);
            }
            else
            {
                _transaction.Rollback();
                _transaction.Dispose();
                _transaction = _context.Database.BeginTransaction();
            }
        }
    }

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (!_isDisposed)
        {
            if (disposing)
            {
                // Free other state (managed objects).
            }

            if (_transaction != null)
            {
                if (_transaction.UnderlyingTransaction.Connection != null)
                {
                    _transaction.Rollback();
                }

                _transaction.Dispose();
            }

            if (_context != null)
            {
                _context.Dispose();
            }

            _isDisposed = true;
        }
    }

    ~DbService()
    {
        Dispose(false);
    }
}

It is still possible to use the service in a using block. If something along the way goes wrong then a destructor shall be called to roll back the transaction and dispose of a context. There are 2 helper methods for committing and rolling back chnages manually. It could either be a final commit when the service is no longer needed or a temporary commit of current transaction and initialization of a new transaction while keeping an integrity of a service.

InsertInvoice method's contexts are also wrapped in a try/catch block in case something unexpected goes wrong.

I can't afford to insert any pending transaction data on a production environment so am taking all possible precautions! Perhaps I will be asking a question on Github about this issue Entity Framework creators themselves.

Update #1

It is very unfortunate, but the code I provided above does NOT guarantee that records will not be inserted. You have to make some additional validations, when using the service.

For example, this testcase will cause the data to be inserted into database sometimes:

[TestMethod]
public void TestFail()
{
    int invoiceId = 3700;

    Parallel.For(0, 30, i =>
    {
        var srvc = new DbService();
        srvc.InsertInvoice(invoiceId + i, i);

        if (i > 15)
        {
            throw new Exception();
        }
    });
}

And following code will guarantee disposal of context correctly:

[TestMethod]
public void TestGood()
{
    int invoiceId = 3700;

    Parallel.For(0, 30, i =>
    {
        DbService srvc = null;

        try
        {
            srvc = new DbService();
            srvc.InsertInvoice(invoiceId + i, i);

            if (i > 25)
                throw new Exception();
        }
        catch(Exception ex)
        {
            if (srvc != null)
                srvc.Dispose();

            throw ex;
        }
    });
}

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM