0

The following stored procedure is called from a .NET Forms application and generally works well but we've had a few occasions where the output parameter returns a value but later when we try and update the row, the row doesn't exist.

It's almost like the transaction is being rolled back but we still receive the id. Any suggestions ? This happens randomly and up until now we've been unable to identify why it happens.

    ALTER PROCEDURE [dbo].[spAdm_QueueSummaryAdd]
    @fknProductId           INT,
    @fknProductRunId        INT,
    @nMailDelay             INT,
    @sProductRunMailServer  VARCHAR(20),
    @sFileName              VARCHAR(100),
    @sStatusCode            VARCHAR(10),
    @nQueueSummaryId        int OUTPUT,     
    @sStatus                NVARCHAR(MAX) OUTPUT    

AS

BEGIN TRANSACTION

    --check if exists
    IF EXISTS
        (SELECT
            pknQueueSummaryId
         FROM
            QueueSummary
         WHERE
            fknProductId = @fknProductId AND
            fknProductRunId = @fknProductRunId)

    --already exists
    BEGIN
        SET @nQueueSummaryId = 0
        SET @sStatus = 'pknQueueSummaryId already exists'
    END

    ELSE
    BEGIN

        --Add
        INSERT INTO [QueueSummary]
        (
            fknProductId,
            fknProductRunId,
            dtDateCreated,
            nMailDelay,
            sProductRunMailServer,
            sFileName,
            nTransactions,
            sStatusCode,
            dtDateModified,
            sModUser
        )
        VALUES
        (
            @fknProductId,
            @fknProductRunId,
            GETDATE(),
            @nMailDelay,
            @sProductRunMailServer,
            @sFilename,
            0,
            @sStatusCode,
            GETDATE(),
            'Superuser'
        )

        SET @nQueueSummaryId = SCOPE_IDENTITY()
        SET @sStatus = 'Success'
    END

--error control
IF @@ERROR <> 0
BEGIN
    SET @nQueueSummaryId = 0
    SET @sStatus = ERROR_MESSAGE()
    ROLLBACK TRANSACTION
    RETURN
END

--commit transaction
COMMIT TRANSACTION

For clarity here is the table definition

CREATE TABLE [dbo].[QueueSummary](
    [pknQueueSummaryId] [int] IDENTITY(1,1) NOT NULL,
    [fknProductRunId] [int] NOT NULL,
    [fknProductId] [int] NOT NULL,
    [nMailDelay] [int] NOT NULL,
    [sProductRunMailServer] [varchar](20) NOT NULL,
    [sFileName] [varchar](255) NOT NULL,
    [nTransactions] [int] NOT NULL,
    [sStatusCode] [varchar](10) NOT NULL,
    [dtDateCreated] [datetime] NOT NULL,
    [dtDateModified] [datetime] NOT NULL,
    [sModUser] [nchar](10) NOT NULL,
 CONSTRAINT [PK_QueueSummary] PRIMARY KEY CLUSTERED 
(
    [pknQueueSummaryId] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, FILLFACTOR = 90) ON [PRIMARY]
) ON [PRIMARY]
GO

