Совместное использование XACT_ABORT и TRY CATCH в SQL Server прерывает откат tSQLt
Я начинаю работать с модульными тестами tSQLt для SQL Server в моем производственном коде. В настоящее время я использую шаблон обработки ошибок Erland Sommarskog для SQL Server.
USE TempDB;
SET ANSI_NULLS, QUOTED_IDENTIFIER ON;
GO
IF OBJECT_ID('dbo.SommarskogRollback') IS NOT NULL
DROP PROCEDURE dbo.SommarskogRollback;
GO
CREATE PROCEDURE dbo.SommarskogRollback
AS
BEGIN; /*Stored Procedure*/
SET XACT_ABORT, NOCOUNT ON;
BEGIN TRY;
BEGIN TRANSACTION;
RAISERROR('This is just a test. Had this been an actual error, we would have given you some cryptic gobbledygook.', 16, 1);
COMMIT TRANSACTION;
END TRY
BEGIN CATCH;
IF @@TRANCOUNT > 0
ROLLBACK TRANSACTION;
THROW;
END CATCH;
END; /*Stored Procedure*/
GO
Erland Sommarskog рекомендует всегда устанавливать SET XACT_ABORT ON, потому что только тогда SQL Server обрабатывает ошибки (в основном) согласованным образом.
Это создает проблему при использовании tSQLt. tSQLt выполняет все тесты внутри явной транзакции. Когда тесты завершены, вся транзакция откатывается. Это делает очистку тестовых артефактов совершенно безболезненной. Однако с XACT_ABORT ON любая ошибка, выданная в блоке TRY, немедленно обрекает эту транзакцию. Транзакция должна полностью откатиться. Он не может зафиксировать и не может вернуться к точке сохранения. Фактически, ничто не может записать в журнал транзакций внутри этого сеанса, пока транзакция не будет откатана. Тем не менее, tSQLt не может правильно отслеживать результаты теста, если транзакция не открыта по окончании теста. tSQLt прекращает выполнение и выдает ROLLBACK ERROR для обреченных транзакций. Неудачный тест показывает состояние "Ошибка" (а не "Успешно" или "Сбой"), а последующие тесты не запускаются.
Себастьян Майн, создатель tSQLt, рекомендует другой способ обработки ошибок.
USE TempDB;
SET ANSI_NULLS, QUOTED_IDENTIFIER ON;
GO
IF OBJECT_ID('dbo.MeineRollback') IS NOT NULL
DROP PROCEDURE dbo.MeineRollback;
GO
CREATE PROCEDURE dbo.MeineRollback
AS
BEGIN /*Stored Procedure*/
SET NOCOUNT ON;
/* We declare the error variables here, populate them inside the CATCH
* block and then do our error handling after exiting the CATCH block
*/
DECLARE @ErrorNumber INT
,@MessageTemplate NVARCHAR(4000)
,@ErrorMessage NVARCHAR(4000)
,@ErrorProcedure NVARCHAR(126)
,@ErrorLine INT
,@ErrorSeverity INT
,@ErrorState INT
,@RaisErrorState INT
,@ErrorLineFeed NCHAR(1) = CHAR(10)
,@ErrorStatus INT = 0
,@SavepointName VARCHAR(32) = REPLACE( (CAST(NEWID() AS VARCHAR(36))), '-', '');
/*Savepoint names are 32 characters and must be unique. UNIQUEIDs are 36, four of which are dashes.*/
BEGIN TRANSACTION; /*If a transaction is already in progress, this just increments the transaction count*/
SAVE TRANSACTION @SavepointName;
BEGIN TRY;
RAISERROR('This is a test. Had this been an actual error, Sebastian would have given you a meaningful error message.', 16, 1);
END TRY
BEGIN CATCH;
/* Build a message string with placeholders for the original error information
* Note: "%d" & "%s" are placeholders (substitution parameters) which capture
* the values from the argument list of the original error message.
*/
SET @MessageTemplate = N': Error %d, Severity %d, State %d, ' + @ErrorLineFeed
+ N'Procedure %s, Line %d, ' + @ErrorLineFeed
+ N', Message: %s';
SELECT @ErrorStatus = 1
,@ErrorMessage = ERROR_MESSAGE()
,@ErrorNumber = ERROR_NUMBER()
,@ErrorProcedure = ISNULL(ERROR_PROCEDURE(), '-')
,@ErrorLine = ERROR_LINE()
,@ErrorSeverity = ERROR_SEVERITY()
,@ErrorState = ERROR_STATE()
,@RaisErrorState = CASE ERROR_STATE()
WHEN 0 /*RAISERROR Can't generate errors with State = 0*/
THEN 1
ELSE ERROR_STATE()
END;
END CATCH;
/*Rollback to savepoint if error occurred. This does not affect the transaction count.*/
IF @ErrorStatus <> 0
ROLLBACK TRANSACTION @SavepointName;
/*If this procedure executed inside a transaction, then the commit just subtracts one from the transaction count.*/
COMMIT TRANSACTION;
IF @ErrorStatus = 0
RETURN 0;
ELSE
BEGIN; /*Re-throw error*/
/*Rethrow the error. The msg_str parameter will contain the original error information*/
RAISERROR( @MessageTemplate /*msg_str parameter as message format template*/
,@ErrorSeverity /*severity parameter*/
,@RaisErrorState /*state parameter*/
,@ErrorNumber /*argument: original error number*/
,@ErrorSeverity /*argument: original error severity*/
,@ErrorState /*argument: original error state*/
,@ErrorProcedure /*argument: original error procedure name*/
,@ErrorLine /*argument: original error line number*/
,@ErrorMessage /*argument: original error message*/
);
RETURN -1;
END; /*Re-throw error*/
END /*Stored Procedure*/
GO
Он объявляет переменные ошибки, начинает транзакцию, устанавливает точку сохранения и затем выполняет код процедуры внутри блока TRY. Если блок TRY выдает ошибку, выполнение переходит к блоку CATCH, который заполняет переменные ошибки. Затем выполнение выходит из блока TRY CATCH. При ошибке транзакция возвращается к точке сохранения, установленной в начале процедуры. Затем транзакция фиксируется. Благодаря тому, что SQL Server обрабатывает вложенные транзакции, этот COMMIT просто вычитает одну из счетчика транзакций при выполнении внутри другой транзакции. (Вложенные транзакции действительно не существуют в SQL Server.)
Себастьян создал очень аккуратный и аккуратный узор. Каждая процедура в цепочке выполнения очищает свои собственные транзакции. К сожалению, у этой модели есть большая проблема: обреченные транзакции. Обреченные транзакции нарушают этот шаблон, потому что они не могут откатиться до точки сохранения или зафиксировать. Они могут только полностью откатиться. Это, конечно, означает, что вы не можете установить XACT_ABORT ON при использовании блоков TRY-CATCH (и вы всегда должны использовать блоки TRY-CATCH.) Даже при отключенном XACT_ABORT OFF многие ошибки, такие как ошибки компиляции, в любом случае обрекают транзакцию., Кроме того, точки сохранения не будут работать с распределенными транзакциями.
Как я могу обойти это? Мне нужен шаблон обработки ошибок, который будет работать в тестовой среде tSQLt, а также обеспечивать согласованную и корректную обработку ошибок в производственной среде. Я мог бы проверить среду во время выполнения и настроить поведение соответственно. (См. Пример ниже.) Однако мне это не нравится. Это похоже на хак для меня. Требуется, чтобы среды разработки были настроены последовательно. Хуже того, я не проверяю свой действующий производственный код. У кого-нибудь есть блестящее решение?
USE TempDB;
SET ANSI_NULLS, QUOTED_IDENTIFIER ON;
GO
IF OBJECT_ID('dbo.ModifiedRollback') IS NOT NULL
DROP PROCEDURE dbo.ModifiedRollback;
GO
CREATE PROCEDURE dbo.ModifiedRollback
AS
BEGIN; /*Stored Procedure*/
SET NOCOUNT ON;
IF RIGHT(@@SERVERNAME, 9) = '\LOCALDEV'
SET XACT_ABORT OFF;
ELSE
SET XACT_ABORT ON;
BEGIN TRY;
BEGIN TRANSACTION;
RAISERROR('This is just a test. Had this been an actual error, we would have given you some cryptic gobbledygook.', 16, 1);
COMMIT TRANSACTION;
END TRY
BEGIN CATCH;
IF @@TRANCOUNT > 0 AND RIGHT(@@SERVERNAME,9) <> '\LOCALDEV'
ROLLBACK TRANSACTION;
THROW;
END CATCH;
END; /*Stored Procedure*/
GO
РЕДАКТИРОВАТЬ: После дальнейшего тестирования я обнаружил, что мой модифицированный откат тоже не работает. Когда процедура выдает ошибку, она завершается без отката или фиксации. tSQLt выдает ошибку, потому что @@TRANCOUNT при выходе из процедуры не совпадает со счетчиком при запуске процедуры. После некоторых проб и ошибок я нашел обходной путь, который работает в моих тестах. Он объединяет два подхода к обработке ошибок - делает обработку ошибок гораздо более сложной, и некоторые пути кода не могут быть протестированы. Я хотел бы найти лучшее решение.
USE TempDB;
SET ANSI_NULLS, QUOTED_IDENTIFIER ON;
GO
IF OBJECT_ID('dbo.TestedRollback') IS NOT NULL
DROP PROCEDURE dbo.TestedRollback;
GO
CREATE PROCEDURE dbo.TestedRollback
AS
BEGIN /*Stored Procedure*/
SET NOCOUNT ON;
/* Due to the way tSQLt uses transactions and the way SQL Server handles errors, we declare our error-handling
* variables here, populate them inside the CATCH block and then do our error-handling after exiting
*/
DECLARE @ErrorStatus BIT
,@ErrorNumber INT
,@MessageTemplate NVARCHAR(4000)
,@ErrorMessage NVARCHAR(4000)
,@ErrorProcedure NVARCHAR(126)
,@ErrorLine INT
,@ErrorSeverity INT
,@ErrorState INT
,@RaisErrorState INT
,@ErrorLineFeed NCHAR(1) = CHAR(10)
,@FALSE BIT = CAST(0 AS BIT)
,@TRUE BIT = CAST(1 AS BIT)
,@tSQLtEnvironment BIT
,@SavepointName VARCHAR(32) = REPLACE( (CAST(NEWID() AS VARCHAR(36))), '-', '');
/*Savepoint names are 32 characters long and must be unique. UNIQUEIDs are 36, four of which are dashes*/
/* The tSQLt Unit Testing Framework we use in our local development environments must maintain open transactions during testing. So,
* we don't roll back transactions during testing. Also, doomed transactions can't stay open, so we SET XACT_ABORT OFF while testing.
*/
IF RIGHT(@@SERVERNAME, 9) = '\LOCALDEV'
SET @tSQLtEnvironment = @TRUE
ELSE
SET @tSQLtEnvironment = @FALSE;
IF @tSQLtEnvironment = @TRUE
SET XACT_ABORT OFF;
ELSE
SET XACT_ABORT ON;
BEGIN TRY;
SET ROWCOUNT 0; /*The ROWCOUNT setting can be updated outside the procedure and changes its behavior. This sets it to the default.*/
SET @ErrorStatus = @FALSE;
BEGIN TRANSACTION;
/*We need a save point to roll back to in the tSQLt Environment.*/
IF @tSQLtEnvironment = @TRUE
SAVE TRANSACTION @SavepointName;
RAISERROR('Cryptic gobbledygook.', 16, 1);
COMMIT TRANSACTION;
RETURN 0;
END TRY
BEGIN CATCH;
SET @ErrorStatus = @TRUE;
/* Build a message string with placeholders for the original error information
* Note: "%d" & "%s" are placeholders (substitution parameters) which capture
* the values from the argument list of the original error message.
*/
SET @MessageTemplate = N': Error %d, Severity %d, State %d, ' + @ErrorLineFeed
+ N'Procedure %s, Line %d, ' + @ErrorLineFeed
+ N', Message: %s';
SELECT @ErrorMessage = ERROR_MESSAGE()
,@ErrorNumber = ERROR_NUMBER()
,@ErrorProcedure = ISNULL(ERROR_PROCEDURE(), '-')
,@ErrorLine = ERROR_LINE()
,@ErrorSeverity = ERROR_SEVERITY()
,@ErrorState = ERROR_STATE()
,@RaisErrorState = CASE ERROR_STATE()
WHEN 0 /*RAISERROR Can't generate errors with State = 0*/
THEN 1
ELSE ERROR_STATE()
END;
END CATCH;
/* Due to the way the tSQLt test framework uses transactions, we use two different error-handling schemes:
* one for unit-testing and the other for our main Test/Staging/Production environments. In those environments
* we roll back transactions in the CATCH block in the event of an error. In unit-testing, on the other hand,
* we begin a transaction and set a save point. If an error occurs we roll back to the save point and then
* commit the transaction. Since tSQLt executes all test in a single explicit transaction, starting a
* transaction at the beginning of this stored procedure just adds one to @@TRANCOUNT. Committing the
* transaction subtracts one from @@TRANCOUNT. Rolling back to a save point does not affect @@TRANCOUNT.
*/
IF @ErrorStatus = @TRUE
BEGIN; /*Error Handling*/
IF @tSQLtEnvironment = @TRUE
BEGIN; /*tSQLt Error Handling*/
ROLLBACK TRANSACTION @SavepointName; /*Rolls back to save point but does not affect @@TRANCOUNT*/
COMMIT TRANSACTION; /*Subtracts one from @@TRANCOUNT*/
END; /*tSQLt Error Handling*/
ELSE IF @@TRANCOUNT > 0
ROLLBACK TRANSACTION;
/*Rethrow the error. The msg_str parameter will contain the original error information*/
RAISERROR( @MessageTemplate /*msg_str parameter as message format template*/
,@ErrorSeverity /*severity parameter*/
,@RaisErrorState /*state parameter*/
,@ErrorNumber /*argument: original error number*/
,@ErrorSeverity /*argument: original error severity*/
,@ErrorState /*argument: original error state*/
,@ErrorProcedure /*argument: original error procedure name*/
,@ErrorLine /*argument: original error line number*/
,@ErrorMessage /*argument: original error message*/
);
END; /*Error Handling*/
END /*Stored Procedure*/
GO
1 ответ
Я тестирую исправление для этого, которое модифицирует каркасную процедуру tSQLt.Private_RunTest. В основном, в первичном блоке CATCH, в котором он пытается выполнить именованный откат (строка 1448 для меня), я заменяю
ROLLBACK TRAN @TranName;
с
IF XACT_STATE() = 1 -- transaction is active
ROLLBACK TRAN @TranName; -- execute original code
ELSE IF XACT_STATE() = -1 -- transaction is doomed; cannot be partially rolled back
ROLLBACK; -- fully roll back
IF (@@TRANCOUNT = 0)
BEGIN TRAN; -- restart transaction to fulfill expectations below
Предварительное тестирование выглядит хорошо. Оставайтесь в курсе. (Я отправлю git после того, как получу больше уверенности в предложенном редактировании.)