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
}
...
}
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);
}
}