ASP.NET Core 3.1 - Concurrency Conflicts

This article will demonstrate the implementation of trapping and handling concurrency conflicts when editing and updating a user. I will assume you have downloaded the ASP.NET Core 3.1 - Users Without Identity Project or created a new ASP.NET Core 3.1 Razor Pages project. See Tutorial: Get started with Razor Pages in ASP.NET Core. You should review the earlier articles of the Users Without Identity Project series.

Users Without Identity Project and Article Series

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.
Update 02/23/2021

I added the Enhanced User Series' article links.

Ken Haggerty
Created 02/02/20
Updated 02/24/21 00:14 GMT

Log In or Reset Quota to read more.

Article Tags:

EF Core Model
Successfully completed. Thank you for contributing.
Processing...
Something went wrong. Please try again.
Contribute to enjoy content without advertisments.
You can contribute without registering.

Comments(0)

Loading...
Loading...

Not accepting new comments.

Submit your comment. Comments are moderated.

User Image.
DisplayedName - Member Since ?