question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

BeginTransaction with ReadUncommitted can cause dirty reads for out-of-transaction queries

See original GitHub issue

Consider the following HomeController, modified from a fresh Asp.Net Core 2.0 MVC template project in Visual Studio 2017 (15.6.6):

public class HomeController : Controller
    {
        private readonly ApplicationDbContext _applicationDbContext;
        
        public HomeController(ApplicationDbContext applicationDbContext)
        {
            _applicationDbContext = applicationDbContext;
        }

        /// <summary>
        /// Add and save a user in a read-uncommitted transaction, but don't commit the changes until some time has passed
        /// </summary>
        /// <returns></returns>
        public async Task<ContentResult> CreateUser1()
        {
            var username = $"{AppConstants.UserPrefix}USER1@TEST.COM";
            var executionStrategy = _applicationDbContext.Database.CreateExecutionStrategy();
            await executionStrategy.ExecuteAsync(async () =>
            {
                using (var t =
                    await _applicationDbContext.Database.BeginTransactionAsync(System.Data.IsolationLevel.ReadUncommitted))
                {
                    _applicationDbContext.Users.Add(new ApplicationUser { UserName = username, NormalizedUserName = username, Email = username, NormalizedEmail = username });
                    await _applicationDbContext.SaveChangesAsync();
                    await Task.Delay(TimeSpan.FromSeconds(120)); // Simulate other work on which transaction committing depends
                    t.Commit();
                }
            });
            return Content("ok");
        }

        /// <summary>
        /// Add and save a user in a read-uncommitted transaction, then commit the transaction WITHOUT DELAY
        /// </summary>
        /// <returns></returns>
        public async Task<ContentResult> CreateUser2()
        {
            var username = $"{AppConstants.UserPrefix}USER2@TEST.COM";
            var executionStrategy = _applicationDbContext.Database.CreateExecutionStrategy();
            await executionStrategy.ExecuteAsync(async () =>
            {
                using (var t =
                    await _applicationDbContext.Database.BeginTransactionAsync(System.Data.IsolationLevel.ReadUncommitted))
                {
                    _applicationDbContext.Users.Add(new ApplicationUser { UserName = username, NormalizedUserName = username, Email = username, NormalizedEmail = username });
                    await _applicationDbContext.SaveChangesAsync();
                    t.Commit();
                }
            });
            return Content("ok");
        }

        /// <summary>
        /// Add and save a user in a read-committed transaction
        /// </summary>
        /// <returns></returns>
        public async Task<ContentResult> CreateUser3()
        {
            var username = $"{AppConstants.UserPrefix}USER3@TEST.COM";
            _applicationDbContext.Users.Add(new ApplicationUser { UserName = username, NormalizedUserName = username, Email = username, NormalizedEmail = username });
            await _applicationDbContext.SaveChangesAsync();
            return Content("ok");
        }

        /// <summary>
        /// Asserts the current transaction isolation level is read-committed
        /// </summary>
        /// <returns></returns>
        public async Task<ContentResult> AssertReadCommitted()
        {
            try
            {
                await _applicationDbContext.Database.ExecuteSqlCommandAsync("IF (select transaction_isolation_level from sys.dm_exec_sessions where session_id = @@SPID)=2 select 'ok'; ELSE THROW 51000, 'NOT READ COMMITTED ISOLATION!.', 1; ");
                return Content("ok");
            }
            catch (Exception e)
            {
                return Content(e.Message);
            }
        }

        /// <summary>
        /// Attempt to get user1 using a standard EF Core query.
        /// </summary>
        /// <returns></returns>
        public async Task<ContentResult> GetUser1()
        {
            var username = $"{AppConstants.UserPrefix}USER1@TEST.COM";
            var user = await _applicationDbContext.Users.Where(u => u.NormalizedUserName == username).FirstOrDefaultAsync();
            return Content("Result: " + user);
        }
    }

    public static class AppConstants
    {
        public static readonly string UserPrefix = Guid.NewGuid().ToString("N").ToUpperInvariant();
    }

