Tuesday, June 23, 2026

When Msg 3991 Lies: A Swallowed Permissions Error in SQLCLR

How a misleading transaction-nesting error hid the real culprit — a denied EXECUTE permission — and how Extended Events finally surfaced it.

Intro

Msg 3991, Level 16, State 1, Procedure dbo.Demo_BuildParts, Line 6 [Batch Start Line 100]

The context transaction which was active before entering user defined routine, trigger or aggregate "GetParts" has been ended inside of it, which is not allowed. Change application logic to enforce strict transaction nesting.

Last week one of our dev teams hit this while testing an application, and the message sent us chasing the wrong problem. It blames transaction nesting. The real cause is something the message never mentions: a SQLCLR function (written in VB.NET) was calling stored procedures the login didn’t have EXECUTE permission on.

The short version of what actually happened: the function’s Try/Catch caught those permission errors, swallowed them, and returned normally — but the failed calls had already ended the transaction the function was running in. When control came back to SQL Server, @@TRANCOUNT had dropped from 1 to 0, so the engine raised 3991 and complained about transaction nesting. The permission errors that caused all of it never surfaced.

The fix was almost anticlimactic: grant the login EXECUTE on the two procedures. But I wanted to know why the original errors vanished, and that pulled me into the genuinely confusing world of SQL Server error and transaction handling. Erland Sommarskog’s series is what made something click: Error and Transaction Handling in SQL Server

What follows is a reduced repro of the problem, plus the two questions it left me with.

The setup

In the real system this call chain is buried under a lot of code, which is part of why it was so hard to see. Stripped down, the pieces are simple: an INSERT fires an AFTER INSERT trigger, the trigger selects from a CLR table-valued function, and that function calls two stored procedures the user can’t execute.

First, the VB code behind the CLR function. Two things matter. GetParts() calls dbo.DeniedProc1 and dbo.DeniedProc2 through a small helper, RunProc; and RunProc wraps each call in a Try/Catch that, on any error, simply returns its (empty) list — no rethrow.

Imports System
Imports System.Collections
Imports System.Collections.Generic
Imports System.Data
Imports System.Data.SqlClient
Imports System.Data.SqlTypes
Imports System.Runtime.InteropServices
Imports Microsoft.SqlServer.Server

Public Class Demo3991

    ' Runs one proc on the context connection and returns the result list.
    ' On ANY error it returns the (empty) list from the catch
    Private Shared Function RunProc(ByVal procName As String) As List(Of String)
        Dim items As New List(Of String)
        Try
            Using conn As SqlConnection = New SqlConnection("context connection=true")
                conn.Open()
                Using cmd As SqlCommand = conn.CreateCommand()
                    cmd.CommandType = CommandType.StoredProcedure
                    cmd.CommandText = procName
                    cmd.ExecuteNonQuery()           ' EXECUTE denied -> raises 229
                End Using
            End Using
            items.Add(procName & ":ok")
            Return items
        Catch ex As Exception
            Return items                            ' <-- swallow + return the list
        End Try
    End Function

    ' CLR table-valued function
    <SqlFunction(FillRowMethodName:="FillRow", _
                 TableDefinition:="PartName nvarchar(100)", _
                 DataAccess:=DataAccessKind.Read)> _
    Public Shared Function GetParts() As IEnumerable
        Dim parts As New List(Of String)
        parts.AddRange(RunProc("dbo.DeniedProc1"))   ' 229 #1 (swallowed via Return, like the moldings proc)
        parts.AddRange(RunProc("dbo.DeniedProc2"))   ' continues -> 229 #2 (swallowed via Return, like the panel proc)
        Return parts                                  ' returns the (now empty) list normally
    End Function

    Public Shared Sub FillRow(ByVal obj As Object, <Out()> ByRef PartName As SqlString)
        PartName = New SqlString(CStr(obj))
    End Sub

End Class

Compile that into Demo3991.dll, then register the assembly and create the function:

USE MyBlogDB;    
GO

EXEC sp_configure 'clr enabled', 1; RECONFIGURE;
GO

/* Trust the DLL by hash (SQL Server 2017+, avoids signing / TRUSTWORTHY) */
DECLARE @hash varbinary(64) =
    (SELECT HASHBYTES('SHA2_512', BulkColumn)
     FROM OPENROWSET(BULK 'C:\temp\CLRDemo\Demo3991.dll', SINGLE_BLOB) AS f);
IF NOT EXISTS (SELECT 1 FROM sys.trusted_assemblies WHERE [hash] = @hash)
    EXEC sys.sp_add_trusted_assembly @hash, N'Demo3991';
GO


/* ---- CLR TVF that swallows on error ---- */
CREATE ASSEMBLY Demo3991 FROM 'C:\temp\CLRDemo\Demo3991.dll' WITH PERMISSION_SET = SAFE;
GO
CREATE FUNCTION dbo.GetParts()
RETURNS TABLE (PartName nvarchar(100))
AS EXTERNAL NAME Demo3991.[Demo3991].GetParts;
GO

 Create the two procedures the function calls:

