Skip to content

SqlCommand retry after transaction abort can partially save data to database #1509

@Chakrygin

Description

@Chakrygin

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:

https://github.com/linq2db/linq2db/blob/21380afa89df514e01e28946e64029766ec28485/Source/LinqToDB/Data/RetryPolicy/RetryingDbCommand.cs#L105

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Up-for-Grabs 🙌Issues that are ready to be picked up for anyone interested. Please self-assign and remove the label

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions