How to avoid concurrent read access issue in SQL Server? - sql

I am getting a date value from a tblinfo table. If the date is null, then I will insert into table, else I will update the row in the table.
I am facing the issue that 2 sessions accessing the table simultaneously with same information, both are getting Null, so both are trying to insert records into same table with same primary key value. We are getting the primary key constraints issue. How to stop this?
begin tran
set #date=(select date from tblinfo where id=#id)
if(#date is null)
--insert
else
--update
commit tran

Using Locking
declare #Date DateTime
, #RowId bigint = 10
Begin Tran
select #Date = SomeDate
from tblInfo
with (RowLock) --this is the important point; during the transaction, this row will be locked, so other statements won't be able to read it
where Id = #RowId
if (#Date is Null)
begin
insert SomeTable (TblInfoId, Date)
values (#RowId, GetUtcDate())
end
else
begin
update SomeTable
set Date = #Date
where tblInfoId = #RowId
end
if ##TranCount > 0 Commit Tran
Technique Avoiding Locking
If preventing reads from occurring is an issue for some reason, the below is a lockless approach; but requires an extra field in your database.
declare #Date DateTime
, #RowId bigint = 10
, #DataAvailable bit
Begin Tran
update tblInfo
set session_lock = ##SPID
where Id = #RowId
and session_lock is null
select #Date = SomeDate
, #DataAvailable = 1
from tblInfo
where Id = #RowId
and session_lock = ##SPID
if (#DataAvailable = 1)
begin
if (#Date is Null)
begin
insert SomeTable (TblInfoId, Date)
values (#RowId, GetUtcDate())
end
else
begin
update SomeTable
set Date = #Date
where tblInfoId = #RowId
end
update tblInfo
set session_lock = null
where Id = #RowId
and session_lock = ##SPID
end
--optionally do something if the row was already "locked"
--else
--begin
--...
--end
if ##TranCount > 0 Commit Tran

Right after "begin tran", update your table like below script
begin tran
-- this update will lock the table so no other process can read data
update tblInfo with (tablock)
set date = date
---- do what ever you need to do here
set #date=(select * from tblinfo)
if(#date is null)
--insert
else
--update
commit tran
this will cause SQL Server lock the table and the second transaction will wait reading the data until the first process finish.

Relying on lock hints to get the desired outcome will perhaps prevent you from attempting to insert the value twice, but it won't prevent errors from happening. You'll just get an 'Unable to acquire lock' or deadlock error instead.
You should put a mutex in your code, in the minimally sized critical section. Using sp_getApplock, you can get a lock on a section of code with a wait period that you specify (subsequent threads will wait for the lock to clear and then continue). Here's some sample code:
declare #LockResource nvarchar(100) = 'VALUE_INSERT'
begin transaction
-- Fetch the lock:
EXEC #res = sp_getapplock
#Resource = #LockResource,
#LockMode = 'Exclusive',
#LockOwner = 'Transaction',
#LockTimeout = 10000,
#DbPrincipal = 'public'
if #res not in (0, 1)
begin
set #msg = 'Unable to acquire Lock for ' + #LockResource
raiserror (#msg , 16, 1)
rollback transaction
return
end
else
begin
begin try
---------------------------------------------
-- Fetch value if it exists, insert if not.
-- Both need to happen here.
---------------------------------------------
end try
begin catch
select #msg = 'An error occurred: ' + ERROR_MESSAGE()
-- Release the lock.
EXEC #res = sp_releaseapplock
#Resource = #LockResource,
#DbPrincipal = 'public',
#LockOwner = 'Transaction'
rollback transaction
raiserror(#msg, 11, 1)
goto cleanup
end catch
-- Release the lock.
EXEC #res = sp_releaseapplock
#Resource = #LockResource,
#DbPrincipal = 'public',
#LockOwner = 'Transaction'
end
commit transaction

Related

SQL: Set variable to a cell value inside a stored procedure

I am trying to store a single cell value from one table in a variable (inside a stored procedure), so I can use it to edit a value in another table, but I keep getting a MSG 201:
Procedure or function 'spBookReturn' expects parameter '#bookID', which was not supplied.
Every time I try to run the sp where it all should happen:
CREATE PROC spBookReturn
#loanID UNIQUEIDENTIFIER,
#bookID UNIQUEIDENTIFIER OUTPUT
AS
BEGIN
BEGIN TRANSACTION tBookReturn
UPDATE BorrowedMaterial SET returned = 1, returnedDate = GETDATE();
SET #bookID = (SELECT TOP 1 bookID FROM BorrowedMaterial WHERE loanID = #loanID ORBER BY returnedDate);
UPDATE Books SET nHome = nHome + 1 WHERE ID = #bookID;
COMMIT TRANSACTION tBookReturn;
END;
EXEC spBookReturn '546A444A-3D8D-412E-876D-2053D575B54F'
Does anyone know why the way I have defined the #bookID variable doesn't work and how I can make it work?
Thanks.
EDIT:
I got two tables: BorrowedMaterial that includes the attributes loanID, bookID, returned, returnedDate and a few others that's not relevant.
The other table is Books and it includes bookID, nHome but not the loanID.
So by giving only the loanID as an input, I would like to update the nHome. I am trying to grab bookID since it is the only thing the two attributes got in common and this is where the issues happen.
Side note: I removed the variable #custID it spawned by mistake.
All parameters for a procedure are Input parameters. If you declare a parameter as an OUTPUT parameter, it is still an input one, and if it doesn't have a default value must be supplied.
If you want the OUTPUT parameters to be option, which I personally find can be quite often, then give them a default value. I also add some additional logic to your procedure, as you should be using an TRY...CATCH and an ORDER BY in your query with a TOP.
CREATE PROC dbo.spBookReturn #loanID uniqueidentifier,
#bookID uniqueidentifier = NULL OUTPUT,
#custID uniqueidentifier = NULL OUTPUT
AS
BEGIN
BEGIN TRY --If you are using tranasctions, make sure you have a ROLLBACK and THROW for errors
BEGIN TRANSACTION tBookReturn
UPDATE BorrowedMaterial
SET returned = 1,
returnedDate = GETDATE()
WHERE loanID = #loanID;
/*
UPDATE BorrowedMaterial
SET returnedDate = GETDATE()
WHERE loanID = #loanID;
*/
SET #bookID = (SELECT TOP 1 bookID
FROM BorrowedMaterial
WHERE loanID = #loanID
ORDER BY ???); --A TOP should have an ORDER BY
UPDATE Books
SET nHome = nHome + 1
WHERE ID = #bookID;
COMMIT TRANSACTION tBookReturn;
END TRY
BEGIN CATCH
ROLLBACK TRANSACTION tBookReturn;
THROW;
END CATCH;
END;
Then you can execute the procedure as you have, without #bookID and #custID being passed. Of course, if you don't, their values will be "lost" in the calling statement. If you need them, then pass their values too in the EXEC:
DECLARE #bookID uniqueidentifier, #CustID uniqueidentifier;
EXEC dbo.spBookReturn #loanID, #bookID OUTPUT, #CustID OUTPUT;
--SELECT #bookID, #CustID;
declare #bookID UNIQUEIDENTIFIER;
declare #custID UNIQUEIDENTIFIER;
declare #InResult;
exec #InResult= spBookReturn '546A444A-3D8D-412E-876D-2053D575B54F',#bookID,#custID;
select #bookID,#custID;
Seems, you need something like this
My take on what you're looking for. Because there are multiple DML statements (2 Updates) in a single transaction the XACT_ABORT ON option ensures a complete rollback. Also, the THROW in the CATCH block is placed before the ROLLBACK to preserve the SQL generated error metadata. Prior to executing the proc the OUTPUT variables are declared and placed in the parameter list (this omission is what was causing the initial error).
drop proc if exists dbo.spBookReturn;
go
CREATE PROC dbo.spBookReturn
#loanID uniqueidentifier,
#bookID uniqueidentifier OUTPUT,
#custID uniqueidentifier OUTPUT
AS
set nocount on;
set xact_abort on;
BEGIN TRANSACTION tBookReturn
BEGIN TRY
declare #ins_bm table(book_id uniqueidentifier,
cust_id uniqueidentifier);
UPDATE BorrowedMaterial
SET returned = 1,
returnedDate = GETDATE()
output inserted.book_id, inserted.cust_id into #ins_bm
WHERE loanID = #loanID;
if ##rowcount=0
begin
select #bookID=0,
#custID=0;
throw 50000, 'No update performed', 1;
end
else
begin
UPDATE b
SET nHome = nHome + 1
from Books b
WHERE exists (select 1
from #ins_bm bm
where b.ID = bm.book_id);
select top(1) #bookID=book_id,
#custID=cust_id
from #ins_bm;
end
COMMIT TRANSACTION tBookReturn;
END TRY
BEGIN CATCH
THROW
ROLLBACK TRANSACTION tBookReturn;
END CATCH;
go
declare #bookID UNIQUEIDENTIFIER;
declare #custID UNIQUEIDENTIFIER;
declare #InResult int;
exec #InResult=spBookReturn '546A444A-3D8D-412E-876D-2053D575B54F', #bookID output, #custID output;
select #bookID, #custID;
Thanks to #Larnu I figured out the only thing missing was a = on line 3 (and thanks to #charlieface I also got my code cleaned up a tiny bit):
CREATE PROC spBookReturn
#loanID UNIQUEIDENTIFIER,
#bookID UNIQUEIDENTIFIER = NULL OUTPUT
AS
BEGIN
BEGIN TRANSACTION tBookReturn
UPDATE BorrowedMaterial SET returned = 1, returnedDate = GETDATE();
SET #bookID = (SELECT TOP 1 bookID FROM BorrowedMaterial WHERE loanID = #loanID
ORDER BY returnedDate);
UPDATE Books SET nHome = nHome + 1 WHERE ID = #bookID;
COMMIT TRANSACTION tBookReturn;
END;
EXEC spBookReturn '546A444A-3D8D-412E-876D-2053D575B54F'

Performance Testing, Inserting batch of records, many insert queries, 20 insert/update in all, Is cursor, while loop, or transaction better

I'm synchronizing our crm and erp. I'll have a batch hundreds of records from our crm to insert every 15 minutes, into our erp about 20 tables each. I want to insert one record at at time and log any errors if a insert fails on any of the 20 tables and reverse the insert for that record. I can see how to insert one record at a time with a cursor, or while loop. How would I insert one record at a time and log errors using transactions?
--Transaction method
--BEGIN TRANS
--partial code list of cursor method, that is working
declare #cur cursor;
declare #x nvarchar(9);
begin
set #cur = cursor for
select people_id from powercampustest.dbo.PeopleChanges where (processed is null)
open #cur
fetch next from #cur
into #x
while ##FETCH_STATUS = 0
begin
/* add the rest here */
begin try
--20 insert queries
end try
begin catch
--transaction method
--ROLLBACK
print 'Error on people id ' + #x
--cursor method
--if one insert fails delete all inserted
--do we want error out on first fail, or try all and report if each failed or not
--TableListForDelete, list of tables for delete
--write error in log table
end catch
fetch next from #cur
into #x
end;
close #cur;
deallocate #cur;
end;
===========================================================
--partial code list of while method
select #loop = ##rowcount
if #loop <= 0 Return
Set #PersonId = 0
Select #PersonId =
(Select Min([PersonId]) From inserted Where [PersonId] > #PersonId)
While #PersonId Is Not Null
begin
SELECT ...
--Get Next Id
Select #PersonId =
(Select Min( [PersonId] ) From inserted Where [PersonId] > #PersonId)
END
if ##error <> 0
begin
raiserror( 1944115471, 16, 65, #MessageType ) with nowait;
rollback tran;
end
end
use this:
set xact_abort on;
begin tran
--all your inserts here
commit tran
it will do everything if there is no error or nothing if there is one.
Cursors are evil, do not use them. Loops are not needed for this as well.

Stored procedure structure and concurrency issues

Someone wrote the following stored procedure in SQL Server 2008. I think it's just not one the best ways to achieve what he needed to achieve.
As you can see that we need to update MyTable under some conditions but not under the other. And whenever there is a condition that lets a user update MyTable we don't want multiple users to update MyTable concurrently. The thing is the way this procedure is written all the variables are set using select statements and then Begin Transaction starts.
But given the structure of this procedure is it better to execute an sp_getapplock procedure to allow just one user to do the update (that might work well with this procedure given its structure but I read all over that sp_getapplock can lead to deadlocks) and the other options is to do a serializable READ_COMMITED.
But in case of serializable wouldn't this stored procedure allow one of the concurrent user (out of say 2) still select the old values of col1, col2, col3 and col4 and populate variables like #onGoingSession, #timeDiff etc with those old values when the other done updating and released the row (through serializable).Since after the transaction completion it's the turn of second user. And I do wanna block other users from reading the row that is being modified.
Shouldn't we move the begin transaction up right after declaring the variables and the do a serializable after it. So that no concurrent users should read old values and populate the variables while one of the users (apparently who already acquired the row lock) is updating the locked row but hasn't committed as yet.
USE [myDatabase]
GO
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE PROCEDURE [dbo].[MyTableProc]
(#UserId char(20))
AS
BEGIN
DECLARE #error varchar(200)
DECLARE #warning varchar(300)
DECLARE #timeDiff smallint
DECLARE #onGoingSession bit = 1
DECLARE #userName char(20)
DECLARE #counterOfResync tinyint
SET #counterOfResync = (SELECT [Col1] FROM MyTable)
SET #userName = (SELECT [Col2] FROM MyTable)
SET #onGoingSession = (SELECT [Col3] FROM MyTable)
SET #timeDiff = (SELECT DATEDIFF(MINUTE, Col4, CURRENT_TIMESTAMP) from MyTable)
BEGIN TRANSACTION
IF(#onGoingSession = 1)
BEGIN
IF(#timeDiff >= 360)
BEGIN
UPDATE MyTable
SET UserId = #UserId, Col4 = CURRENT_TIMESTAMP
IF(##ERROR = 0)
BEGIN
SET #warning = RTRIM('An unfinsihed session for ' +
LTRIM(RTRIM(#userName)) + ' is going on for the past ' +
LTRIM(RTRIM(#timeDiff)) + ' minutes but updates from ' + LTRIM(RTRIM(#UserId)) + ' are successful')
COMMIT
RAISERROR(#warning,7, 1)
RETURN
END
ELSE
BEGIN
ROLLBACK
RETURN ##ERROR
END
END
ELSE
BEGIN
SET #error = RTRIM('A session of updates for '+ LTRIM(RTRIM(#userName))+ ' is already in progress concurrent updates are not allowed')
IF(##ERROR = 0)
BEGIN
COMMIT
RAISERROR(#error, 8, 1)
RETURN
END
ELSE
BEGIN
ROLLBACK
RETURN ##ERROR
END
END
END
ELSE IF(#onGoingSession = 0 AND #counterOfResync = 0)
BEGIN
UPDATE MyTable
SET UserId = #UserId, Col3 = 1, Col4 = CURRENT_TIMESTAMP
IF(##ERROR =0)
BEGIN
COMMIT
RETURN
END
ELSE
BEGIN
ROLLBACK
RETURN ##ERROR
END
END
ELSE IF(#onGoingSession = 0 AND #counterOfResync > 0 AND #timeDiff >= 5)
BEGIN
UPDATE MyTable
SET Col3 = 1, CountOfResync = 0, UserId = #UserId, Col4 =
CURRENT_TIMESTAMP
IF(##ERROR = 0)
BEGIN
COMMIT
RETURN
END
ELSE
BEGIN
ROLLBACK
RETURN ##ERROR
END
END
ELSE
BEGIN
SET #error = RTRIM('A server resync session already in progress, updates can''t be made at the same time')
IF(##ERROR = 0)
BEGIN
COMMIT
RAISERROR(#error, 9, 1)
RETURN
END
ELSE
BEGIN
ROLLBACK
RETURN ##ERROR
END
END
RETURN ##ERROR
END
Something like the following would reduce the transaction to a single update statement with the logic to update individual columns handled by case expressions. Post processing handles any errors and generating the curious variety of results expected.
create procedure dbo.MyTableProc( #UserId as Char(20) )
as
begin
declare #CounterOfResync as TinyInt;
declare #Error as Int;
declare #ErrorMessage as VarChar(200);
declare #OngoingSession as Bit = 1;
declare #PriorUsername as Char(20);
declare #TimeDiff as SmallInt;
declare #WarningMessage as VarChar(300);
update MyTable
set
#CounterOfResync = Col1,
#PriorUsername = UserId,
#OngoingSession = Col3,
#TimeDiff = DateDiff( minute, Col4, Current_Timestamp ),
UserId = #UserId,
Col2 = case when Col3 = 0 and Col1 > 0 and DateDiff( minute, Col4, Current_Timestamp ) >= 5 then 1 else Col2 end,
Col3 = case when Col1 = 0 and Col3 = 0 then 1 else Col3 end,
Col4 = Current_Timestamp
set #Error = ##Error;
if ( #CounterOfResync = 1 )
if ( #TimeDiff >= 360 )
begin
if ( #Error = 0 )
begin
set #WarningMessage = 'An unfinished session for user ' + #PriorUsername + ' is going on for the past ' +
Cast( #TimeDiff as VarChar(10) ) + ' minutes but updates from ' + #UserId + ' are successful';
RaIsError( #WarningMessage, 7, 1 );
end
else
return #Error;
end
else
begin
if ( #Error = 0 )
begin
set #ErrorMessage = 'A session of updates for '+ #PriorUsername + ' is already in progress concurrent updates are not allowed';
RaIsError( #ErrorMessage, 8, 1 );
end
else
return #Error;
end
else
if ( #OngoingSession = 0 and #CounterOfResync = 0 )
return #Error
else
-- ...
Apologies for any errors in trying to wade through the existing code and translate it into something stranger. My intent is to provide a (mis)direction that you may choose to follow, not the completed code.

Creating an Instead of insert Trigger SQL

I am a DBA with my company. I am trying to create trigger that will check any insert statement for duplicates first then if none allow the original insert. Not even sure this can be done. The insert statements may be written by various users so the statements will never be the same. All I have found so far is the check for duplicates but the insert statement is then hard coded in the trigger. My plan is also to check update as well, but it is not important right now.
Here is my current code.
ALTER TRIGGER [dbo].[BlockDuplicatesOnTable]
ON [dbo].[blockduplicates]
Instead of INSERT, Update
AS
BEGIN
SET NOCOUNT ON;
Declare #ProviderAcctNumber nvarchar(50)
Declare #Referredby int
Declare #Action as char(1)
Declare #Count as int
Set #Action = 'I'
Select #Count = Count(*) from DELETED
IF #Count > 0
Begin
Set #Action = 'D'
Select #Count = count(*) from INSERTED
IF #Count > 0
Set #Action = 'U'
IF #Action = 'I'
Begin
IF not exists (Select 1 from inserted as i
inner join dbo.blockduplicates as b
on i.ProviderAcctNumber = b.ProviderAcctNumber
and i.Referredby = b.Referredby)
Begin
--execute original insert
End
Else
Begin
Print 'Duplicate insert'
Raiserror ('Duplicate Entry for Insert',16,1);
Return
End
End
Else IF #Action = 'U'
Begin
Select #ProviderAcctNumber = ProviderAcctNumber, #Referredby = Referredby from inserted
IF Not exists (Select 1 from deleted where ProviderAcctNumber = #ProviderAcctNumber and Referredby = #Referredby)
Begin
Print 'Update Statement is True';
End
Else
Begin
Print 'duplicate'
Raiserror ('Duplicate Entry for Update',16,1);
Return
End
End
End
End;

SQL Stored Procedure not handling error from nested stored procedure

I have two stored procedures, one nested inside the other. When the nested stored procedure is called, at the moment, it should error with a foreign key constraint violation and then rollback the earlier call to insert into the ProductLicense table. The nested procedure does not perform any action on the database because of the foreign key violation but the calling stored procedure isn't catching the error and rolling back. If I execute the nested stored procedure by itself it does return error 547 Foreign key violation.
How can I get the two stored procedures to work together?
Outer procedure:
ALTER PROCEDURE [dbo].[AddNewLicense2_i]
-- Add the parameters for the stored procedure here
#customerId nvarchar(10),
#licenseModeId int,
#licenseModeProgramId int,
#createdBy int,
#updateBy int,
#systemId nvarchar(50),
#productId int
AS
BEGIN TRY
BEGIN TRANSACTION
-- SET NOCOUNT ON added to prevent extra result sets from
-- interfering with SELECT statements.
SET NOCOUNT ON;
--SET XACT_ABORT ON; --used for automatic rollback when an error occurs
DECLARE #tempDays INT
DECLARE #programCornerAmt INT
DECLARE #tempEndDate DATETIME
DECLARE #tempExpDate DATETIME
DECLARE #err INT
SET #err = 0
/*SET #tempDays = (SELECT lmp.TimeoutDays
FROM LicenseModeProgram lmp
WHERE lmp.LicenseModeProgramId = #licenseModeProgramId)*/
SELECT #tempDays = TimeoutDays, #programCornerAmt = MonthlyCornersAmount
FROM LicenseModeProgram
WHERE LicenseModeProgramId = #licenseModeProgramId
--Build Expiration and End Dates.
IF #tempDays = NULL --then this is NOT a time rental or metered system
BEGIN
SET #tempEndDate = NULL
SET #tempExpDate = NULL
END
ELSE
BEGIN
SET #tempEndDate = DATEADD("d", #tempDays, GETDATE())
SET #tempExpDate = DATEADD("d", #tempDays, GETDATE())
END
-- Create new product license record
INSERT INTO ProductLicense (CustomerId, LicenseModeId, LicenseModeProgramId, CreatedBy, UpdatedBy, SystemId, ProductId, ExpirationDate, LicenseEndDate)
VALUES (#customerId, #licenseModeId, #licenseModeProgramId, #createdBy, #updateBy, #systemId, #productId, #tempExpDate, #tempEndDate)
IF #licenseModeId = 4 AND #systemId NULL AND #programCornerAmt NULL
--call stored procedure to add corners to the customer account
EXECUTE #err = AddMeteredTx_i #systemId, 1, 1, #programCornerAmt , 'Initial License Creation'
PRINT #err
COMMIT TRANSACTION
END TRY
BEGIN CATCH
RAISERROR('Failed to Create License', 11, 2)
ROLLBACK TRANSACTION
RETURN 1
END CATCH
--COMMIT TRANSACTION
RETURN 0
GO
Inner procedure:
ALTER PROCEDURE [dbo].[AddMeteredTx_i]
-- Add the parameters for the stored procedure here
#systemId nvarchar(50),
#activityEventId int,
#createdBy int,
#amount int,
#notes text
AS
BEGIN TRY
BEGIN TRANSACTION
-- SET NOCOUNT ON added to prevent extra result sets from
-- interfering with SELECT statements.
SET NOCOUNT ON;
--SET XACT_ABORT ON; --used for automatic rollback when an error occurs
INSERT INTO CustomerAccountActivity (SystemId, ActivityEventId, CreatedBy, Amount, Notes)
VALUES (#systemId, #activityEventId, #createdBy, #amount, #notes)
UPDATE CustomerAccount
SET MeteredBalance = (SELECT MeteredBalance FROM CustomerAccount WHERE SystemId = #systemId) + #amount
WHERE SystemId = #systemId
COMMIT TRANSACTION
END TRY
BEGIN CATCH
RAISERROR('Error Update to Customer Account Record ', 11, 2)
ROLLBACK TRANSACTION
RETURN 1
--COMMIT TRANSACTION
END CATCH
RETURN 0
GO
Catching errors with a call stack like this using ##Error can be problematic. It's a lot more reliable to use TRY/CATCH
The basic format is:
BEGIN TRY
<BEGIN TRAN>
... do stuff ...
<COMMIT TRAN>
END TRY
BEGIN CATCH
<ROLLBACK TRAN>
... do error stuff like re-raise the error to outer scope ...
END CATCH
Any error encountered in the try will automatically take you to the CATCH block without additional checking.