15 KiB
title | author | description | ms.author | ms.custom | ms.date | uid |
---|---|---|---|---|---|---|
Razor Pages with EF Core in ASP.NET Core - Concurrency - 8 of 8 | rick-anderson | This tutorial shows how to handle conflicts when multiple users update the same entity at the same time. | riande | mvc | 12/07/2018 | data/ef-rp/concurrency |
Razor Pages with EF Core in ASP.NET Core - Concurrency - 8 of 8
By Rick Anderson, Tom Dykstra, and Jon P Smith
[!INCLUDE about the series]
This tutorial shows how to handle conflicts when multiple users update an entity concurrently (at the same time). If you run into problems you can't solve, download or view the completed app. Download instructions.
Concurrency conflicts
A concurrency conflict occurs when:
- A user navigates to the edit page for an entity.
- Another user updates the same entity before the first user's change is written to the DB.
If concurrency detection isn't enabled, when concurrent updates occur:
- The last update wins. That is, the last update values are saved to the DB.
- The first of the current updates are lost.
Optimistic concurrency
Optimistic concurrency allows concurrency conflicts to happen, and then reacts appropriately when they do. For example, Jane visits the Department edit page and changes the budget for the English department from $350,000.00 to $0.00.
Before Jane clicks Save, John visits the same page and changes the Start Date field from 9/1/2007 to 9/1/2013.
Jane clicks Save first and sees her change when the browser displays the Index page.
John clicks Save on an Edit page that still shows a budget of $350,000.00. What happens next is determined by how you handle concurrency conflicts.
Optimistic concurrency includes the following options:
-
You can keep track of which property a user has modified and update only the corresponding columns in the DB.
In the scenario, no data would be lost. Different properties were updated by the two users. The next time someone browses the English department, they will see both Jane's and John's changes. This method of updating can reduce the number of conflicts that could result in data loss. This approach:
- Can't avoid data loss if competing changes are made to the same property.
- Is generally not practical in a web app. It requires maintaining significant state in order to keep track of all fetched values and new values. Maintaining large amounts of state can affect app performance.
- Can increase app complexity compared to concurrency detection on an entity.
-
You can let John's change overwrite Jane's change.
The next time someone browses the English department, they will see 9/1/2013 and the fetched $350,000.00 value. This approach is called a Client Wins or Last in Wins scenario. (All values from the client take precedence over what's in the data store.) If you don't do any coding for concurrency handling, Client Wins happens automatically.
-
You can prevent John's change from being updated in the DB. Typically, the app would:
- Display an error message.
- Show the current state of the data.
- Allow the user to reapply the changes.
This is called a Store Wins scenario. (The data-store values take precedence over the values submitted by the client.) You implement the Store Wins scenario in this tutorial. This method ensures that no changes are overwritten without a user being alerted.
Handling concurrency
When a property is configured as a concurrency token:
- EF Core verifies that property has not been modified after it was fetched. The check occurs when SaveChanges or SaveChangesAsync is called.
- If the property has been changed after it was fetched, a DbUpdateConcurrencyException is thrown.
The DB and data model must be configured to support throwing DbUpdateConcurrencyException
.
Detecting concurrency conflicts on a property
Concurrency conflicts can be detected at the property level with the ConcurrencyCheck attribute. The attribute can be applied to multiple properties on the model. For more information, see Data Annotations-ConcurrencyCheck.
The [ConcurrencyCheck]
attribute isn't used in this tutorial.
Detecting concurrency conflicts on a row
To detect concurrency conflicts, a rowversion tracking column is added to the model. rowversion
:
- Is SQL Server specific. Other databases may not provide a similar feature.
- Is used to determine that an entity has not been changed since it was fetched from the DB.
The DB generates a sequential rowversion
number that's incremented each time the row is updated. In an Update
or Delete
command, the Where
clause includes the fetched value of rowversion
. If the row being updated has changed:
rowversion
doesn't match the fetched value.- The
Update
orDelete
commands don't find a row because theWhere
clause includes the fetchedrowversion
. - A
DbUpdateConcurrencyException
is thrown.
In EF Core, when no rows have been updated by an Update
or Delete
command, a concurrency exception is thrown.
Add a tracking property to the Department entity
In Models/Department.cs, add a tracking property named RowVersion:
The Timestamp attribute specifies that this column is included in the Where
clause of Update
and Delete
commands. The attribute is called Timestamp
because previous versions of SQL Server used a SQL timestamp
data type before the SQL rowversion
type replaced it.
The fluent API can also specify the tracking property:
modelBuilder.Entity<Department>()
.Property<byte[]>("RowVersion")
.IsRowVersion();
The following code shows a portion of the T-SQL generated by EF Core when the Department name is updated:
The preceding highlighted code shows the WHERE
clause containing RowVersion
. If the DB RowVersion
doesn't equal the RowVersion
parameter (@p2
), no rows are updated.
The following highlighted code shows the T-SQL that verifies exactly one row was updated:
@@ROWCOUNT returns the number of rows affected by the last statement. In no rows are updated, EF Core throws a DbUpdateConcurrencyException
.
You can see the T-SQL EF Core generates in the output window of Visual Studio.
Update the DB
Adding the RowVersion
property changes the DB model, which requires a migration.
Build the project. Enter the following in a command window:
dotnet ef migrations add RowVersion
dotnet ef database update
The preceding commands:
-
Adds the Migrations/{time stamp}_RowVersion.cs migration file.
-
Updates the Migrations/SchoolContextModelSnapshot.cs file. The update adds the following highlighted code to the
BuildModel
method: -
Runs migrations to update the DB.
Scaffold the Departments model
Visual Studio
Follow the instructions in Scaffold the student model and use Department
for the model class.
.NET Core CLI
Run the following command:
dotnet aspnet-codegenerator razorpage -m Department -dc SchoolContext -udl -outDir Pages\Departments --referenceScriptLibraries
The preceding command scaffolds the Department
model. Open the project in Visual Studio.
Build the project.
Update the Departments Index page
The scaffolding engine created a RowVersion
column for the Index page, but that field shouldn't be displayed. In this tutorial, the last byte of the RowVersion
is displayed to help understand concurrency. The last byte isn't guaranteed to be unique. A real app wouldn't display RowVersion
or the last byte of RowVersion
.
Update the Index page:
- Replace Index with Departments.
- Replace the markup containing
RowVersion
with the last byte ofRowVersion
. - Replace FirstMidName with FullName.
The following markup shows the updated page:
Update the Edit page model
Update pages\departments\edit.cshtml.cs with the following code:
To detect a concurrency issue, the OriginalValue is updated with the rowVersion
value from the entity it was fetched. EF Core generates a SQL UPDATE command with a WHERE clause containing the original RowVersion
value. If no rows are affected by the UPDATE command (no rows have the original RowVersion
value), a DbUpdateConcurrencyException
exception is thrown.
In the preceding code, Department.RowVersion
is the value when the entity was fetched. OriginalValue
is the value in the DB when FirstOrDefaultAsync
was called in this method.
The following code gets the client values (the values posted to this method) and the DB values:
The following code adds a custom error message for each column that has DB values different from what was posted to OnPostAsync
:
The following highlighted code sets the RowVersion
value to the new value retrieved from the DB. The next time the user clicks Save, only concurrency errors that happen since the last display of the Edit page will be caught.
The ModelState.Remove
statement is required because ModelState
has the old RowVersion
value. In the Razor Page, the ModelState
value for a field takes precedence over the model property values when both are present.
Update the Edit page
Update Pages/Departments/Edit.cshtml with the following markup:
The preceding markup:
- Updates the
page
directive from@page
to@page "{id:int}"
. - Adds a hidden row version.
RowVersion
must be added so post back binds the value. - Displays the last byte of
RowVersion
for debugging purposes. - Replaces
ViewData
with the strongly-typedInstructorNameSL
.
Test concurrency conflicts with the Edit page
Open two browsers instances of Edit on the English department:
- Run the app and select Departments.
- Right-click the Edit hyperlink for the English department and select Open in new tab.
- In the first tab, click the Edit hyperlink for the English department.
The two browser tabs display the same information.
Change the name in the first browser tab and click Save.
The browser shows the Index page with the changed value and updated rowVersion indicator. Note the updated rowVersion indicator, it's displayed on the second postback in the other tab.
Change a different field in the second browser tab.
Click Save. You see error messages for all fields that don't match the DB values:
This browser window didn't intend to change the Name field. Copy and paste the current value (Languages) into the Name field. Tab out. Client-side validation removes the error message.
Click Save again. The value you entered in the second browser tab is saved. You see the saved values in the Index page.
Update the Delete page
Update the Delete page model with the following code:
The Delete page detects concurrency conflicts when the entity has changed after it was fetched. Department.RowVersion
is the row version when the entity was fetched. When EF Core creates the SQL DELETE command, it includes a WHERE clause with RowVersion
. If the SQL DELETE command results in zero rows affected:
- The
RowVersion
in the SQL DELETE command doesn't matchRowVersion
in the DB. - A DbUpdateConcurrencyException exception is thrown.
OnGetAsync
is called with theconcurrencyError
.
Update the Delete page
Update Pages/Departments/Delete.cshtml with the following code:
The preceding markup makes the following changes:
- Updates the
page
directive from@page
to@page "{id:int}"
. - Adds an error message.
- Replaces FirstMidName with FullName in the Administrator field.
- Changes
RowVersion
to display the last byte. - Adds a hidden row version.
RowVersion
must be added so post back binds the value.
Test concurrency conflicts with the Delete page
Create a test department.
Open two browsers instances of Delete on the test department:
- Run the app and select Departments.
- Right-click the Delete hyperlink for the test department and select Open in new tab.
- Click the Edit hyperlink for the test department.
The two browser tabs display the same information.
Change the budget in the first browser tab and click Save.
The browser shows the Index page with the changed value and updated rowVersion indicator. Note the updated rowVersion indicator, it's displayed on the second postback in the other tab.
Delete the test department from the second tab. A concurrency error is display with the current values from the DB. Clicking Delete deletes the entity, unless RowVersion
has been updated.department has been deleted.
See Inheritance on how to inherit a data model.
Additional resources
[!div class="step-by-step"] Previous