-
Notifications
You must be signed in to change notification settings - Fork 312
Description
Describe the bug
When an exception occurs on SqlCommand execution, and the exception is related with transaction abort, the Transaction property of SqlCommand is set to null.
Execution retry of such SqlCommand can be successful, lead to partial change saving of transaction and data corruption.
I think, this behavior is dangerous, because it may be done out of ignorance.
For example, this problem occurs in the linq2db project, where one command is used in the retry policy for SqlServer several times:
For compare, in the Npgsql project, command execution retry throws exception with message: "current transaction is aborted".
To reproduce
Start sqlserver in a docker container:
docker run --rm --detach ^
--name SqlServer ^
--publish 1433:1433 ^
--env "ACCEPT_EULA=Y" ^
--env "SA_PASSWORD=Password12!" ^
mcr.microsoft.com/mssql/server:2017-CU20-ubuntu-16.04
Run following program (it simulates a deadlock and transaction abort):
public static class Program
{
public static async Task Main(string[] args)
{
await ExecuteMaster($@"
if db_id('Test') is not null
begin
alter database Test set single_user with rollback immediate;
drop database Test;
end");
await ExecuteMaster($@"
create database Test");
await ExecuteTest($@"
create table Item1 (Id int, Value int)");
await ExecuteTest($@"
create table Item2 (Id int, Value int)");
await ExecuteTest($@"
insert into Item1 (Id, Value) values (1, 0)");
await ExecuteTest($@"
insert into Item2 (Id, Value) values (1, 0)");
var connectionString = CreateConnectionString("Test");
var counter = 0;
// This transaction updates the Item1 table, then Item2 table and set value "1" in both tables.
var task1 = Task.Run(async () =>
{
await using var connection = new SqlConnection(connectionString);
await connection.OpenAsync();
await using var transaction = connection.BeginTransaction(IsolationLevel.ReadCommitted);
await UpdateWithRetry(connection, transaction, table: "Item1", value: 1);
Interlocked.Increment(ref counter);
while (Volatile.Read(ref counter) < 2)
await Task.Delay(1000);
await UpdateWithRetry(connection, transaction, table: "Item2", value: 1);
await transaction.CommitAsync();
});
// This transaction updates the Item2 table, then Item1 table and set value "2" in both tables.
var task2 = Task.Run(async () =>
{
await using var connection = new SqlConnection(connectionString);
await connection.OpenAsync();
await using var transaction = connection.BeginTransaction(IsolationLevel.ReadCommitted);
await UpdateWithRetry(connection, transaction, table: "Item2", value: 2);
Interlocked.Increment(ref counter);
while (Volatile.Read(ref counter) < 2)
await Task.Delay(1000);
await UpdateWithRetry(connection, transaction, table: "Item1", value: 2);
await transaction.CommitAsync();
});
try
{
await Task.WhenAll(task1, task2);
}
catch (Exception ex)
{
Console.WriteLine(ex);
}
}
private static async Task UpdateWithRetry(SqlConnection connection, SqlTransaction transaction, string table, int value)
{
var query = $"update {table} set Value = {value} where Id = 1";
await using var command = new SqlCommand(query);
command.Connection = connection;
command.Transaction = transaction;
try
{
await command.ExecuteNonQueryAsync();
}
catch (Exception ex)
{
// Retry:
await command.ExecuteNonQueryAsync();
}
}
private static Task ExecuteMaster(string query) => Execute(query, initialCatalog: "master");
private static Task ExecuteTest(string query) => Execute(query, initialCatalog: "Test");
private static async Task Execute(string query, string initialCatalog)
{
var connectionString = CreateConnectionString(initialCatalog);
await using var connection = new SqlConnection(connectionString);
await using var command = new SqlCommand(query, connection);
connection.Open();
await command.ExecuteNonQueryAsync();
}
private static string CreateConnectionString(string initialCatalog)
{
var csb = new SqlConnectionStringBuilder
{
DataSource = "localhost",
InitialCatalog = initialCatalog,
UserID = "sa",
Password = "Password12!",
Encrypt = false,
};
return csb.ToString();
}
}
Expected behavior
Only one transaction has been completed successfully. Both tables (Item1 and Item2) will contain the same value: 1 or 2.
Current behavior
One transaction will complete successfully. In the second transaction, the second update will also complete successfully.
One table contains the value 1 and the other table contains the value 2.
Further technical details
Microsoft.Data.SqlClient version: 4.1.0
.NET target: Core 3.1
SQL Server version: mcr.microsoft.com/mssql/server:2017-CU20-ubuntu-16.04