Search code examples
c#asp.net-coreentity-framework-coreasp.net-core-identity

Identity Usermanager DeleteAsync DbUpdateConcurrencyException


I am trying to delete a user via the aspnetcore.identity UserManager behind a webapi.

    [HttpPost("Delete", Name = "DeleteRoute")]
    [Authorize(Roles = "SuperUser")]
    public async Task<IActionResult> DeleteAsync([FromBody] User user)
    {
        Console.WriteLine("Deleting user: " + user.Id);
        try {
            await _userManager.DeleteAsync(user);
            return Ok();
        } catch(Exception e) {
            return BadRequest(e.Message);
        }

    }

This throws a DbUpdateConcurrencyException

   Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException: Database operation expected to affect 1 row(s) but actually affected 0 row(s). Data may have been modified or deleted since entities were loaded. See http://go.microsoft.com/fwlink/?LinkId=527962 for information on understanding and handling optimistic concurrency exceptions.
   at Microsoft.EntityFrameworkCore.Update.AffectedCountModificationCommandBatch.ThrowAggregateUpdateConcurrencyException(Int32 commandIndex, Int32 expectedRowsAffected, Int32 rowsAffected)
   at Microsoft.EntityFrameworkCore.Update.AffectedCountModificationCommandBatch.ConsumeResultSetWithoutPropagationAsync(Int32 commandIndex, RelationalDataReader reader, CancellationToken cancellationToken)
   at Microsoft.EntityFrameworkCore.Update.AffectedCountModificationCommandBatch.ConsumeAsync(RelationalDataReader reader, CancellationToken cancellationToken)
   at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
   at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(DbContext _, ValueTuple`2 parameters, CancellationToken cancellationToken)
   at Microsoft.EntityFrameworkCore.SqlServer.Storage.Internal.SqlServerExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(IReadOnlyList`1 entriesToSave, CancellationToken cancellationToken)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
   at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException: Database operation expected to affect 1 row(s) but actually affected 0 row(s). Data may have been modified or deleted since entities were loaded. See http://go.microsoft.com/fwlink/?LinkId=527962 for information on understanding and handling optimistic concurrency exceptions.
   at Microsoft.EntityFrameworkCore.Update.AffectedCountModificationCommandBatch.ThrowAggregateUpdateConcurrencyException(Int32 commandIndex, Int32 expectedRowsAffected, Int32 rowsAffected)
   at Microsoft.EntityFrameworkCore.Update.AffectedCountModificationCommandBatch.ConsumeResultSetWithoutPropagationAsync(Int32 commandIndex, RelationalDataReader reader, CancellationToken cancellationToken)
   at Microsoft.EntityFrameworkCore.Update.AffectedCountModificationCommandBatch.ConsumeAsync(RelationalDataReader reader, CancellationToken cancellationToken)
   at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
   at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(DbContext _, ValueTuple`2 parameters, CancellationToken cancellationToken)
   at Microsoft.EntityFrameworkCore.SqlServer.Storage.Internal.SqlServerExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(IReadOnlyList`1 entriesToSave, CancellationToken cancellationToken)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
   at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)

Im aware that this exception is usually indicating race conditions, but I dont get why that happens.

Am I doing something wrong?

EDIT

The user object i post looks something like this:

"User": {
 "Email": "",
 "FirstName": "",
 "LastName": "",
 "Gender": "",
 "Affiliation": {
     "isStudent": true,
     "isEmployee": false
   }
   ...
}

Solution

  • Entity Framework Core uses Optimistic Concurrency:

    In an optimistic concurrency model, a violation is considered to have occurred if, after a user receives a value from the database, another user modifies the value before the first user has attempted to modify it.

    Contrast this with Pessimistic Concurrency:

    ...in a pessimistic concurrency model, a user who updates a row establishes a lock. Until the user has finished the update and released the lock, no one else can change that row.

    In order to achieve Optimistic Concurrency, the IdentityUser class contains a ConcurrencyStamp property (and corresponding column in the database), which is a string-representation of a GUID:

    public virtual string ConcurrencyStamp { get; set; } = Guid.NewGuid().ToString();
    

    Every time a user is saved to the database, the ConcurrencyStamp gets set to a new GUID.

    Taking the example of deleting a user, a simplified version of the DELETE SQL statement sent to the server might look something like the following:

    DELETE FROM dbo.AspNetUsers
    WHERE Id = '<USER_ID>' AND ConcurrencyStamp = '<CONCURRENCY_STAMP>'
    

    The error message you receive occurs when the CONCURRENCY_STAMP value in the SQL statement above does not match the value stored in the database for the given user. This ensures that if you retrieve a user from the database (which contains a specific ConcurrencyStamp), you can only save changes to the database if no other changes have been made elsewhere (as you're providing the same ConcurrencyStamp value that existed in the database).

    As you can see from the ConcurrencyStamp definition above, the property defaults to a new GUID - every time an IdentityUser (or subclass) is created, it gets a new ConcurrencyStamp value. In your example, with the User that is passed in to your DeleteAsync action, ASP.NET Core Model-Binding first creates a new instance of User and then sets the properties that exist in the JSON payload. As there is no ConcurrencyStamp value in the payload, User will end up with a new ConcurrencyStamp value that will not match that in the database.

    To avoid this problem, you could add the ConcurrencyStamp value into your payload sent from the client. However, I wouldn't recommend this. The simplest and safest approach to resolving this issue would be to send the Id of the User as the payload, retrieve the User itself using _userManager.FindByIdAsync and then using that instance to perform the delete. Here's an example:

    [HttpPost("Delete/{id}", Name = "DeleteRoute")]
    [Authorize(Roles = "SuperUser")]
    public async Task<IActionResult> DeleteAsync(string id)
    {
        Console.WriteLine("Deleting user: " + id);
        try {
            var user = await _userManager.FindByIdAsync(id);
    
            if(user == null)
                // ... 
    
            await _userManager.DeleteAsync(user);
            return Ok();
        } catch(Exception e) {
            return BadRequest(e.Message);
        }
    }