Steps to Reproduce the problem

  1. Run the solution and navigate to /home/createuser1 in a browser (this will spin because of the delay waiting to commit)

  2. Run all of the following steps while the above operation is still waiting to complete (“spinning”)

  • Open another tab in the browser and navigate to /home/getuser1. This will return the expected empty result (because user1 hasn’t yet been committed from /home/createuser1). Note also that navigating to /home/AssertReadCommitted will return “ok” (i.e. read-committed isolation for normal queries).

  • Open another tab in the browser and navigate to /home/createuser2. This will create another user immediately in a read-uncommitted transaction

  • Open another tab in the browser and navigate to /home/getuser1. This now returns the user1 record, which is unexpected because it still hasn’t been committed. This implies that there has been a dirty read (I’m proposing that this is an issue). Note that navigating to /home/AssertReadCommitted will now also return an exception string showing that we have read-uncommitted isolation for normal queries.

  • Open another tab in the browser and navigate to /home/createuser3. This will create another user immediately in a read-committed transaction

  • Open another tab in the browser and navigate to /home/getuser1. Now the user1 record is not returned any more, because we’re back to read-committed behavior after the creation of user3. Note also that navigating to /home/AssertReadCommitted will return “ok” (i.e. read-committed isolation for normal queries).

Remarks

I might be missing something, but it seems to me that creating a transaction with BeginTransactionAsync and ReadUncommitted should mean that only the queries within that transaction are affected by the isolation level. Each separate web request gets a different ApplicationDbContext instance if I’m not mistaken, but presumably they all use the same underlying “connection”/“session” to the database, hence the propagation of the transaction isolation level change to other web requests.

My particular use case: I’m writing a booking application that involves payment. When someone creates a booking, I need to make sure that 1) there are no conflicts in time with other bookings, including other new bookings for which the payment might currently be being processed, and 2) the payment goes through successfully. My approach was going to be something along the lines of:

            var executionStrategy = _applicationDbContext.Database.CreateExecutionStrategy();
            await executionStrategy.ExecuteAsync(async () =>
            {
                using (var t =
                    await _applicationDbContext.Database.BeginTransactionAsync(System.Data.IsolationLevel.ReadUncommitted))
                {
                    // 1. Insert and Save the booking
                    // 2. Retrieve all potential conflicts (including uncommitted ones from this operation in other transactions)
                    // 3. If conflict, rollback, otherwise process payment
                   // 4. If payment fails, rollback, otherwise commit transaction
                }
            });

Given the above issue, I couldn’t use this approach because it would mean that other normal GET/read queries might seeing uncommitted booking data (that may e.g. still fail payment), which of course I want to avoid.

I’d be grateful for any comments on whether or not this is indeed a bug, and the recommended approach for solving such scenarios.

Further technical details

EF Core version: Microsoft.AspNetCore.All 2.0.7, Microsoft.EntityFrameworkCore.Tools 2.0.2 Database Provider: Microsoft.EntityFrameworkCore.SqlServer Operating system: Windows 10 Pro 10.0.16299 IDE: Visual Studio 2017 15.6.6 Corresponding VS Solution ZIP: TransactionsDemo.zip

Issue Analytics

  • State:closed
  • Created 5 years ago
  • Comments:16 (10 by maintainers)

github_iconTop GitHub Comments

1reaction
rojicommented, Jul 24, 2020

@samcic you can test the above by running your same flow with pooling off - if I’m right, then it should work as expected. It would be good to get a confirmation for that.

1reaction
rojicommented, Jul 24, 2020

I took another look at the scenario above, and it does seem like the root cause is dotnet/SqlClient#96, i.e. SqlClient leaking isolation levels across pooled connections.

  • In step 2, /home/createuser2 is accessed. This gets a (new) connection and executes an operation with ReadUncommitted. The connection is then returned to the pool.
  • In step 3, /home/getuser1 is accessed. This presumably gets the previous connection from the pool, which has retained the ReadUncommitted isolation level (this is precisely the bug dotnet/SqlClient#96 describes). Because of this, the uncommitted user1 record is returned, even if its inserting transaction hasn’t committed yet.

So this doesn’t seem to be related to EF Core. Until dotnet/SqlClient#96 is resolved, any application which executes a transaction in the non-default isolation level should probably reset the level back (e.g. by executing a second dummy transaction).

Read more comments on GitHub >

github_iconTop Results From Across the Web

BeginTransaction with ReadUncommitted can cause dirty ...
This implies that there has been a dirty read (I'm proposing that this is an issue). Note that navigating to /home/AssertReadCommitted will now ......
Read more >
Dirty Reads and the Read Uncommitted Isolation Level
The simplest explanation of the dirty read is the state of reading uncommitted data. In this circumstance, we are not sure about the...
Read more >
Why use a READ UNCOMMITTED isolation level?
This isolation level allows dirty reads. One transaction may see uncommitted changes made by some other transaction.
Read more >
Best situation to use READ UNCOMMITTED isolation level
As we all know, READ UNCOMMITTED is the lowest isolation level in which things like dirty reads and phantom reads may accrue. When...
Read more >
SET TRANSACTION ISOLATION LEVEL (Transact-SQL)
When this option is set, it is possible to read uncommitted modifications, which are called dirty reads. Values in the data can be...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found