Entity Framework Code First: SaveChanges is not atomic
Asked Answered
A

1

9

I have the following very simple unit test that reproduces a case where DbContext.SaveChanges is not atomic. By not atomic I mean that the committed data can be read before all the commit was completed.

Add task: In a loop, adds a new TestEntity and a ReferencingEntity. Validate task: checks if there is a TestEntity that is not referenced by any ReferencingEntity - that is not supposed to happen because of the way I add the entities.

The unit test fails... any advice?

EDIT: According to the accepted answer - In order to run the unit test with the proposed solution add in the InitTest method:

using (var context = new TestContext())
{
    var objectContext = (context as IObjectContextAdapter).ObjectContext;
    objectContext.ExecuteStoreCommand(string.Format("ALTER DATABASE [{0}] SET READ_COMMITTED_SNAPSHOT ON", context.GetType().FullName));
}

Unit test:

using System.Data.Entity;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace Atlit.Server.Tests.Integration.SessionProcessing
{
    class TestContext : DbContext
    {
        public DbSet<TestEntity> TestEntities { get; set; }
        public DbSet<ReferencingEntity> ReferencingEntities { get; set; }
    }

    class TestEntity
    {
        public int TestEntityId { get; set; }
    }

    class ReferencingEntity
    {
        public int ReferencingEntityId { get; set; }
        public TestEntity TestEntity { get; set; } 
    }

    [TestClass]
    public class SaveChangesAtomicTest
    {
        private volatile int m_Count = 3000;
        private volatile bool m_Failed = false;

        [TestInitialize]
        public void InitTest()
        {
            using (var context = new TestContext())
            {
                var dbInitializer = new DropCreateDatabaseAlways<TestContext>();
                dbInitializer.InitializeDatabase(context);
            }
        }

        private void AddEntities()
        {
            while (m_Count-- > 0 && !m_Failed)
            {
                var transactionOptions = new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted };
                using (var transactionScope = new TransactionScope(TransactionScopeOption.RequiresNew, transactionOptions))
                {
                    using (var context = new TestContext())
                    {
                        var entity = context.TestEntities.Add(new TestEntity());
                        context.ReferencingEntities.Add(new ReferencingEntity { TestEntity = entity });
                        context.SaveChanges();
                    }
                    transactionScope.Complete();
                }
            }        
        }

        private void ValidateEntities()
        {
            while (m_Count > 0 && !m_Failed)
            {
                if (FreeEntitiesExist())
                {
                    m_Failed = true;
                }
            }            
        }

        [TestMethod]
        public void TestIsSaveChangesAtomic()
        {
            var addTask = Task.Factory.StartNew(AddEntities);
            var readTask = Task.Factory.StartNew(ValidateEntities);

            addTask.Wait();
            readTask.Wait();

            Assert.IsFalse(FreeEntitiesExist(), "sanity failed");
            Assert.IsFalse(m_Failed, "test failed");
        }

        private static bool FreeEntitiesExist()
        {
            using (var context = new TestContext())
            {
                return (from entity in context.TestEntities
                        where !context.ReferencingEntities.Any(re => re.TestEntity.TestEntityId == entity.TestEntityId)
                        select entity)
                        .ToArray().Any();
            }
        }
    }
}
Adelaadelaida answered 22/5, 2013 at 13:51 Comment(4)
This could be a "dirty read", depending on the database you are using and on isolation level. SQL Server for example has an isolation level READ UNCOMMITTED (msdn.microsoft.com/en-us/library/ms173763(v=sql.100).aspx) that allows a thread to read data inserted by another thread in a transaction before it has been committed. The data are "dirty" in the sense that they could "disappear" from the database when the second thread decides to roll back the transaction. But READ UNCOMMITTED is not the default in SQL Server.Greenwood
@Greenwood If he's using SQL Server with connection pooling, he could possibly be getting a connection that inherits a previously-set isolation level. @OhadMeir You might try wrapping your operations in a TransactionScope with an explicitly set isolation level of IsolationLevel.ReadCommitted and see if the error continues.Monofilament
added IsolationLevel.ReadCommitted - test still failsAdelaadelaida
Same issue here; any news about this question?Complexioned
C
7

Try the database option "Is Read Commited Snapshot On"=True.

We had same kind of issues. This option solved them.

More information on:

http://msdn.microsoft.com/en-us/library/ms173763.aspx

and

Add object and its relationships atomically in SQL Server database

Complexioned answered 11/6, 2013 at 7:50 Comment(1)
Also adding an answer to show how to solve it in the unit testAdelaadelaida

© 2022 - 2024 — McMap. All rights reserved.