EF Core with MVC Handle Concurrency


A concurrency conflict occurs when one user displays an entity's data in order to edit it, and then another user updates the same entity's data before the first user's change is written to the database.

Pessimistic Concurrency

Pessimistic concurrency control is when a record is locked at the time the user begins his or her edit process.

  • In this concurrency mode, the record remains locked for the duration of the edit.
  • The primary advantage is that no other user is able to get a lock on the record for updating, and also inform any requesting user that they cannot update the record because it is in use.

Optimistic Concurrency

The alternative to pessimistic concurrency is optimistic concurrency. Optimistic concurrency means allowing concurrency conflicts to happen, and then reacting appropriately if they do.

  • The optimistic concurrency control approach doesn't actually lock anything, instead, it remembers when a row is retrieved from the database.
  • When the user wants to update the row, the row will be updated to the database only if the row still looks like when it was retrieved.
  • It doesn't prevent a possible conflict, but it can detect it before any damage is done and fails safely.

Default Concurrency in EF

By default, Entity Framework supports optimistic concurrency.

  • EF saves an entity data to the database, assuming that the same data has not been changed since the entity was loaded.
  • If it finds that the data has changed, then an exception is thrown and you must resolve the conflict before attempting to save it again.

Add Optimistic Concurrency

In order to check concurrency for the Author entity, the Authors table must have a rowversion column. So, add a tracking property named RowVersion to the Author class.

public class Author
{
    public int AuthorId { get; set; }

    [StringLength(20, ErrorMessage = "Name cannot be longer than 20 characters.")]
    public string FirstName { get; set; }

    [StringLength(20, ErrorMessage = "Name cannot be longer than 20 characters.")]
    public string LastName { get; set; }

    [NotMapped]
    public string FullName
    {
        get
        {
            return FirstName + " " + LastName;
        }
    }

    [DataType(DataType.Date)]
    [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]        
    public DateTime BirthDate { get; set; }

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

    public virtual ICollection<Book> Books { get; set; }
}

The Timestamp attribute specifies that this column will be included in the Where clause of Update and Delete commands sent to the database.

If you prefer to use the fluent API, you can use the IsConcurrencyToken method to specify the tracking property, as shown below.

modelBuilder.Entity<Author>()
    .Property(p => p.RowVersion).IsConcurrencyToken();

The database has been changed, so we need to do another migration. In the Package Manager Console, run the following commands.

Add-Migration RowVersion
Update-Database

Replace the existing code for the HttpPost Edit method with the following code.

[HttpPost, ActionName("Edit")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> EditPost(int? id, byte[] rowVersion)
{
    string[] fieldsToBind = new string[] { "FirstName", "LastName", "BirthDate" };

    if (id == null)
    {
        return new NotFoundResult();
    }

    var authorToUpdate = await _context.Authors.FirstOrDefaultAsync(s => s.AuthorId == id);
    if (authorToUpdate == null)
    {
        Author deletedAuthor = new Author();
        await TryUpdateModelAsync(deletedAuthor);
        ModelState.AddModelError(string.Empty,
            "Unable to save changes. The author was deleted by another user.");

        return View(deletedAuthor);
    }

    _context.Entry(authorToUpdate).Property("RowVersion").OriginalValue = rowVersion;

    if (await TryUpdateModelAsync<Author>(
        authorToUpdate,
        "",
        s => s.FirstName, s => s.LastName, s => s.BirthDate))
    {
        try
        {
            await _context.SaveChangesAsync();
            return RedirectToAction(nameof(Index));
        }
        catch (DbUpdateConcurrencyException ex)
        {
            var exceptionEntry = ex.Entries.Single();
            var clientValues = (Author)exceptionEntry.Entity;
            var databaseEntry = exceptionEntry.GetDatabaseValues();
            if (databaseEntry == null)
            {
                ModelState.AddModelError(string.Empty,
                    "Unable to save changes. The author was deleted by another user.");
            }
            else
            {
                var databaseValues = (Author)databaseEntry.ToObject();

                if (databaseValues.FirstName != clientValues.FirstName)
                {
                    ModelState.AddModelError("FirstName", $"Current value: {databaseValues.FirstName}");
                }
                if (databaseValues.LastName != clientValues.LastName)
                {
                    ModelState.AddModelError("LastName", $"Current value: {databaseValues.LastName}");
                }
                if (databaseValues.BirthDate != clientValues.BirthDate)
                {
                    ModelState.AddModelError("BirthDate", $"Current value: {databaseValues.BirthDate:d}");
                }

                ModelState.AddModelError(string.Empty, "The record you attempted to edit "
                        + "was modified by another user after you got the original value. The "
                        + "edit operation was canceled and the current values in the database "
                        + "have been displayed. If you still want to edit this record, click "
                        + "the Save button again. Otherwise click the Back to List hyperlink.");
                authorToUpdate.RowVersion = (byte[])databaseValues.RowVersion;
                ModelState.Remove("RowVersion");
            }
        }
    }
    
    return View(authorToUpdate);            
}
  • The above code also sets the RowVersion value of the Author object to the new value retrieved from the database.
  • The new RowVersion value will be stored in the hidden field when the Edit page is redisplayed, and the next time the user clicks Save, only concurrency errors that happen since the redisplay of the Edit page will be caught.

In Views\Author\Edit.cshtml, add a hidden field to save the RowVersion property value, immediately following the hidden field for the AuthorId property.

@model MvcWithEFCoreDemo.Models.Author

@{
    ViewData["Title"] = "Edit";
}

<h1>Edit</h1>

<h4>Author</h4>
<hr />
<div class="row">
    <div class="col-md-4">
        <form asp-action="Edit">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <input type="hidden" asp-for="AuthorId" />
            <input type="hidden" asp-for="RowVersion" />
            <div class="form-group">
                <label asp-for="FirstName" class="control-label"></label>
                <input asp-for="FirstName" class="form-control" />
                <span asp-validation-for="FirstName" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="LastName" class="control-label"></label>
                <input asp-for="LastName" class="form-control" />
                <span asp-validation-for="LastName" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="BirthDate" class="control-label"></label>
                <input asp-for="BirthDate" class="form-control" />
                <span asp-validation-for="BirthDate" class="text-danger"></span>
            </div>
            <div class="form-group">
                <input type="submit" value="Save" class="btn btn-primary" />
            </div>
        </form>
    </div>
</div>

<div>
    <a asp-action="Index">Back to List</a>
</div>

@section Scripts {
    @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

Let's run your application and click Authors tab, open the same author for editing in two different tabs.

  • The two tabs display the same information, so let's change a Birth Date field in the first browser tab and click Save.
  • The browser shows the Index page with the changed value.
  • Now change a Birth Date field in the second browser tab with different values and click Save.

You can see an error message, if you click Save again, the value you entered in the second browser tab is saved along with the original value of the data you changed in the first browser. You see the saved values when the Index page appears.