1

I have a situation with SQL Server 2012 Express and an Index that throws a "Duplicate Key Exception" without a reason. This code works on multiple machines, but in a particular QA department's Workstation this error appear sometimes, I can´t figure out the cause and I can't replicate this in other machines.

In context:

The information is generated by third party equipment, and the software tries to synchronize that information with its own database. The software receives the information when asking for it or with a notification event generated by the equipment. It tries to insert the data and always inserts the data but sometimes SQL Server throws a "Duplicate Key Exception". I added several logs to diagnose the problem and I could discard a double execution of the saving function and saw that the problem is the "Duplicate Key Exception" on the index, even with a successful inserted data.

I use .NET Core 6 and System.Data.SqlClient (4.8.2) library. I tried several ways to insert the data without success. I tried the ExecuteNonQuery, ExecuteScalar and ExecuteReader functions with different SQL code to insert. I tried using OUTPUT INSERTED.TransactionID, checking existence of the key before inserting, returning 0 or 1 to differentiate the successful insert from the failed one, but each attempt finished with the same problem returning the "Duplicate Key Exception".

This code creates the table if it doesn't exist.

IF NOT EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[FOO].[Transactions]') AND type in (N'U'))
    BEGIN
        CREATE TABLE [FOO].[Transactions](              
            [TransactionID] [bigint] NOT NULL,
            [DeviceID] [tinyint] NOT NULL,
            [TransactionNumber] [bigint] NOT NULL,
            [Material] [text] NULL,
            [Volume] [decimal](15, 3) NOT NULL,
            [StartDateTime] [datetime2](7) NOT NULL,
            [EndDateTime] [datetime2](7) NOT NULL,
            [StartingVolume] [decimal](15, 3) NOT NULL,
            [EndingVolume] [decimal](15, 3) NOT NULL,
        PRIMARY KEY 
        (
            [TransactionID] ASC
        )WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
        ) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]
    END;

IF NOT EXISTS (SELECT * FROM sys.indexes WHERE object_id = OBJECT_ID(N'[FOO].[transactions]') AND name = N'IX_transaction_number')
    CREATE INDEX [IX_transaction_number] ON [FOO].[transactions]
    (
        [TransactionNumber] DESC
    )WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, SORT_IN_TEMPDB = OFF, IGNORE_DUP_KEY = OFF, DROP_EXISTING = OFF, ONLINE = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY];
                

IF NOT EXISTS (SELECT * FROM sys.indexes WHERE object_id = OBJECT_ID(N'[FOO].[transactions]') AND name = N'IX_device_sale')
    CREATE UNIQUE NONCLUSTERED INDEX [IX_device_sale] ON [FOO].[transactions]
    (
        [DeviceID] ASC,
        [TransactionNumber] DESC
    )WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, SORT_IN_TEMPDB = OFF, IGNORE_DUP_KEY = OFF, DROP_EXISTING = OFF, ONLINE = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY];

This one of the variant code to makes the insert:

INSERT INTO [FOO].[Transactions]
        ([TransactionID],[DeviceID],[TransactionNumber],[Material],[Volume],[StartDateTime],[EndDateTime],[StartingVolume],[EndingVolume])
VALUES
    (@TransactionID ,@DeviceID,@TransactionNumber,@Material,@Volume,@StartDateTime,@EndDateTime,@StartingVolume,@EndingVolume);

This SQL code return 0, but in the StatementCompleted event of the SqlCommand class is triggered with 1 affected row from the insert.

IF EXISTS (SELECT TOP 1 * FROM [SCD].[Transactions] WHERE [SaleID] = @SaleID)
BEGIN
    SELECT 0;
END
ELSE
    BEGIN
        INSERT INTO [SCD].[Transactions]
                    ([TransactionID],[DeviceID],[TransactionNumber],[Material],[Volume],[StartDateTime],[EndDateTime],[StartingVolume],[EndingVolume])
                VALUES
                    (@TransactionID ,@DeviceID,@TransactionNumber,@Material,@Volume,@StartDateTime,@EndDateTime,@StartingVolume,@EndingVolume);
        SELECT 1;
    END

This is the C# code for insert:

private int SaveTransaction(SqlConnection connection, TransactionInfo transaction)
{
    try
    {
        _logger.LogInformation("[REPOSITORY] Starting to save TransactionID:{0} at {1} ns",transaction.TransactionID,Stopwatch.GetTimestamp());
        using SqlCommand cmd = new SqlCommand(INSERT_TRANSACTION, connection);
        cmd.Parameters.Add("TransactionID", System.Data.SqlDbType.BigInt).Value = transaction.TransactionID;
        cmd.Parameters.Add("DeviceID", System.Data.SqlDbType.SmallInt).Value = transaction.PumpID;
        cmd.Parameters.Add("TransactionNumber", System.Data.SqlDbType.BigInt).Value = transaction.TransactionNumber;
        cmd.Parameters.Add("Material", System.Data.SqlDbType.Text).Value = transaction.Material;
        cmd.Parameters.Add("Volume", System.Data.SqlDbType.Decimal).Value = transaction.Volume;
        cmd.Parameters.Add("StartDateTime", System.Data.SqlDbType.DateTime2).Value = transaction.StartDateTime;
        cmd.Parameters.Add("EndDateTime", System.Data.SqlDbType.DateTime2).Value = transaction.EndDateTime;
        cmd.Parameters.Add("StartingVolume", System.Data.SqlDbType.Decimal).Value = transaction.StartingVolume;
        cmd.Parameters.Add("EndingVolume", System.Data.SqlDbType.Decimal).Value = transaction.EndingVolume;
        var res = cmd.ExecuteNonQuery();
        _logger.LogInformation("[REPOSITORY] Ending to save TransactionID:{0} at {1} ns Result:{2}", transaction.TransactionID, Stopwatch.GetTimestamp(),res);
        return res > 0 ? 1 : 0;
    }
    catch (Exception ex)
    {
        _logger.LogInformation("[REPOSITORY] Error saving TransactionID:{0} at {1} ns \n {2}", transaction.TransactionID, Stopwatch.GetTimestamp(), ex.Message);
        _logger.LogError(ex, ex.Message);
        return -1;
    }
}

The questions: What SQL Server configuration or detail can cause this kind of exception? Where can I look for the source of the problem?

