1

I have a transaction where

  1. Transaction start
  2. I run an update on a row with a given id that is marked currently active and mark it inactive
  3. Add a new row with a audit_id from the above updated row
  4. Commit the transaction.

However, when two concurrent transactions ran, ended up with a scenario where #2 happened twice on the same row and #3 got executed twice resulting in two extra rows being inserted instead of just 1.

TLDR; No two rows should have same audit ids. If transactions ran as intended, row 3 should have never appeared.

id x_id is_active audit_id. status
1.  9     false.    null     inprogress
2.  9     true       1        done
3.  9     true       1        done

How do I avoid this?

I was looking at ROWLOCKS, UPDATELOCKS and HOLDLOCKs

For the above scenario, looks like I need a HOLDLOCK..

  1. Transaction begin
  2. Select id from tableA where x_id = 1 and is_active = true and status = inprogress with (HOLDLOCK)
  3. Update is_active = false where id = (id from #2)
  4. Insert into tableA x_id=1, is_active = true, audit_id = (id from #2), status = done
  5. Commit transaction

Is the above right? According to what I understand, if I use UPDLOCK, say in another concurrent transaction #2 was already executed, both transactions will end up executing #3, not at once but one after the other in overlapping transactions and I'll end up with two rows having same audit_id and status = done.

Hemanth Gowda
  • 604
  • 4
  • 16
  • 1
    i think your original code together with UPDLOCK should do the trick, but easiest to test is to stress test your system with a lot of calls – siggemannen Apr 26 '23 at 06:55
  • 1
    The HOLDLOCK hint will maintain the lock until the transaction is completed, preventing subsequent transactions from making changes to the same row until the current transaction is committed or rolled back. As a result, the audit_id will be guaranteed to be distinct and just one row will be entered. – Pratik Lad Apr 26 '23 at 07:00
  • If you do update + output inserted.id, you should be able to close the window for other transactions to do anything with your row – siggemannen Apr 26 '23 at 08:05
  • Post your *actual* code. `UPDATE` is atomic so an `UPDATE .. SET Active=1 WHERE Active=0` or a similar condition will work fine. It's the rest of the code that causes the problem, if not the attempt to use the database as some sort of queue or application lock itself. – Panagiotis Kanavos Apr 26 '23 at 08:56
  • If you want to generate audit records why not use a trigger? Or a temporal table? If you want an application lock you can use [sp_getapplock](https://learn.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sp-getapplock-transact-sql?view=sql-server-ver16) *but* that points to a design problem. Databases are built to server thousands of concurrent users, not just one at a time. – Panagiotis Kanavos Apr 26 '23 at 09:00
  • Thanks siggemannen, pratik, panagiotis.. I've posted the code in the answer. Please do let me know of your thoughts if any. – Hemanth Gowda Apr 27 '23 at 05:08

4 Answers4

1

With READ COMMITTED SNAPSHOT on you need to opt-in to a lock when reading. Otherwise you'll simply read the "last-known-good" version of the row.

So use UPDLOCK, and add HOLDLOCK if you also need to lock empty ranges (ie where the row doesn't exist and you intend to insert it).

EG

Select id 
from tableA with (UPDLOCK,HOLDLOCK)
where x_id = 1 
  and is_active = true 
  and status = inprogress 
David Browne - Microsoft
  • 80,331
  • 6
  • 39
  • 67
  • 1
    Thanks @David. I did not test the "lock empty ranges" scenario, but you're right! UPDLOCK does the job. – Hemanth Gowda Apr 27 '23 at 00:24
  • The problem is using SELECT in the first place. Aaron Bertrand calls this the [UPSERT anti-pattern](https://sqlperformance.com/2020/09/locking/upsert-anti-pattern). It's better to get rid of `SELECT` than add extra locking to make it work. – Panagiotis Kanavos Apr 27 '23 at 08:05
1

I wrote a test and looks like UPDLOCK does the job! Thank you everyone for the inputs.

    CREATE TABLE lock_test(
ID INT NOT NULL,
is_active INT NOT NULL,
x_id INT NOT NULL,
audit_id INT,
status varchar(25)
)

INSERT INTO lock_test(ID, is_active, x_id, status)
VALUES (1, 1, 9, 'inprogress')

In query tab 1

BEGIN TRANSACTION tran1
  BEGIN TRY
        IF EXISTS (select * from lock_test with(updlock) where x_id = 9 and is_active = 1 and status = 'inprogress')
        BEGIN
            UPDATE lock_test SET is_active = 0 where x_id = 9 and is_active = 1 and status = 'inprogress'
            WAITFOR DELAY '00:00:20'
            INSERT INTO lock_test(ID, is_active, x_id, status) VALUES (2, 1, 9, 'done')
            COMMIT TRANSACTION tran1
        END 
  END TRY
  BEGIN CATCH
      ROLLBACK TRANSACTION tran1
  END CATCH

In query tab2

BEGIN TRANSACTION tran1
  BEGIN TRY
        IF EXISTS (select * from lock_test with(updlock) where x_id = 9 and is_active = 1 and status = 'inprogress')
        BEGIN
            UPDATE lock_test SET is_active = 0 where x_id = 9 and is_active = 1 and status = 'inprogress'
            INSERT INTO lock_test(ID, is_active, x_id, status) VALUES (2, 1, 9, 'done')
            COMMIT TRANSACTION tran1
        END 
  END TRY
  BEGIN CATCH
      ROLLBACK TRANSACTION tran1
  END CATCH

Result Test results

To reproduce the problem in question, Remove with(updlock) from both selects and follow the steps mentioned above.

Problematic result: Problematic result

References:

Hemanth Gowda
  • 604
  • 4
  • 16
  • The problem isn't HOLDLOCK or UPDATE, it's that needless `SELECT` which was never part of the question. Neither that nor `IF` are needed. You can use `UPDATE ... OUTPUT inserted.* INTO @SomeTableVar` to get the IDs of the matched and updated rows and *then* insert the new rows. No SELECT, no `UPDLOCK` needed – Panagiotis Kanavos Apr 27 '23 at 07:30
  • Even so, there are far better options, especially in Azure which provides queueing out of the box. What real problem are you trying to solve with this table and query? – Panagiotis Kanavos Apr 27 '23 at 07:31
0

Azure works differently than "default mode" SQL Server here. Because Snapshot Isolation is normally on, reads will not block during an open write transaction even at the "READ COMMITTED" isolation level -- Azure will "take a snapshot" of the old data instead. If you want to avoid this behavior, you can either turn off snapshot isolation ("READ_COMMITTED_SNAPSHOT OFF"), upgrade to an update lock (as per the other answer), or set the transaction isolation level to serializable just for this one transaction.

However, the better solution is to avoid this contortion entirely. To do this without a multi-statement explicit transaction, use the "OUTPUT" clause on the update (which is atomic) to return the Audit ID you wish to insert. If your INSERT contains the where clause "..WHERE Active=1", it can't go wrong. An example of using OUTPUT to audit rows in atomic fashion like this can be found at:

SQLServerCentral: The Output Clause

Endymio
  • 35
  • 6
  • No, Azure SQL doesn't work differently. SNAPSHOT isolation was added in SQL Server 2005, 3 years before Azure itself was created. The difference is that the `READ COMMITTED SNAPSHOT` is on by default in Azure SQL. – Panagiotis Kanavos Apr 27 '23 at 08:08
  • Yes, so *by default*, Azure works differently than SQL Server. Of course, the real problem here is the OP trying to do this in non-atomic fashion. Using the OUTPUT clause on the UPDATE is the better solution. – Endymio Apr 28 '23 at 10:19
-2

For step #2 UPDLOCK does not work for SELECT statements.
Correction: It acctually does work when both SELECTs have UPDLOCK.
You could use an exclusive lock, so that other SELECTs (without any hint) will wait for the transaction to finish:

SELECT id FROM tableA 
  WITH (PAGLOCK,XLOCK)
  WHERE x_id = 1 AND is_active = 1 AND status = 'inprogress'

Note: On Azure SQL the default isolation level (READ COMMITTED) behaves differently than on SQL Server, and does not wait on reads even when the lock is exclusive. On Azure SQL to wait in SELECTs on locked records, you should set SET TRANSACTION ISOLATION LEVEL REPEATABLE READ or SET TRANSACTION ISOLATION LEVEL SERIALIZABLE

It would be much simpler if you could avoid SELECT before UPDATE with something like this:

BEGIN TRAN
DECLARE @id int
UPDATE tableA 
  SET is_active = 0, @id=id
  WHERE x_id = 1 AND is_active = 1 AND status = 'inprogress'
IF @@ROWCOUNT>0 -- if found then also insert audit record
  INSERT INTO tableA (x_id, is_active, audit_id, status)
    VALUES (1, 1/*true*/, @id, 'inprogress')
COMMIT TRAN
Cosmin Rus
  • 330
  • 2
  • 8
  • `On Azure SQL the default isoloation level (READ COMMITTED) behaves differently than on SQL Server` actually, READ COMMITTED is the default going back to the 1990s. `does not wait on reads even when the lock is exclusive` wrong, it does. That's what `READ COMMITTED` means in the first place. If the row is locked for updating, it's not yet committed, so you can't access it. – Panagiotis Kanavos Apr 26 '23 at 08:56
  • At least on my test, on Azure SQL, when isolation is left READ COMMITTED the SELECT statement from the secondary connection finishes imediatelly and reads the old value, even when in the primary connection (where the transaction is in progress) the update has already been executed (but the commit/rollback not). In non-Azure SQL the SELECT waits for the transaction to finish. When the isolation is changed to READ UNCOMMITTED in the secondary connection then the SELECT finishes imediatelty but returns the new (uncommited) value (as expected) in both Azure SQL and Standard/Enterprise SQL Server. – Cosmin Rus Apr 26 '23 at 10:32
  • 1
    The difference is the READ COMMITTED SNAPSHOT setting, which is on by default in Azure SQL Database, and off by default for backwards-compatibility in SQL Server. With the same setting hey behave the same. – David Browne - Microsoft Apr 26 '23 at 19:22
  • The SNAPSHOT isolation level and the READ COMMITTED SNAPSHOT database setting are very different from READ COMMITTED and was introduced in SQL Server 2005. Azure didn't even exist back then. SQL Server isn't a new product. There's no ambiguity in the isolation levels, nothing to test. If what you see contradicts the docs, you're doing something wrong – Panagiotis Kanavos Apr 27 '23 at 07:22
  • So the diffrent locking behavior (as David Browne noted) with the same READ COMMITED isolation level for reads, is because on Azure SQL READ_COMMITTED_SNAPSHOT database option is on by default, while for SQL Server it is off by default. [Set transaction isolation level](https://learn.microsoft.com/en-us/sql/t-sql/statements/set-transaction-isolation-level-transact-sql#arguments) (The behavior of READ COMMITTED depends on the setting of the READ_COMMITTED_SNAPSHOT) – Cosmin Rus Apr 27 '23 at 12:20