From 2c7beb6c412c2160f0db6983bfe805cfca4e4f63 Mon Sep 17 00:00:00 2001 From: Daniel Kudernatsch Date: Fri, 14 Aug 2026 10:42:07 +0200 Subject: [PATCH] RM-9724 Add FocrceSeek to lockstatment for Batched Sql queries --- .../BatchedLockDbCommandBuilderTest.cs | 32 +++++++++---------- .../SqlProviderGeneratedSqlTest.cs | 2 +- .../BatchedLockDbCommandBuilder.cs | 6 +++- 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/Remotion/Data/DomainObjects.UnitTests/Persistence/Rdbms/DbCommandBuilders/BatchedLockDbCommandBuilderTest.cs b/Remotion/Data/DomainObjects.UnitTests/Persistence/Rdbms/DbCommandBuilders/BatchedLockDbCommandBuilderTest.cs index cdd881824c..5cd7cfc2b9 100644 --- a/Remotion/Data/DomainObjects.UnitTests/Persistence/Rdbms/DbCommandBuilders/BatchedLockDbCommandBuilderTest.cs +++ b/Remotion/Data/DomainObjects.UnitTests/Persistence/Rdbms/DbCommandBuilders/BatchedLockDbCommandBuilderTest.cs @@ -67,13 +67,13 @@ public void Create_WithOneTableAndDefaultSchema () SET @IsReadCommittedSnapshotOn = (SELECT [is_read_committed_snapshot_on] FROM [sys].[databases] WHERE [database_id] = DB_ID()); IF (@TransactionIsolationLevel = 2 AND @IsReadCommittedSnapshotOn = 1) BEGIN - SELECT [P].[col1], [P].[col2] FROM [Table] [T] WITH(ROWLOCK, XLOCK, READPAST) + SELECT [P].[col1], [P].[col2] FROM [Table] [T] WITH(ROWLOCK, XLOCK, READPAST, FORCESEEK) RIGHT JOIN @TVP_Lock_Table [P] ON [P].[col1] = [T].[col1] AND [P].[col2] = [T].[col2] WHERE [T].[ID] IS NULL; END ELSE BEGIN - SELECT [P].[col1], [P].[col2] FROM [Table] [T] WITH(ROWLOCK, XLOCK) + SELECT [P].[col1], [P].[col2] FROM [Table] [T] WITH(ROWLOCK, XLOCK, FORCESEEK) RIGHT JOIN @TVP_Lock_Table [P] ON [P].[col1] = [T].[col1] AND [P].[col2] = [T].[col2] WHERE [T].[ID] IS NULL; END @@ -105,13 +105,13 @@ public void Create_WithOneTableCustomSchema () SET @IsReadCommittedSnapshotOn = (SELECT [is_read_committed_snapshot_on] FROM [sys].[databases] WHERE [database_id] = DB_ID()); IF (@TransactionIsolationLevel = 2 AND @IsReadCommittedSnapshotOn = 1) BEGIN - SELECT [P].[col1], [P].[col2] FROM [customscheme].[Table] [T] WITH(ROWLOCK, XLOCK, READPAST) + SELECT [P].[col1], [P].[col2] FROM [customscheme].[Table] [T] WITH(ROWLOCK, XLOCK, READPAST, FORCESEEK) RIGHT JOIN @TVP_Lock_Table [P] ON [P].[col1] = [T].[col1] AND [P].[col2] = [T].[col2] WHERE [T].[ID] IS NULL; END ELSE BEGIN - SELECT [P].[col1], [P].[col2] FROM [customscheme].[Table] [T] WITH(ROWLOCK, XLOCK) + SELECT [P].[col1], [P].[col2] FROM [customscheme].[Table] [T] WITH(ROWLOCK, XLOCK, FORCESEEK) RIGHT JOIN @TVP_Lock_Table [P] ON [P].[col1] = [T].[col1] AND [P].[col2] = [T].[col2] WHERE [T].[ID] IS NULL; END @@ -150,29 +150,29 @@ public void Create_WithMultipleTablesDefaultSchema () SET @IsReadCommittedSnapshotOn = (SELECT [is_read_committed_snapshot_on] FROM [sys].[databases] WHERE [database_id] = DB_ID()); IF (@TransactionIsolationLevel = 2 AND @IsReadCommittedSnapshotOn = 1) BEGIN - SELECT [P].[col1], [P].[col2] FROM [Table1] [T] WITH(ROWLOCK, XLOCK, READPAST) + SELECT [P].[col1], [P].[col2] FROM [Table1] [T] WITH(ROWLOCK, XLOCK, READPAST, FORCESEEK) RIGHT JOIN @TVP_Lock_Table1 [P] ON [P].[col1] = [T].[col1] AND [P].[col2] = [T].[col2] WHERE [T].[ID] IS NULL UNION ALL - SELECT [P].[col1], [P].[col2] FROM [Table2] [T] WITH(ROWLOCK, XLOCK, READPAST) + SELECT [P].[col1], [P].[col2] FROM [Table2] [T] WITH(ROWLOCK, XLOCK, READPAST, FORCESEEK) RIGHT JOIN @TVP_Lock_Table2 [P] ON [P].[col1] = [T].[col1] AND [P].[col2] = [T].[col2] WHERE [T].[ID] IS NULL UNION ALL - SELECT [P].[col1], [P].[col2] FROM [Table3] [T] WITH(ROWLOCK, XLOCK, READPAST) + SELECT [P].[col1], [P].[col2] FROM [Table3] [T] WITH(ROWLOCK, XLOCK, READPAST, FORCESEEK) RIGHT JOIN @TVP_Lock_Table3 [P] ON [P].[col1] = [T].[col1] AND [P].[col2] = [T].[col2] WHERE [T].[ID] IS NULL; END ELSE BEGIN - SELECT [P].[col1], [P].[col2] FROM [Table1] [T] WITH(ROWLOCK, XLOCK) + SELECT [P].[col1], [P].[col2] FROM [Table1] [T] WITH(ROWLOCK, XLOCK, FORCESEEK) RIGHT JOIN @TVP_Lock_Table1 [P] ON [P].[col1] = [T].[col1] AND [P].[col2] = [T].[col2] WHERE [T].[ID] IS NULL UNION ALL - SELECT [P].[col1], [P].[col2] FROM [Table2] [T] WITH(ROWLOCK, XLOCK) + SELECT [P].[col1], [P].[col2] FROM [Table2] [T] WITH(ROWLOCK, XLOCK, FORCESEEK) RIGHT JOIN @TVP_Lock_Table2 [P] ON [P].[col1] = [T].[col1] AND [P].[col2] = [T].[col2] WHERE [T].[ID] IS NULL UNION ALL - SELECT [P].[col1], [P].[col2] FROM [Table3] [T] WITH(ROWLOCK, XLOCK) + SELECT [P].[col1], [P].[col2] FROM [Table3] [T] WITH(ROWLOCK, XLOCK, FORCESEEK) RIGHT JOIN @TVP_Lock_Table3 [P] ON [P].[col1] = [T].[col1] AND [P].[col2] = [T].[col2] WHERE [T].[ID] IS NULL; END @@ -211,29 +211,29 @@ public void Create_WithMultipleTablesCustomSchema () SET @IsReadCommittedSnapshotOn = (SELECT [is_read_committed_snapshot_on] FROM [sys].[databases] WHERE [database_id] = DB_ID()); IF (@TransactionIsolationLevel = 2 AND @IsReadCommittedSnapshotOn = 1) BEGIN - SELECT [P].[col1], [P].[col2] FROM [customscheme].[Table1] [T] WITH(ROWLOCK, XLOCK, READPAST) + SELECT [P].[col1], [P].[col2] FROM [customscheme].[Table1] [T] WITH(ROWLOCK, XLOCK, READPAST, FORCESEEK) RIGHT JOIN @TVP_Lock_Table1 [P] ON [P].[col1] = [T].[col1] AND [P].[col2] = [T].[col2] WHERE [T].[ID] IS NULL UNION ALL - SELECT [P].[col1], [P].[col2] FROM [customscheme].[Table2] [T] WITH(ROWLOCK, XLOCK, READPAST) + SELECT [P].[col1], [P].[col2] FROM [customscheme].[Table2] [T] WITH(ROWLOCK, XLOCK, READPAST, FORCESEEK) RIGHT JOIN @TVP_Lock_Table2 [P] ON [P].[col1] = [T].[col1] AND [P].[col2] = [T].[col2] WHERE [T].[ID] IS NULL UNION ALL - SELECT [P].[col1], [P].[col2] FROM [customscheme].[Table3] [T] WITH(ROWLOCK, XLOCK, READPAST) + SELECT [P].[col1], [P].[col2] FROM [customscheme].[Table3] [T] WITH(ROWLOCK, XLOCK, READPAST, FORCESEEK) RIGHT JOIN @TVP_Lock_Table3 [P] ON [P].[col1] = [T].[col1] AND [P].[col2] = [T].[col2] WHERE [T].[ID] IS NULL; END ELSE BEGIN - SELECT [P].[col1], [P].[col2] FROM [customscheme].[Table1] [T] WITH(ROWLOCK, XLOCK) + SELECT [P].[col1], [P].[col2] FROM [customscheme].[Table1] [T] WITH(ROWLOCK, XLOCK, FORCESEEK) RIGHT JOIN @TVP_Lock_Table1 [P] ON [P].[col1] = [T].[col1] AND [P].[col2] = [T].[col2] WHERE [T].[ID] IS NULL UNION ALL - SELECT [P].[col1], [P].[col2] FROM [customscheme].[Table2] [T] WITH(ROWLOCK, XLOCK) + SELECT [P].[col1], [P].[col2] FROM [customscheme].[Table2] [T] WITH(ROWLOCK, XLOCK, FORCESEEK) RIGHT JOIN @TVP_Lock_Table2 [P] ON [P].[col1] = [T].[col1] AND [P].[col2] = [T].[col2] WHERE [T].[ID] IS NULL UNION ALL - SELECT [P].[col1], [P].[col2] FROM [customscheme].[Table3] [T] WITH(ROWLOCK, XLOCK) + SELECT [P].[col1], [P].[col2] FROM [customscheme].[Table3] [T] WITH(ROWLOCK, XLOCK, FORCESEEK) RIGHT JOIN @TVP_Lock_Table3 [P] ON [P].[col1] = [T].[col1] AND [P].[col2] = [T].[col2] WHERE [T].[ID] IS NULL; END diff --git a/Remotion/Data/DomainObjects.UnitTests/Persistence/Rdbms/SqlServer/IntegrationTests/SqlProviderGeneratedSqlTest.cs b/Remotion/Data/DomainObjects.UnitTests/Persistence/Rdbms/SqlServer/IntegrationTests/SqlProviderGeneratedSqlTest.cs index a62d5ad86e..f0f8666360 100644 --- a/Remotion/Data/DomainObjects.UnitTests/Persistence/Rdbms/SqlServer/IntegrationTests/SqlProviderGeneratedSqlTest.cs +++ b/Remotion/Data/DomainObjects.UnitTests/Persistence/Rdbms/SqlServer/IntegrationTests/SqlProviderGeneratedSqlTest.cs @@ -316,7 +316,7 @@ public void Save_BatchedSaveCommandFactory () _testHelper.ExpectExecuteReader( sequence, expectedCommandBehavior:CommandBehavior.Default, - "DECLARE @TransactionIsolationLevel int;\r\nDECLARE @IsReadCommittedSnapshotOn bit;\r\nSET @TransactionIsolationLevel = (SELECT [transaction_isolation_level] FROM [sys].[dm_exec_sessions] WHERE [session_id] = @@SPID);\r\nSET @IsReadCommittedSnapshotOn = (SELECT [is_read_committed_snapshot_on] FROM [sys].[databases] WHERE [database_id] = DB_ID());\r\nIF (@TransactionIsolationLevel = 2 AND @IsReadCommittedSnapshotOn = 1)\r\nBEGIN\r\nSELECT [P].[ID], [P].[Timestamp] FROM [Employee] [T] WITH(ROWLOCK, XLOCK, READPAST)\r\nRIGHT JOIN @TVP_Lock_Employee [P] ON [P].[ID] = [T].[ID] AND [P].[Timestamp] = [T].[Timestamp]\r\nWHERE [T].[ID] IS NULL;\r\nEND\r\nELSE\r\nBEGIN\r\nSELECT [P].[ID], [P].[Timestamp] FROM [Employee] [T] WITH(ROWLOCK, XLOCK)\r\nRIGHT JOIN @TVP_Lock_Employee [P] ON [P].[ID] = [T].[ID] AND [P].[Timestamp] = [T].[Timestamp]\r\nWHERE [T].[ID] IS NULL;\r\nEND", + "DECLARE @TransactionIsolationLevel int;\r\nDECLARE @IsReadCommittedSnapshotOn bit;\r\nSET @TransactionIsolationLevel = (SELECT [transaction_isolation_level] FROM [sys].[dm_exec_sessions] WHERE [session_id] = @@SPID);\r\nSET @IsReadCommittedSnapshotOn = (SELECT [is_read_committed_snapshot_on] FROM [sys].[databases] WHERE [database_id] = DB_ID());\r\nIF (@TransactionIsolationLevel = 2 AND @IsReadCommittedSnapshotOn = 1)\r\nBEGIN\r\nSELECT [P].[ID], [P].[Timestamp] FROM [Employee] [T] WITH(ROWLOCK, XLOCK, READPAST, FORCESEEK)\r\nRIGHT JOIN @TVP_Lock_Employee [P] ON [P].[ID] = [T].[ID] AND [P].[Timestamp] = [T].[Timestamp]\r\nWHERE [T].[ID] IS NULL;\r\nEND\r\nELSE\r\nBEGIN\r\nSELECT [P].[ID], [P].[Timestamp] FROM [Employee] [T] WITH(ROWLOCK, XLOCK, FORCESEEK)\r\nRIGHT JOIN @TVP_Lock_Employee [P] ON [P].[ID] = [T].[ID] AND [P].[Timestamp] = [T].[Timestamp]\r\nWHERE [T].[ID] IS NULL;\r\nEND", Tuple.Create("@TVP_Lock_Employee", DbType.Object, (object)expectedLockTvpValue)); _testHelper.ExpectExecuteNonQuery( diff --git a/Remotion/Data/DomainObjects/Persistence/Rdbms/DbCommandBuilders/BatchedLockDbCommandBuilder.cs b/Remotion/Data/DomainObjects/Persistence/Rdbms/DbCommandBuilders/BatchedLockDbCommandBuilder.cs index bf3ab25b0f..a442e050fe 100644 --- a/Remotion/Data/DomainObjects/Persistence/Rdbms/DbCommandBuilders/BatchedLockDbCommandBuilder.cs +++ b/Remotion/Data/DomainObjects/Persistence/Rdbms/DbCommandBuilders/BatchedLockDbCommandBuilder.cs @@ -121,7 +121,11 @@ private string CreateLockStatement ( { var selectColumns = string.Join(", ", columns.Select(c => $"{parameterAlias}.{c}")); var joinCondition = string.Join(" AND ", columns.Select(c => $"{parameterAlias}.{c} = {tableAlias}.{c}")); - var tableHints = forReadCommittedIsolation ? "ROWLOCK, XLOCK, READPAST" : "ROWLOCK, XLOCK"; + // FORCESEEK guarantees that the optimizer seeks into the target table's index instead of scanning it, which would otherwise + // XLOCK every row of the table (not just the rows being locked) on small tables and cause spurious ConcurrencyViolations + // for concurrent batches locking disjoint rows (RM-9724). The target table's ID column always has a clustered index (its + // primary key), so a seek plan always exists. + var tableHints = forReadCommittedIsolation ? "ROWLOCK, XLOCK, READPAST, FORCESEEK" : "ROWLOCK, XLOCK, FORCESEEK"; return $""" SELECT {selectColumns} FROM {schemaName}{tableName} {tableAlias} WITH({tableHints})