And here is C# code that calls the stored proc

 public int Add(int fknProductId,
                          int fknProductRunId,
                          int nMailDelay,
                          string sProductRunMailServer,
                          string sFileName,
                          string sStatusCode)

        {
            //declare
            SqlCommand Command = null;
            SqlConnection Connection = null;


            try
            {
                //init
                Connection = new SqlConnection(_store.ConnectionString);
                Command = new SqlCommand("spAdm_QueueSummaryAdd", Connection);
                Command.CommandType = CommandType.StoredProcedure;
                Command.CommandTimeout = _store.nCommandTimeout;

                //add parameters
                SqlParameter par_fknProductId = new SqlParameter("@fknProductId", SqlDbType.Int);
                par_fknProductId.Direction = ParameterDirection.Input;
                par_fknProductId.Value = fknProductId;
                Command.Parameters.Add(par_fknProductId);

                //add parameters
                SqlParameter par_fknProductRunId = new SqlParameter("@fknProductRunId", SqlDbType.Int);
                par_fknProductRunId.Direction = ParameterDirection.Input;
                par_fknProductRunId.Value = fknProductRunId;
                Command.Parameters.Add(par_fknProductRunId);

                //add parameters
                SqlParameter par_nMailDelay = new SqlParameter("@nMailDelay", SqlDbType.Int);
                par_nMailDelay.Direction = ParameterDirection.Input;
                par_nMailDelay.Value = nMailDelay;
                Command.Parameters.Add(par_nMailDelay);

                //add parameters
                SqlParameter par_sProductRunMailServer = new SqlParameter("@sProductRunMailServer", SqlDbType.VarChar,20);
                par_sProductRunMailServer.Direction = ParameterDirection.Input;
                par_sProductRunMailServer.Value = sProductRunMailServer;
                Command.Parameters.Add(par_sProductRunMailServer);

                //add parameters
                SqlParameter par_sFileName = new SqlParameter("@sFileName", SqlDbType.VarChar, 100);
                par_sFileName.Direction = ParameterDirection.Input;
                par_sFileName.Value = sFileName;
                Command.Parameters.Add(par_sFileName);

                //add parameters
                SqlParameter par_sStatusCode = new SqlParameter("@sStatusCode", SqlDbType.VarChar, 10);
                par_sStatusCode.Direction = ParameterDirection.Input;
                par_sStatusCode.Value = sStatusCode;
                Command.Parameters.Add(par_sStatusCode);

                //add output parameter
                SqlParameter par_nQueueSummaryId = new SqlParameter("@nQueueSummaryId", SqlDbType.Int);
                par_nQueueSummaryId.Direction = ParameterDirection.Output;
                Command.Parameters.Add(par_nQueueSummaryId);

                SqlParameter par_sStatus = new SqlParameter("@sStatus", SqlDbType.NVarChar, -1);
                par_sStatus.Direction = ParameterDirection.Output;
                Command.Parameters.Add(par_sStatus);

                //execute
                Connection.Open();
                Command.ExecuteNonQuery();

                if(Command.Parameters["@sStatus"].Value.ToString() != "Success")
                {
                    _errorHandler.LogError("edd.Admin.QueueSummary Add() Status=" + Command.Parameters["@sStatus"].Value.ToString() + " |fknProductId=" + fknProductId + " |fknProductRunId=" + fknProductRunId + " |nMailDelay=" + nMailDelay + " |sProductRunMailServer=" + sProductRunMailServer + " |sFileName=" + sFileName +" |@nQueueSummaryId=" + Command.Parameters["@nQueueSummaryId"].Value  +  " |sStatusCode=" + sStatusCode);
                }

                return Convert.ToInt32(Command.Parameters["@nQueueSummaryId"].Value); 


            }
            catch (Exception errExp)
            {
                _errorHandler.LogError(errExp);
                return 0;
            }
            finally
            {
                //dispose objects
                if (Connection != null)
                {
                    if (Connection.State != ConnectionState.Closed)
                    {
                        Connection.Close();
                    }
                    Connection.Dispose();
                }
                if (Command != null)
                {
                    Command.Dispose();
                }
            }
        }
