I have a Web API that has a method that has been working for several months without any issues (PurchaseController
). The list passed in was always around 100 or less. A few days ago, the volume spiked to around 600 items in the list which resulted in the app that passes the list getting a timeout instead of a success/failure response. It also seemed to make all other Web API methods run very slow until this method had finished running. So I modified the app code to break up the submissions into a configurable number of items at a time (currently set to 20 for the purpose of testing). I also modified the way the list was organized so that I could wrap each purchase in its own SQL transaction, in the hopes that this would allow other API methods to run without interruption.
However, once these changes were made, I ran into frequent errors like this when PurchaseController
was called:
Transaction (Process ID 62) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction.
The original PurchaseController
code:
public class PurchaseController : ControllerBase
{
/// <summary>
///
/// </summary>
/// <param name="Purchases"></param>
/// <returns></returns>
public JsonResult<APIResponse> Put([FromBody]CPurchases Purchases)
{
if (!ValidateCredentials(Request, this.ServiceName))
{
throw new HttpResponseException(HttpStatusCode.Unauthorized);
}
APIResponse response = new APIResponse();
try
{
using (var dbTransaction = Members.Database.BeginTransaction())
{
response = base.RecordEventRegistrations(Purchases.EventRegistrations);
if (!response.IsSuccess)
throw new Exception(response.Error);
response = base.RecordPublicationPurchases(Purchases.PublicationPurchases);
if (!response.IsSuccess)
throw new Exception(response.Error);
response = base.RecordSubscriptionPurchases(Purchases.SubscriptionPurchases);
if (!response.IsSuccess)
throw new Exception(response.Error);
response = base.RecordTransactions(Purchases.Transactions);
if (!response.IsSuccess)
throw new Exception(response.Error);
dbTransaction.Commit();
}
}
catch (Exception ex)
{
Utilities.Logging.LogToEventLog(ex);
response.IsSuccess = false;
response.Error = Utilities.Logging.GetInnermostException(ex);
}
return Json(response);
}
}
The new code that ran into deadlocks on virtually every call:
public class PurchaseController : ControllerBase
{
/// <summary>
/// </summary>
/// <param name="Purchases"></param>
/// <returns></returns>
public JsonResult<APIResponse> Put([FromBody]List<PurchaseDetails> Purchases)
{
if (!ValidateCredentials(Request, this.ServiceName))
{
throw new HttpResponseException(HttpStatusCode.Unauthorized);
}
APIResponse response = new APIResponse();
try
{
using (var dbTransaction = Members.Database.BeginTransaction())
{
foreach (var Purchase in Purchases)
{
response = base.RecordEventRegistrations(Purchase.EventRegistrations);
if (!response.IsSuccess)
throw new Exception(response.Error);
response = base.RecordPublicationPurchases(Purchase.PublicationPurchases);
if (!response.IsSuccess)
throw new Exception(response.Error);
response = base.RecordSubscriptionPurchases(Purchase.SubscriptionPurchases);
if (!response.IsSuccess)
throw new Exception(response.Error);
response = base.RecordTransactions(Purchase.PaymentTransactions);
if (!response.IsSuccess)
throw new Exception(response.Error);
dbTransaction.Commit();
}
}
}
catch (Exception ex)
{
Utilities.Logging.LogToEventLog(ex);
response.IsSuccess = false;
response.Error = Utilities.Logging.GetInnermostException(ex);
}
return Json(response);
}
}
I later moved the SQL transaction outside the foreach like this:
foreach (var Purchase in Purchases)
{
using (var dbTransaction = Members.Database.BeginTransaction())
{
and this made a huge difference in the frequency of deadlocks, but did not eliminate them. I also added System.Data.IsolationLevel.RepeatableRead
to the SQL transaction but it seemed to make no difference.
The code calling this method changed from:
public static APIResponse SavePurchases(IEnumerable<EventRegistration> Registrations, IEnumerable<PublicationPurchase> PublicationPurchases, IEnumerable<SubscriptionPurchase> SubscriptionPurchases, IEnumerable<PaymentTransactionModel> Transactions)
{
if (Registrations.Count() == 0 && PublicationPurchases.Count() == 0 && SubscriptionPurchases.Count() == 0 && Transactions.Count() == 0)
return new APIResponse() { IsSuccess = true };
CPurchases Purchases = new CPurchases()
{
EventRegistrations = Registrations,
PublicationPurchases = PublicationPurchases,
SubscriptionPurchases = SubscriptionPurchases,
Transactions = Transactions
};
return PutAPI("/Purchase", Purchases);
}
to
public static APIResponse SavePurchases(IEnumerable<PurchaseDetails> Purchases)
{
int PageSize = AppSettings.MaxNumberOfPurchasesPerAPIBatch;
APIResponse Response = new APIResponse() { IsSuccess = true };
List<PurchaseDetails> Page;
for (int i = 0; i < Purchases.Count(); i = i + PageSize)
{
Page = Purchases.Skip(i).Take(PageSize).ToList();
if (Page.Any())
{
Response = PutAPI("/Purchase", Page);
if (!Response.IsSuccess)
break;
}
}
return Response;
}
In addition, it is worth noting that the table the RecordEventRegistrations
modifies has a single trigger to enforce that the varchar event number is in all caps. This was put in place when the original code went live. And all database changes are done using Entity Framework 6.
No other code changes to the database, the app, or the Web API have been made in the past 2 weeks, so I'm pretty sure the problem is due to these code changes but I can't seem to figure out what or why.
UPDATE: I've been trying to get the deadlock graph for several hours but my account didn't have the correct permissions. Now that I have it I believe the problem is in the trigger, if I'm reading the graph properly. I've temporarily disabled the trigger and it seems to have resolved the deadlocks. But since the trigger has been unchanged for the past 6+ months, I'm guessing it has something to do with the volume of insert/updates over the last couple of days?
xml_report
<deadlock>
<victim-list>
<victimProcess id="process23368b65468"/>
</victim-list>
<process-list>
<process id="process23368b65468" taskpriority="0" logused="248" waitresource="PAGE: 6:1:84432 " waittime="3847" ownerId="22806353" transactionname="user_transaction" lasttranstarted="2020-12-30T10:33:09.433" XDES="0x2360aa86408" lockMode="U" schedulerid="4" kpid="3916" status="suspended" spid="54" sbid="2" ecid="0" priority="0" trancount="2" lastbatchstarted="2020-12-30T10:33:09.437" lastbatchcompleted="2020-12-30T10:33:09.420" lastattention="1900-01-01T00:00:00.420" clientapp="EntityFramework" hostname="***-API-01" hostpid="1400" loginname="sa" isolationlevel="read committed (2)" xactid="22806353" currentdb="6" lockTimeout="4294967295" clientoption1="671219744" clientoption2="128056">
<executionStack>
<frame procname="members.dbo.tr_event_registration" line="8" stmtstart="332" stmtend="584" sqlhandle="0x03000600e3d0825d1a48970094ac000000000000000000000000000000000000000000000000000000000000"> UPDATE [Event_Registration] SET event_number = UPPER(event_number) WHERE event_number in (select event_number from inserted </frame>
<frame procname="adhoc" line="1" stmtstart="156" stmtend="506" sqlhandle="0x0200000015ef0508db49eeb79e583b351130d3afdb0ebac40000000000000000000000000000000000000000"> unknown </frame>
<frame procname="unknown" line="1" sqlhandle="0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"> unknown </frame>
</executionStack>
<inputbuf> (@0 datetime2(7),@1 char(2),@2 datetime2(7),@3 datetime2(7),@4 int,@5 char(5))UPDATE [dbo].[Event_Registration] SET [registration_date] = @0, [source] = @1, [payment_date] = @2, [time_stamp] = @3 WHERE (([entity_number] = @4) AND ([event_number] = @5)) </inputbuf>
</process>
<process id="process233a1618ca8" taskpriority="0" logused="328" waitresource="PAGE: 6:1:1848653 " waittime="3824" ownerId="22806350" transactionname="UPDATE" lasttranstarted="2020-12-30T10:33:09.383" XDES="0x2360db58e58" lockMode="U" schedulerid="1" kpid="6096" status="suspended" spid="90" sbid="0" ecid="0" priority="0" trancount="2" lastbatchstarted="2020-12-30T10:33:09.380" lastbatchcompleted="2020-12-30T10:33:09.393" lastattention="1900-01-01T00:00:00.393" clientapp=".Net SqlClient Data Provider" hostname="***-MBR-01" hostpid="3936" loginname="sa" isolationlevel="read committed (2)" xactid="22806350" currentdb="6" lockTimeout="4294967295" clientoption1="671219744" clientoption2="128056">
<executionStack>
<frame procname="members.dbo.tr_event_registration" line="8" stmtstart="332" stmtend="584" sqlhandle="0x03000600e3d0825d1a48970094ac000000000000000000000000000000000000000000000000000000000000"> UPDATE [Event_Registration] SET event_number = UPPER(event_number) WHERE event_number in (select event_number from inserted </frame>
<frame procname="adhoc" line="1" stmtstart="714" stmtend="1696" sqlhandle="0x02000000f367b4079d5a8152b57beb111052747ccde62e3d0000000000000000000000000000000000000000"> unknown </frame>
<frame procname="unknown" line="1" sqlhandle="0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"> unknown </frame>
</executionStack>
<inputbuf> (@is_canceled bit,@source nvarchar(1),@event_number nvarchar(5),@entity_number int,@registration_date datetime,@cancel_date nvarchar(4000),@fee_code nvarchar(2),@amount_paid float,@pay_status nvarchar(1),@show_up nvarchar(1),@payment_date nvarchar(10),@payment_method nvarchar(6),@hours_override_reason nvarchar(4000),@quantity int,@user_stamp nvarchar(11))update event_registration set registration_date = @registration_date, cancel_date = @cancel_date, is_canceled = @is_canceled, fee_code = @fee_code, amount_paid = @amount_paid, pay_status = @pay_status, show_up = @show_up, payment_date = @payment_date, payment_method = @payment_method, hours_override_reason = @hours_override_reason, quantity = @quantity, user_stamp = @user_stamp, source = @source, time_stamp = getdate() where event_number = @event_number and entity_number = @entity_number; IF @@ROWCOUNT = 0 BEGIN insert into event_registration (event_number, entity_number, registration_date, cancel_date, is_canceled, fee_code, amount_paid, pay_status, show_up </inputbuf>
</process>
</process-list>
<resource-list>
<pagelock fileid="1" pageid="84432" dbid="6" subresource="FULL" objectname="members.dbo.Event_Registration" id="lock233a5ad4d00" mode="U" associatedObjectId="72057594463256576">
<owner-list>
<owner id="process233a1618ca8" mode="U"/>
</owner-list>
<waiter-list>
<waiter id="process23368b65468" mode="U" requestType="wait"/>
</waiter-list>
</pagelock>
<pagelock fileid="1" pageid="1848653" dbid="6" subresource="FULL" objectname="members.dbo.Event_Registration" id="lock233a5858d00" mode="IX" associatedObjectId="72057594463256576">
<owner-list>
<owner id="process23368b65468" mode="IX"/>
</owner-list>
<waiter-list>
<waiter id="process233a1618ca8" mode="U" requestType="wait"/>
</waiter-list>
</pagelock>
</resource-list>
</deadlock>
The trigger is:
ALTER Trigger [dbo].[tr_event_registration]
on [dbo].[Event_Registration]
AFTER insert, update
as
if ((trigger_nestlevel() > 1) or (@@rowcount = 0))
return
UPDATE [Event_Registration]
SET event_number = UPPER(event_number)
WHERE
event_number in (select event_number from inserted);