/* ---- the two procs the user is NOT granted permissions to execute ---- */
CREATE PROCEDURE dbo.DeniedProc1 AS BEGIN SET NOCOUNT ON; SELECT 1 AS x; END
GO
CREATE PROCEDURE dbo.DeniedProc2 AS BEGIN SET NOCOUNT ON; SELECT 2 AS x; END
GO

Add the table whose INSERT starts everything, the destination table, and the AFTER INSERT trigger that calls GetParts():

/* ---- table whose INSERT kicks everything off ---- */
CREATE TABLE dbo.Orders_Demo (OrderID int IDENTITY PRIMARY KEY, Note nvarchar(50) NULL);
GO

/* ---- destination table ---- */
CREATE TABLE dbo.PartsInProcess (PartName nvarchar(100) NULL);
GO

/* ---- AFTER INSERT trigger (runs inside the INSERT's transaction) ---- */
CREATE TRIGGER dbo.trg_Orders_Demo ON dbo.Orders_Demo AFTER INSERT
AS
BEGIN
    SET NOCOUNT ON;
    INSERT INTO dbo.PartsInProcess (PartName)
    SELECT PartName FROM dbo.GetParts();
END
GO

Finally, create a test user with INSERT on the orders table but no EXECUTE on the two procedures — exactly the misconfiguration that bit us during testing:

/* 
   INSERT on Orders_Demo is all that's needed; the
   rest of the chain (MSTVF, CLR TVF, INSERT into PartsInProcess) is reached via
   ownership chaining. EXECUTE of the two procs over the CLR context connection
   is re-checked and denied -> error 229 (the CLR function opens a separate connection
   with context connection=true and runs the procs through it, 
   which behaves like dynamic SQL — a fresh execution context that breaks the chain).   */
CREATE USER dbg_demo_user WITHOUT LOGIN;
GRANT INSERT   ON dbo.Orders_Demo     TO dbg_demo_user;
DENY  EXECUTE  ON dbo.DeniedProc1     TO dbg_demo_user;
DENY  EXECUTE  ON dbo.DeniedProc2     TO dbg_demo_user;
GO

Reproducing the failure

 Now insert a row as that user:

EXECUTE AS USER = 'dbg_demo_user';
INSERT INTO dbo.Orders_Demo (Note) VALUES ('A');
GO
REVERT;
GO

And here is what comes back:

Msg 3991, Level 16, State 1, Procedure dbo.Demo_BuildParts, Line 6 [Batch Start Line 100]

The context transaction which was active before entering user defined routine, trigger or aggregate "GetParts" has been ended inside of it, which is not allowed. Change application logic to enforce strict transaction nesting.

Notice what’s missing: any mention of permissions. The message points squarely at transaction nesting, so that’s where we looked — hunting for places the transaction was opened and closed unevenly. We even asked Claude for help. After an hour or two it had us weighing rewrites of the CLR code and of the large outer transaction that wraps the insert. All of it was beside the point.

The reveal: Extended Events

What finally cracked it was capturing the errors the engine actually raised, instead of the one it chose to show us. An Extended Events session on error_reported does exactly that:

IF EXISTS (SELECT * FROM sys.server_event_sessions WHERE name='Debug_Demo3991')
    DROP EVENT SESSION Debug_Demo3991 ON SERVER;
GO
CREATE EVENT SESSION Debug_Demo3991 ON SERVER
ADD EVENT sqlserver.error_reported
    ( ACTION (sqlserver.tsql_stack, sqlserver.transaction_id, sqlserver.sql_text)
      WHERE ([severity] >= 11) )
ADD TARGET package0.ring_buffer;
GO
ALTER EVENT SESSION Debug_Demo3991 ON SERVER STATE = START;
GO

Run the insert again, then read the ring buffer:

/* ---- what the engine actually raised (expect two 229s + a 3991 per failing run) ---- */
;WITH xe AS (
    SELECT CONVERT(xml, t.target_data) AS x
    FROM sys.dm_xe_sessions s
    JOIN sys.dm_xe_session_targets t ON t.event_session_address = s.address
    WHERE s.name = 'Debug_Demo3991' AND t.target_name = 'ring_buffer'
)
SELECT  e.n.value('(@timestamp)[1]','datetime2')                        AS event_time,
        e.n.value('(data[@name="error_number"]/value)[1]','int')        AS error_number,
        e.n.value('(action[@name="transaction_id"]/value)[1]','bigint') AS transaction_id,
        e.n.value('(data[@name="message"]/value)[1]','nvarchar(max)')   AS message
FROM xe
CROSS APPLY xe.x.nodes('//event') AS e(n)
ORDER BY event_time, error_number;
GO

