In an approval workflow I want to ensure that reminder emails are sent exactly once.
With SqlCommand.ExecuteNonQuery I can ensure this by testing the return value. What is the recommended solution using EF? According to the documentation ObjectContext.SaveChanges does not return an equivalent value.
SqlCommand example: (The TransactionScope is used to rollback the DB update in case SendMail fails.)
Dim sql = "UPDATE LeaveApprovalRequests SET State = 'Reminded'" &
" WHERE ID=3 AND State <>'Reminded'"
Using scope As New TransactionScope
Using cnx As New SqlConnection(My.Settings.connectionString)
cnx.Open()
Dim cmd As New SqlCommand(sql, cnx)
If 1 = cmd.ExecuteNonQuery Then
SendMail()
End If
scope.Complete()
End Using
End Using
By enabling optimistic concurrency (using ConcurrencyMode=Fixed on a RowVersion property) and catching the OptimisticConcurrencyException I am able to identify if the object was actually updated in the store. Now the TransactionScope (used to rollback the DB update if SendMail fails) throws a deadlock error. Why?
Using scope As New TransactionScope
Using ctx As New ApprovalEntities
Try
Dim approval = ctx.LeaveApprovalRequests.
Where(Function(r) r.ID = 3 And r.State = "Created"
).FirstOrDefault
If approval Is Nothing Then
Console.WriteLine("not found")
Exit Sub
End If
Threading.Thread.Sleep(4000)
approval.State = "Reminded"
ctx.SaveChanges()
SendMail()
Catch ex As OptimisticConcurrencyException
Exit Try
End Try
End Using
scope.Complete()
End Using
Well, as a matter of fact the exact number of rows affected can be inferred from a call to ObjectContext.SaveChanges().
If you take a look at the ObjectContext.SaveChanges documentation you'll see:
public int SaveChanges()
(1) and (2) basically means that if your call to SaveChanges() has been successfully completed and you don't get any exception then EF guarantees that the return value exactly reflects the number of objects that has been modified.
Therefore, all you need to do is:
When Refresh with ClientWins is called, it executes a query to retrieve the current
values of this entity in the database, including the new timestamp. Therefore, all of the original field values have been updated to reflect the latest database values so we can safely try SaveChanges() one more time.
try {
// Try to save changes, which may cause a conflict.
int num = context.SaveChanges();
SendMail();
}
catch (OptimisticConcurrencyException) {
//Refresh the entity, using ClientWins
context.Refresh(RefreshMode.ClientWins, yourEntityObject);
//SaveChanges again;
context.SaveChanges();
}
Updated With Your Question:
The task is: only send an email if I am able to change the state from created to reminded. Thus it does not make sense to force through the SaveChanges when handling the OptimisticConcurrencyException. The error handler should exit if changing the state caused the exception and otherwise retry the whole task (reading and saving). How can I do this if optimistic concurreny is enabled via a RowVersion column and not by state only?
Well, each time a row is modified, the rowversion field is automatically updated, so in your case that would the best if you turn off Concurrency Mode on rowversion and turn it on, for the State property, so that your code will be as simple as:
try {
context.SaveChanges();
SendMail();
}
catch (OptimisticConcurrencyException) {
// RowVersion Concurrency Mode = Fixed
// State Concurrency Mode = None
// If it reaches here it means that the State has been changed;
// so you do nothing except than throwing the exception
throw;
}
But, if you would like to set Concurrency Mode = Fixed only for the rowversion property (like you mentioned), then it means that you can potentially get OptimisticConcurrencyException for change on any field including State, so it will be a little bit more work do the job:
try {
ctx.SaveChanges();
SendMail;
}
catch (OptimisticConcurrencyException) {
// RowVersion Concurrency Mode = Fixed
// State Concurrency Mode = None
// If it reches here it means that ANY/All field(s) has changed
// So we need to see if it was State:
ctx.Refresh(RefreshMode.ClientWins, approval);
ObjectStateEntry ose = ctx.ObjectStateManager.GetObjectStateEntry(approval);
string stateValue = ose.OriginalValues["State"].ToString();
// If the value is still "Created" then something else should have changed,
// And caused the exception, so we can proceed with the Save:
if (stateValue == "Created") {
ctx.SaveChanges();
SendMail;
}
else {
// Nope, it was state, so we throw the exception to the caller:
throw;
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With