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();
            }
        }
    }
}
billinkc
  • 59,250
  • 9
  • 102
  • 159
Ohad Meir
  • 714
  • 8
  • 18
  • 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` (http://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. – Slauma May 22 '13 at 16:44
  • @Slauma If he's using SQL Server with connection pooling, he could possibly be getting a connection that [inherits a previously-set isolation level](http://support.microsoft.com/kb/972915). @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. – Jeremy Todd May 22 '13 at 17:56
  • 1
    added IsolationLevel.ReadCommitted - test still fails – Ohad Meir May 23 '13 at 07:53
  • Same issue here; any news about this question? – sthiers Jun 07 '13 at 06:22

1 Answers1

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

Community
  • 1
  • 1
sthiers
  • 3,489
  • 5
  • 34
  • 47