0

I have this statement block with transaction and try/catch:

begin try
    begin transaction;
        insert ....
        update ....
        delete ....
    commit transaction;
end try
begin catch
    if (@@trancount > 0)
        rollback transaction;
    throw;
end catch;

I am using @@trancount here without fully understanding what happens. Why exactly could it ever happen that @@trancount is zero in this case?

askolotl
  • 964
  • 1
  • 13
  • 27
  • 2
    One example would be a trigger called by the `INSERT` that rolls back the transaction and throws an error caught by `catch` – Martin Smith May 17 '20 at 10:27

1 Answers1

2

The most common scenario to receive @@trancount = 0 requires a bit more elaborated logic than your sample uses.

If you have other stored procedures called in the try block, they might have their own understanding of how transactions should be managed, and either due to a poorly written code, or some other mishap, they can either commit the outer transaction by accident, or roll it back (remember, there is no really such thing in SQL Server as a nested transaction, so any rollback statement that doesn't reference a previously declared savepoint wipes out everything). The error in this case might vary, either the one that caused the inner procedure to misbehave in the first place, or if nothing else, you'll get error 266, "Transaction count after EXECUTE indicates a mismatching number of BEGIN and COMMIT statements. Previous count = %ld, current count = %ld."

Note that this behaviour can also be caused by rollback done by a trigger, as well.

It is possible that there might be some other situations when you end up with no current transaction in the catch block, but apparently they are so rare that I can't think of anything else off the top of my head.

Personally, I use the following template for all my stored procedures whenever possible:

create procedure dbo.ProcTemplate
(
    @Error int = null output,
    @Message nvarchar(2048) = null output
) as

/*



20191223, RW - to be completed
*/

set nocount, quoted_identifier, ansi_nulls, ansi_warnings, ansi_padding, concat_null_yields_null, arithabort on;
set xact_abort, implicit_transactions, numeric_roundabort off;


declare @XTran bit = cast(sign(@@trancount) as bit);

begin try

if @XTran = 0
    begin tran;


-- Put your code here


if @XTran = 0
    commit;

end try
begin catch

if nullif(@Error, 0) is null
    select @Error = error_number(), @Message = error_message();

if @@trancount > 0 and @XTran = 0
    rollback;

end catch;
return;
go

One can argue that explicitly issuing set xact_abort off might result in some unpleasant side effects, such as a batch-terminating error (208, for instance) skips the catch and leaves the current transaction open. That's up to you; the trade-offs here are:

  • Better diagnostic. When all stored procs in a database follow this template, they bubble up the error to the outermost procedure by the means of output parameters and gracefully rollback everything.
  • Possibility to continue execution after the error. For example, log the error after the transaction has been rolled back and make sure the log record won't disappear with the rest of transaction.
Roger Wolf
  • 7,307
  • 2
  • 24
  • 33
  • 2
    A system error such as deadlock would not cause this. [An error that ordinarily ends a transaction outside a TRY block causes a transaction to enter an uncommittable state when the error occurs inside a TRY block.](https://learn.microsoft.com/en-us/sql/t-sql/language-elements/try-catch-transact-sql?view=sql-server-ver15#uncommittable-transactions-and-xact_state) so `@@trancount` would still be `1` but the transaction is doomed – Martin Smith May 17 '20 at 11:08
  • 1
    @MartinSmith, interesting. Yes, I've never delved into much details on this, being content with the approach I use, but doesn't it compromise the deadlock monitor? Suppose I have a long running code in the `catch` (CLR function that reports the error to an external service, assuming it's allowed). Last time I checked, locks acquired by transaction are held until its end, one way or another. So, potentially deadlock resolution doesn't really resolve anything in this case - the winner still has to wait? – Roger Wolf May 17 '20 at 13:32
  • To be absolutely precise, wouldn't it be even better to store the @@transcount into a variable directly after `begin transaction`, and then rollback only then the @@trancount is the same as the stored value? Because it could happen that my procedure is called by another one, and then @@transcount would already be 1 when my procedure is entered. – askolotl May 17 '20 at 16:51
  • Yes it could compromise deadlock handling. If the `CATCH` block does something time intensive before rolling back the tran then it will add a delay to the time before the locks are released - but it is just blocking rather than a deadlock now because when the transaction does eventually finish the situation will be resolved – Martin Smith May 17 '20 at 17:25
  • @askolotl, that's exactly what the `@XTran` variable is for, in my template. When procedure detects outer transaction, it doesn't try to do anything with it, leaving the final resolution to the outermost layer (be it entry point procedure or the client side). – Roger Wolf May 17 '20 at 23:44
  • @RogerWolf Ah yes, as I understand, your variable `@XTran`prevents **nesting** of transactions. Only when it is zero (which happens only when @@trancount is zero) then a `begin tran` is started. – askolotl May 18 '20 at 10:54