I'm trying to keep data consistency in my Azure Sql Server database and implemented two scenarios:
- check constraint
- insert/update trigger
None of them work and I still able to reproduce a situation when my check is bypassed. The rule is simple - there couldn't be more than one active assignment for a user
.
Tasks:
- Id
- UserId
- Status ('Active', 'Done')
User
- Id
- ...
Approach #1 - Check Constraints
I've implemented a function to ensure data consistency and applied it as a check constraint
create function [dbo].[fnCheckUserConcurrentAssignment]
(
@id nvarchar(128),
@status nvarchar(50), -- not used but required to check constraint
)
returns bit
as
begin
declare @result bit
select @result = cast(case when (select count(t.Id)
from dbo.Tasks t
where t.[UserId] = @id
and t.[Status != 'Done') > 1
then 1
else 0
end as bit)
return @result
end
alter table dbo.Tasks
with check add constraint [CK_CheckUserConcurrentAssignment]
check (dbo.fnCheckUserConcurrentAssignment([UserId], [Status]) = 0)
Approach #2 - Trigger
alter trigger dbo.[TR_CheckUserConcurrentAssignment]
on dbo.Tasks
for insert, update
as begin
if(exists(select conflictId from
(select (select top 1 t.Id from dbo.Tasks t
where t.UserId = i.UserId
and o.[Status] != N'Done'
and o.Id != i.Id) as conflictId
from inserted i
where i.UserId is not null) conflicts
where conflictId is not null))
begin
raiserror ('Concurrent user assignment detected.', 16, 1);
rollback;
end
end
If I create lots of assignments in parallel (regularly > 10) then some of them will be rejected by the constraint/trigger, other will be able to concurrently save UserId in the database. As a result by database data will be inconsistent.
I've verified both approaches in Management Studio and it prevents me from corrupting my data. I'm unable to assign multiple 'Active' tasks to a given user.
What is improtant to say, I'm using Entity Framework 6.x
at my Backend to save my data (SaveChangesAsync
) and every save action is executed in a separate transaction with default Transaction Isolation level ReadCommited
What could be wrong in my approaches and how to keep my data consistent?
It is a classic race condition.
If two threads run the
fnCheckUserConcurrentAssignment
function at the same time, they will get the samecount
fromTasks
. Then each thread will proceed with inserting the row and the final state of the database would violate your constraint.If you want to keep your approach with the function in the
CHECK
constraint, or the trigger, you should make sure that your transaction isolation level is set toSERIALIZABLE
. Or use query hints to lock the table. Or usesp_getapplock
to serialise calls to your function/trigger.In your case, the check is pretty simple, so it can be implemented without a trigger or function. I'd use a filtered unique index:
This unique index would guarantee that there is no two rows with
Status = 'Active'
which have the sameUserID
.There is a similar question on dba.se How are my SQL Server constraints being bypassed? with more detailed explanations. They mention another possible solution - indexed views, which boils down again to unique index.