Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Concurrency across a scoped DbContext

I have a standard ASP.NET Core 2.0 Web API with a standard DI DbContext as such:

services.AdDbContext<TestContext>( ... );

I know that my context will be added with a scoped lifetime that essentially means that everytime my API gets a new request, it will create a new instance of the context.

So far so good.

Now then, I have created a repository called TestRepository that contains the following:

public class TestRepository : ITestRepository {
    public TestContext _context;
    public TestRepository(TestContext context) {
        _context = context;
    }

    public async Task IncrementCounter() {
        var row = await _context.Banks.FirstOrDefaultAsync();
        row.Counter += 1;
        await _context.SaveChangesAsync();
    }
}

So it basically just increments a value in a column with 1 - asynchronously. The ITestRepository is added to services IoC and injected wherever needed.

Scenario:

User A calls the API, gets a new instance of the TestContext, and now invokes the IncrementCounter method.

Before the SaveChangesAsync is called a User B has made a call to the API, gotten a new TestContext, and have invoked the IncrementCounter method aswell.

Now User A saves the value 1 into the column while User B also believes that it should save value 1 but it should actually be 2.

Even after reading related documentation, I am still unsure how to guarantee, that IncrementCounter method are incrementing correctly, even when multiple users are calling the API with their own instance of the TestContext.

I might have confused myself more than I should but could someone clarify how concurrency across different instances of the same context are handled?

Related documentation:

  • Concurrency in ASP.NET Core 2.0
  • Handling Concurrency Conflicts
  • Dependency Injection .NET Core 2.0

Update 1

I have thought about implementing the [TimeStamp] data-annotation on a new 'rowVersion' property and then catch the DbConcurrencyException, as the related documentation explains, but does that solve the issue that it is a scoped DbContext and therefore different context that are trying to SaveChangesAsync()?

like image 303
Anonymous Avatar asked Mar 06 '23 07:03

Anonymous


1 Answers

This actually doesn't have anything to do with object lifetimes, scoped or otherwise. Concurrency is concurrency. Doing something like incrementing a counter is inherently not thread-safe, so you must either employ locks or optimistic concurrency, just like with any other thread-unsafe task. Database locks are generally frowned upon, so optimistic concurrency is definitely the preferred approach.

How this works on a database-level is that you essentially have a timestamp column, which gets updated with each change. EF is smart enough to watch this column, and if the value it has on update is different than the value it had when you first queried the row, then it aborts the update. At that point, your code catches the exception and handles it by requerying the row and attempting to update it again.

For example, User A and User B attempt to save value 1 at the same time. Let's say User B gets in a few milliseconds earlier and successfully updates the column. This then also updates the timestamp column. When User A's update arrives, the update fails because the timestamp column no longer matches. This bubbles up back to EF, where it throws a DbUpdateConcurrencyException. Your code catches this exception, and responds by re-querying the row, where the column is now 1, instead of 0 (and gets the latest timestamp), increments to 2, and then attempts to save again. This time, there's no other users doing anything, so it goes through successfully. However, you could also have another concurrency exception. In which case, you need to rinse and repeat, eventually attempting to save 3 to the column, and so on.

You've linked to all the relevant documentation in your question itself, so I suggest you simply take some time to go through all that and really understand it. You'll need to add a new property to the entity where your incremented value is stored:

[Timestamp]
public byte[] Version { get; set; }

With that (and after you migrate obviously), EF will then begin throwing exceptions on save as described above when there's a conflict. To catch and handle these, I'd suggest using a library like Polly. It lets you set up retry policies, making it trivial to handle this repeated query-increment-save logic.

like image 151
Chris Pratt Avatar answered Mar 07 '23 21:03

Chris Pratt