EDIT This is the calling function, here the connection is created and closed.

 public int SaveTransaction(TransactionInfo transaction)
{
        using SqlConnection connection = new SqlConnection(_connectionString);
    try
    {
        connection.Open();
        var res = SaveTransaction(connection, transaction);
        return res;
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, ex.Message);
        return -1;
    }
    finally
    {
        connection.Close();
    }
}
Frank
  • 192
  • 9
  • 7
    The error is correct - somewhere you are trying to insert a duplicate key... there is no setting or alternative insert method to get around that, aside from disabling the unique key constraint. Based on the code shown you are either trying to insert a duplicate `TransactionID` or a duplicate `DeviceID, TransactionNumber` pair - you just need to work out how that is happening. – Dale K Jul 05 '22 at 21:35
  • TransactionID is the primary key that can cause the exception. Where does it come from? I think this is the "key" to figuring out this issue. Make sure the TransactionID is what you expect. – Lzh Jul 05 '22 at 21:55
  • 6
    Not an answer to your question but you should consider upgrading from sql server 2012. In just 7 days it will be fully unsupported. – Sean Lange Jul 05 '22 at 21:57
  • 1
    @mzn and `CREATE UNIQUE NONCLUSTERED INDEX [IX_device_sale]` – Dale K Jul 05 '22 at 22:00
  • It seems like the function is getting called twice with the same parameters. post the code that calls the function. – Golden Lion Jul 05 '22 at 22:22
  • 2
    Are you looking at the right event? 1 row will be affected in both cases because you issue a `select` which returns a single row in both cases. The code as written is also not really safe, because there's a delay between the check for existing data, and then the insert. What if two processes are executing with the same parameters at nearly the same time? The first one does it's check, then the second does its check, then the first does its insert (successfully), and the second will now attempt to do the insert (the row didn't exist when the check was run), but it will be a duplicate. – allmhuran Jul 05 '22 at 22:52
  • 2
    The technique to guard against the scenario I described is actually less trivial than you might think: You issue a combination of `updlock` and `holdlock` hints, either in the first of two statements (your current pattern), or in a single statement using `insert ... select ... where not exists ...`. See [this question](https://stackoverflow.com/q/3789287/7165279) – allmhuran Jul 05 '22 at 23:07
  • You definitely need to change to `var res = (int)cmd.ExecuteScalar();` given that you are using a `SELECT 1`, but that won't fix your issue. I'm guessing your `IF EXISTS` is wrong, it should probably be `IF EXISTS (SELECT 1 FROM SCD.Transactions WHERE TransactionID = @TransactionID)` – Charlieface Jul 06 '22 at 00:25
  • @Charlieface I use `ExecuteScalar` only with the `IF EXISTS (SELECT 1 FROM SCD.Transactions WHERE TransactionID = @TransactionID)`. I use `ExecuteNonQuery` with the `INSERT` statements. – Frank Jul 06 '22 at 16:48
  • @allmhuran I use the row affected event only with the plain `INSERT` statement. In fact, the code that returning the affected row is the code that I don't use that value. The check is an other attempt of solving the problem. – Frank Jul 06 '22 at 17:08
  • @GoldenLion that what I thought, that's why I putt the log `_logger.LogInformation("[REPOSITORY] Starting to save TransactionID:{0}...` and `_logger.LogInformation("[REPOSITORY] Ending to save TransactionID:{0} at {1}` to discard a multiple execution or concurrent problems. – Frank Jul 06 '22 at 17:23
  • @Mzn the `TransactionID` is generated in an external Device and is never received twice. The log that I put was for discard any duplication scenario in execution or information. – Frank Jul 06 '22 at 17:39
  • Clearly you do have a duplicate key. It's either `TransactionID` or a `DeviceID, TransactionNumber`combination, because those are the unique keys/indexes you have. The server doesn't lie. – Charlieface Jul 06 '22 at 17:42
  • @DaleK The `TransactionID` index is the only with the problem. Obviously there are something trying to insert a duplicate key but is not in my hands. The duplication occurs (sometimes) in the execution of the SQL from C#. The logs that I put was for discard any behavior from the C# code. So discarding my code, the error are between the `SqlCommand` class and SQL Server query execution. – Frank Jul 06 '22 at 17:48
  • @Charlieface I don't think the server is lying, but clearly the problem is in between SQL Server 2012 and `System.Data.SqlClient (4.8.2)` library because I put logs all over the persistence process for evaluate the software behavior, tried several ways to confirm the data is persisted, discard any duplicate execution or data on the code, and the behavior is inconsistent. We can't replicate the behavior in other place, we just repeat the scenario until the error occurs in the Workstation. – Frank Jul 06 '22 at 18:07
  • There are many ways this could still happen. For example, your `IF EXISTS` batch does not have any transaction, and even if it did it would need a `SERIALIZABLE, UPDLOCK` lock hint inside the `IF EXISTS` query itself `IF EXISTS (SELECT TOP 1 * FROM [SCD].[Transactions] WITH (SERIALIZABLE, UPDLOCK) WHERE [SaleID] = @SaleID)` in order to prevent duplicates from causing a problem. – Charlieface Jul 06 '22 at 18:11
  • @Charlieface The `IF EXISTS (SELECT 1 FROM SCD.Transactions WHERE TransactionID = @TransactionID)` is only another try that I made, but the original code is the `INSERT INTO [FOO].[Transactions]...`. There is a any use for the `(SERIALIZABLE, UPDLOCK)` in the `INSERT INTO [FOO].[Transactions] ([TransactionID],[DeviceID],[TransactionNumber],[Material],[Volume],[StartDateTime],[EndDateTime],[StartingVolume],[EndingVolume]) VALUES (@TransactionID ,@DeviceID,@TransactionNumber,@Material,@Volume,@StartDateTime,@EndDateTime,@StartingVolume,@EndingVolume);` code? – Frank Jul 06 '22 at 19:29
  • Yes of course, if there is any chance that the same values could be used by another process at the same time – Charlieface Jul 06 '22 at 19:32
  • @Charlieface I tried the `IF EXISTS ...`, I tried the `INSERT ...` capturing the # of the affected rows and I tried the `INSERT .... OUTPUT INSERTED.TransactionID...` returning the `TransactionID`. In all cases the error occurs some times not always. With the `IF EXISTS ...`, always return 0. I suspect maybe the `System.Data.SqlClient (4.8.2)` are executing the command twice, but I have not idea how to prove that. – Frank Jul 06 '22 at 19:43
  • 1
    It's not clear why you are passing in an `SqlConnection` - most likely it should be declared with a using statement in the method as you did with SqlCommand. Please add the the declaration for `SqlConnection` as well as the code for the method that calls `SaveTransaction`. – Tu deschizi eu inchid Jul 06 '22 at 21:06
  • Thanks for adding the other `SaveTransaction` method. However, the question still remains: Why you are passing in an `SqlConnection`? It doesn't make sense to have that overload for `SaveTransaction`. What purpose does it serve? Just add the code that creates the SqlConnection to the first version of `SaveTransaction` that you posted and remove the SqlConnection parameter. – Tu deschizi eu inchid Jul 06 '22 at 21:30
  • @user9938 That was to avoid to write the logic of 'private SaveTransaction(Connection,Transaction)' twice, because another deleted function have to save a collection of `Transaction` and use this function too to save one by one. But why doesn't make sense? There is some principle violation or something that I missing? – Frank Jul 07 '22 at 17:15
  • The explanation you've provided doesn't provide clarification. Perhaps adding additional code to your post will help. – Tu deschizi eu inchid Jul 07 '22 at 17:28

2 Answers2

1

put your connection in an using block. This ensures that the connection trash collection occurs. Call a stored procedure for doing the transaction processing. Put error handling in the stored procedure and catch for it in your c# code. rebuild your indexes. maybe one of the indexes is corrupt. you can also disable the primary key and complete all the transactions and then enable it.

ALTER INDEX constraint_name ON table_name DISABLE;

check for duplicates before enabling the primary key

ALTER INDEX Index_Name ON Table_Name REBUILD

ALTER INDEX constraint_name ON table_name ENABLE;
Golden Lion
  • 3,840
  • 2
  • 26
  • 35
  • I'm not using async calls. In fact I suspect that in the first place, but the logs shows the ThreadId and that's only one thread executing and saving transactions – Frank Jul 06 '22 at 21:06
  • did you move the logger.LogInformation before the transaction in SaveTransaction? I want to see if your application is calling the function twice – Golden Lion Jul 06 '22 at 21:56
  • The `logger.LogInformation` is not called twice, I already check that. I put that code to check the multiple execution scenarios, so if the `cmd.ExecuteNonQuery();` is executed twice this should be reflected in the logs. – Frank Jul 07 '22 at 17:05
0

Just for remember, to ENABLE the INDEX again, you need execute the script below:

ALTER INDEX Index_Name ON Table_Name REBUILD;

tiagorockman
  • 341
  • 2
  • 6