ASP.NET Core 3.1 - Concurrency Conflicts


Ken Haggerty
Created 02/02/2020 - Updated 02/02/2020 16:24

This article will demonstrate the implementation of trapping and handling concurrency conflicts when editing and updating a user. I will assume you have created a new ASP.NET Core 3.1 Razor Pages project and have implemented the first 6 articles of the series. See Tutorial: Get started with Razor Pages in ASP.NET Core .

This article is part of a series about users without Identity.


Access to the research project source code may be purchased on KenHaggerty.Com at Manage > Assets.

A project which implements users without Identity has been published to demo.kenhaggerty.com.

I enjoy writing these articles. It often enhances and clarifies my coding. The research project is a result of a lot of refactoring and hopefully provides logical segues for the articles. Thank you for supporting my efforts.

You try to catch a DbUpdateConcurrencyException when you save the user to the database after an update. If the DbUpdateConcurrencyException is caught, you throw the exception. You still need to configure a concurrency check and we can do a better job warning the editor.

We already have a RowVersion byte[] property with the [TimeStamp] data annotation which will automatically update on SQL Update. You can use the RowVersion as a concurrency check if you add it to the InputModel.

Edit Admin/ Users/ Edit.cshtml.cs > InputModel:
public class InputModel
{
    [Key]
    public int Id { get; set; }

    [Required]
    [StringLength(32, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
    [Display(Name = "Login Name")]
    public string LoginName { get; set; }

    [StringLength(32, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 8)]
    public string Password { get; set; }

    [Display(Name = "Admin")]
    public bool IsAdmin { get; set; }

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

Add the user's RowVersion to the InputModel in the edit page's OnGetAsync method and add a hidden input for RowVersion on the edit page for binding.

Edit Admin/ Users/ Edit.cshtml:
<input type="hidden" asp-for="Input.RowVersion" />

In the edit user page's OnPostAsync method, we get a fresh representation of the user from the database. If another editor has updated the record, the RowVersion will have been automatically updated. We update this fresh representation of the user from the InputModel. You need to update the RowVersion in OriginalValues for SQL to detect a concurrency conflict.

When we catch a DbUpdateConcurrencyException, you can use the ModelState.AddModelError to inform the editor the update was canceled and display the current values from the database. You can extract the current database values from the exception.

Edit Admin/ Users/ Edit.cshtml.cs > OnPostAsync:
_context.Attach(user).State = EntityState.Modified;

try
{
    _context.Entry(user).OriginalValues["RowVersion"] = Input.RowVersion;
    await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
    var entry = ex.Entries.Single();
    var databaseEntry = entry.GetDatabaseValues();
    if (databaseEntry == null)
    {
        ModelState.AddModelError(string.Empty,
            "Unable to save changes. The User was deleted by another user. Click Cancel.");
    }
    else
    {
        ModelState.Clear(); // required to update Input model

        ModelState.AddModelError(string.Empty, "The record you attempted to edit " +
            "was modified by another user after you got the original values. The " +
            "edit operation was canceled and the current values in the database " +
            "have been displayed. You can continue to edit then Save again. " +
            "Otherwise click Cancel.");

        var databaseValues = (AppUser)databaseEntry.ToObject();
        Input = new InputModel()
        {
            Id = databaseValues.Id,
            LoginName = databaseValues.LoginName,
            IsAdmin = databaseValues.IsAdmin,
            RowVersion = databaseValues.RowVersion
        };
    }

    return Page();
}
catch (RetryLimitExceededException /* dex */)
{
    // Retry Limit = 6
    ModelState.AddModelError("", "Unable to save changes. Try again, and if the problem persists, see your system administrator.");
}
catch (DbUpdateException)
{
    throw new InvalidOperationException("DbUpdateException occurred creating a new user.");
}

Notice the InputModel will not update without ModelState.Clear(). You can do a concurrency check before you make the update call to the database. I do both.

Edit Admin/ Users/ Edit.cshtml.cs > OnPostAsync:
// Test for concurrency conflict
if (!user.RowVersion.SequenceEqual(Input.RowVersion))
{
    ModelState.Clear(); // required to update Input model

    ModelState.AddModelError(string.Empty, "The record you attempted to edit " +
            "was modified by another user after you got the original values. The " +
            "edit operation was canceled and the current values in the database " +
            "have been displayed. You can continue to edit then Save again. " +
            "Otherwise click Cancel.");

    Input = new InputModel()
    {
        Id = user.Id,
        LoginName = user.LoginName,
        IsAdmin = user.IsAdmin,
        RowVersion = user.RowVersion
    };

    return Page();
}

The delete user page is a little different. Add a validation summary and hidden input for AppUser.RowVersion.

Edit Admin/ Users/ Delete.cshtml:
<form class="" method="post">
    <div asp-validation-summary="ModelOnly" class="text-danger"></div>
    <input type="hidden" asp-for="AppUser.Id" />
    <input type="hidden" asp-for="AppUser.RowVersion" />
    <input type="submit" value="Delete" class="btn btn-danger" />
    <a class="btn btn-primary" asp-page="/Admin/Users/Index">Cancel</a>
</form>
Edit Admin/ Users/ Delete.cshtml.cs > OnPostAsync:
var user = await _context.AppUsers
    .AsNoTracking()
    .FirstOrDefaultAsync(m => m.Id == AppUser.Id);

if (user == null)
{
    ModelState.AddModelError(string.Empty,
            "Unable to save changes. The User was deleted by another user. Click Cancel.");
    return Page();
}

_context.AppUsers.Remove(user);

try
{
    _context.Entry(user).OriginalValues["RowVersion"] = AppUser.RowVersion;
    await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
    var entry = ex.Entries.Single();
    var databaseEntry = entry.GetDatabaseValues();
    if (databaseEntry == null)
    {
        ModelState.AddModelError(string.Empty,
            "Unable to save changes. The User was deleted by another user.");
    }
    else
    {
        ModelState.Clear(); // required to update the model

        ModelState.AddModelError(string.Empty, "The record you attempted to delete " +
                "was modified by another user after you got the original values. The " +
                "delete operation was canceled and the current values in the database " +
                "have been displayed. You can continue to Delete again. " +
                "Otherwise click Cancel.");

        AppUser = (AppUser)databaseEntry.ToObject();
    }

    return Page();
}
catch (RetryLimitExceededException /* dex */)
{
    // Retry Limit = 6
    ModelState.AddModelError("", "Unable to save changes. Try again, and if the problem persists, see your system administrator.");
}
catch (DbUpdateException)
{
    throw new InvalidOperationException("DbUpdateException occurred deleting a user.");
}

Build, run and test.

Edit User page

Article Tags:

EF Core Model

1 Following


Comment Count = 0

Please log in to comment or follow.

Login Register