There they were: two “EXECUTE permission was denied” errors (229) sitting above the 3991. Granting the user EXECUTE on both procedures made the whole thing disappear.

Why did this happen?

That fixed it, but two questions stuck with me:

  • What rolled back the transaction inside the CLR function and produced the 3991?
  • Why did the 229 errors never come back to me?

There is no explicit rollback inside the CLR, so what rolled back the transaction?

Erland’s series has the answer. In Part Two he sorts errors into classes by how they behave.

Without TRY-CATCH With TRY-CATCH
SET XACT_ABORT OFF ON OFF ON ON or OFF OFF ON
Class Name Aborts Rolls Back Catchable Dooms transaction
1 Fatal errors Connection Yes No n/a
2 Batch-aborting Batch Yes Yes Yes
3 Batch-only aborting Batch No Yes Yes No Yes
4 Statement-terminating Statement Batch No Yes Yes No Yes
5 Terminates nothing at all Nothing No Yes Yes/No Yes
6 Compilation: syntax errors (Statement) No Yes No Yes
7 Compilation: binding errors Scope Batch No Yes Outer scope only No Yes
8 Compilation: optimisation Batch Yes Outer scope only Yes
9 Attention signal Batch No Yes No n/a
10 Informational and warning messages Nothing No No n/a
11 Uncatchable errors Varying Varying No n/a
12 Errors that only sets @@error No No No n/a

The error here aborts and rolls back the transaction; XACT_ABORT was off; and it isn’t a compilation error, which rules out his compilation class. Fatal errors terminate the connection, and ours clearly didn’t. That leaves batch abortion with rollback — errors that, with no CATCH handler on the stack, abort execution on the spot and roll back any open transaction.

The catch is that a permission error isn’t normally batch-aborting; on its own it’s only statement-terminating. So it behaved like a batch-aborting error here because of the context, not because that’s its nature. What promoted it, I believe, is trigger context. We can check that by calling the function outside a trigger:

GRANT SELECT ON GetParts TO dbg_demo_user;
EXECUTE AS USER = 'dbg_demo_user';
SET XACT_ABORT OFF;
BEGIN TRAN;
    SELECT * FROM dbo.GetParts();                 -- DeniedProc1/2 -> 229 (swallowed) -> empty result
    SELECT @@TRANCOUNT AS TranCount_StillOpen;    -- expect 1  (transaction survived)
COMMIT;                                           -- expect success
REVERT;
GO

No 3991, and no permission error surfaces either (more on that next). The transaction is still open returned from the call (no transaction rollback within the CLR) and the result set is simply empty.


Why didn’t the 229 errors come back?

Because the function ate them. Look at the catch again:


Catch ex As Exception
    Return items
End Try

There’s no Throw. A caught exception that’s never rethrown doesn’t propagate, so the 229 never travels from the function up to the trigger, let alone back to us. 

But swallowing the exception only hides the error; it does nothing about the side effect, which is the rolled-back transaction. When control returns to the outer INSERT and SQL Server finds the transaction it was counting on gone, it raises the only complaint it has left: 3991. And that is what costs us a few hours of troubleshooting because of the misleading error message.

Takeaway

  • Don’t swallow errors, whatever your reason — users deserve to know what’s actually going on, and an error message that’s only reacting to the damage can confuse everyone.
  • error_reported in Extended Events shows you everything that was raised — not just the last one left standing.
  • There’s another case where 3991 is thrown and the original error never surfaces: when the error is both batch- and transaction-aborting and there’s explicit ROLLBACK inside the SQLCLR (Error and Transaction Handling in SQL Server Appendix 2: CLR Modules)

Conclusion

In my opinion, I don't like using CLR. The safest default is to reach for CLR only when T-SQL genuinely can't do the job. It isn't that CLR caused this bug — the swallowed exception and a missing GRANT did — but CLR brings extra moving parts: an assembly security model you have to get right (SAFE vs. EXTERNAL_ACCESS/UNSAFE, and signing or trusting assemblies under clr strict security), and error handling that behaves differently across the managed/T-SQL boundary. In terms of the performance, it can be faster than T-SQL for CPU-bound work, but for data-access-heavy logic, set-based T-SQL is usually the better and simpler call. 

More to read:

Error and Transaction Handling in SQL Server

Cleanup code

ALTER EVENT SESSION Debug_Demo3991 ON SERVER STATE = STOP;
DROP EVENT SESSION Debug_Demo3991 ON SERVER;
DROP TRIGGER   dbo.trg_Orders_Demo;
DROP FUNCTION  dbo.GetParts;
DROP ASSEMBLY  Demo3991;
DROP TABLE     dbo.PartsInProcess;
DROP TABLE     dbo.Orders_Demo;
DROP PROCEDURE dbo.DeniedProc1;
DROP PROCEDURE dbo.DeniedProc2;
DROP USER      dbg_demo_user;

No comments:

Post a Comment