Lowkey
  • 25
  • 6
  • Insert will fail if the row already exists in the database when the primary keys already exist. So with Insert you must check if the number of rows changed > 0 (or verify the primary key exists) and then do a Update to change existing row. – jdweng Apr 06 '18 at 07:02
  • Primery key is auto incremented and not included in insert statement hence a new primery key will be created for each insert – Lowkey Apr 06 '18 at 07:03
  • You can try to see what happens registering to `SqlConnection.InfoMessage` event and read messages coming from the DB. If there are some errors you should have them logged there... – Seididieci Apr 06 '18 at 07:12
  • As this happens randomly, first try to fix your stored procedure so that it [does not suffer from race conditions](https://stackoverflow.com/q/3407857/11683) which it currently does. – GSerg Apr 06 '18 at 07:15
  • Then change : INSERT INTO [QueueSummary] to an Update. This statement is in the code where the item exists. You cannot use an INSERT, Must be UPDATE. – jdweng Apr 06 '18 at 07:55
  • @jdweng please note `[pknQueueSummaryId] [int] IDENTITY(1,1) NOT NULL,` is the primary key and will auto increment upon insert – Lowkey Apr 06 '18 at 08:02
  • You have an IF that test if value exists. Yuu should replace these two statements with an UPDATE : SET '@nQueueSummaryId = 0 SET '@sStatus = 'pknQueueSummaryId already exists'. It is possible that nQueueSummaryId will change before sStatus is set. Did you check data base to see if any values had : 'pknQueueSummaryId already exists' – jdweng Apr 06 '18 at 09:25
  • Just a thought... you may want to edit the question to add a 'sql-server' tag to help distinguish it from other SQL variants. – Richardissimo Apr 06 '18 at 20:49

1 Answers1

1

@@error returns the success or failure of the last statement, which was not the INSERT statement (I think you're assuming it was). Combined with the race condition, that's your problem. Allow SQL to do its job (with the benefit of simplifying your code) as follows...

Add either a unique constraint or a unique index on the pair of columns fknProductId and fknProductRunId rather than trying to check it yourself. (If you select records from this table by supplying those two columns, go for the index.)

Remove all the transactional bits, since the only thing left in there is the insert, which will have an implicit atomic transaction.

Remove the two output variables. The status is derivable from the other value.

Select Scope_Identity() after the Insert. The calling code can do ExecuteScalar to get the identity value back.

You should be left with a stored proc which just does the insert and then the select.

The calling code can handle the duplicate write SqlException if the pair of values is already in use.

Richardissimo
  • 5,596
  • 2
  • 18
  • 36
  • Bonus tips now that you've posted your code... SqlConnection and SqlCommand are disposable so put them in `using ` blocks rather than try...finally Dispose. (And the Dispose already handles the Close). Since you now only have input parameters, you can do this.... `Command.Parameters.Add(new SqlParameter("@sStatusCode", SqlDbType.VarChar, 10){Value=*whatever*});` – Richardissimo Apr 06 '18 at 07:48
  • Thanks for your input, still doesn't explain why we're receiving an identity which has not been inserted successfully. – Lowkey Apr 06 '18 at 07:50
  • Agree `using` would be a better way to handle the disposing of the `SqlConnection` and `SqlCommand`. This `Command.Parameters.Add(new SqlParameter("@sStatusCode", SqlDbType.VarChar, 10){Value=*whatever*});` however is a little less readable than adding the parameters one by one in my opinion – Lowkey Apr 06 '18 at 07:55
  • @Lowkey @@error returns the success or failure of the last statement, which was not the INSERT. Combined with the race condition, that's your problem. – Richardissimo Apr 06 '18 at 10:28
  • Thanks will move the error check to right after insert – Lowkey Apr 06 '18 at 11:01
  • Where do you see the race condition ? – Lowkey Apr 06 '18 at 11:09
  • @Lowkey Doing the Select and Insert separately. Let SQL do the uniqueness check for you, as I have explained, with either a unique constraint or unique index. – Richardissimo Apr 06 '18 at 11:16
  • SCOPE_IDENTITY Actually returns the id even if the insert fails – Lowkey Apr 06 '18 at 12:06
  • @Lowkey There you go, so the insert failed due to the race condition, the identity got stored in the out parameter, @@error returned zero so the code thought it was successful when it wasn't. Good stuff. – Richardissimo Apr 06 '18 at 20:46