Avoid External Dependencies in SQL Server Triggers

Problem

I sometimes want to perform auditing or other actions in a trigger based on some criteria. More specifically, there are a few cases that may warrant an e-mail; for example, if a web sale takes place that requires custom or overnight shipping and handling. It is tempting to just add code to the trigger that sends an e-mail when these criteria are met. But this can be problematic for two reasons: (1) your users are waiting for that processing to occur, and (2) if you can’t send the e-mail, how do you decide whether or not to roll back the transaction, and how do you bring the problem to the attention of the administrator?

Solution

Since anything you do in a trigger is going to hold up the statement that caused the trigger to fire, you should always go out of your way to make sure your processing is as efficient as possible. This is especially true for processes that require waiting for some external event to occur, or that are prone to errors. In the past, the various approaches we’ve had for sending e-mail from SQL Server – SQL Mail, the sp_OA procedures using CDO, and even Gert Drapers’ xp_smtp_sendmail – have all been problematic in their own way: the local mail profile is corrupt, the server has been migrated to 64-bit hardware, the sp_OA procedures are slow, OLE automation has been disabled, or the SMTP server is down.

These days, most DBAs use Database Mail which, because it uses Service Broker, does not exhibit any of these problems – it places your message on an asynchronous queue, and returns control almost immediately. But it is still questionable whether these e-mails should be initiated from within the trigger at all – not just because we want to squeeze every ounce of performance out of our user-visible operations, but also because there is no trivial way to deal with send failures unless we monitor the send queue from within the trigger.

So a typical starting point would be a table and trigger that looks like this:

<span style="color: blue;">CREATE TABLE </span><span style="color: black;">dbo.Orders<br></span><span style="color: gray;">(<br>   </span><span style="color: black;">OrderID </span><span style="color: blue;">INT PRIMARY KEY</span><span style="color: gray;">,<br><br>   </span><span style="color: green;">/* other columns */<br><br>   </span><span style="color: black;">ShippingMethod </span><span style="color: blue;">VARCHAR</span><span style="color: gray;">(</span><span style="color: black;">10</span><span style="color: gray;">) NOT NULL<br>    </span><span style="color: blue;">CHECK </span><span style="color: gray;"><br></span><span style="color: blue;">   </span> <span style="color: gray;">(<br></span><span style="color: blue;">   </span><span style="color: blue;">   </span><span style="color: gray;"></span><span style="color: black;">ShippingMethod </span><span style="color: blue;">IN<br>      </span><span style="color: gray;">(<br>       </span><span style="color: red;">'Express'</span><span style="color: gray;">, </span><span style="color: red;">'Normal'</span><span style="color: gray;">, </span><span style="color: red;">'Slow'<br>      </span><span style="color: gray;">)</span><br><span style="color: blue;">    </span><span style="color: gray;">)<br>);<br></span><span style="color: blue;">GO</span><br><br><span style="color: blue;">CREATE TRIGGER </span><span style="color: black;">dbo.Order_ShippingNotify<br>   </span><span style="color: blue;">ON </span><span style="color: black;">dbo.Orders<br>   </span><span style="color: blue;">FOR UPDATE</span><span style="color: gray;">, </span><span style="color: blue;">INSERT<br>AS<br>BEGIN<br>   SET NOCOUNT ON</span><span style="color: gray;">;<br><br>   </span><span style="color: blue;">DECLARE <br>       </span><span style="color: rgb(67, 67, 67);">@OrderID </span><span style="color: blue;">INT</span><span style="color: gray;">, <br>       </span><span style="color: rgb(67, 67, 67);">@body    </span><span style="color: blue;">NVARCHAR</span><span style="color: gray;">(</span><span style="color: magenta;">MAX</span><span style="color: gray;">);<br><br>   </span><span style="color: blue;">DECLARE </span><span style="color: black;">c </span><span style="color: blue;">CURSOR <br>       </span><span style="color: blue;">LOCAL FORWARD_ONLY STATIC READ_ONLY<br>       </span><span style="color: blue;">FOR<br>       SELECT </span><span style="color: black;">i.OrderID<br>           </span><span style="color: blue;">FROM <br>               </span><span style="color: black;">inserted </span><span style="color: blue;">AS </span><span style="color: black;">i<br>           </span><span style="color: magenta;">LEFT </span><span style="color: gray;">OUTER </span><span style="color: blue;">JOIN <br>               </span><span style="color: black;">deleted </span><span style="color: blue;">AS </span><span style="color: black;">d<br>               </span><span style="color: blue;">ON </span><span style="color: black;">i.OrderID </span><span style="color: blue;">= </span><span style="color: black;">d.OrderID<br>           </span><span style="color: blue;">WHERE <br>               </span><span style="color: black;">i.ShippingMethod </span><span style="color: blue;">= </span><span style="color: red;">'Express'<br>               </span><span style="color: gray;">AND<br>               (<br>                   </span><span style="color: black;">d.OrderID </span><span style="color: blue;">IS </span><span style="color: gray;">NULL <br>                   OR </span><span style="color: black;">d.ShippingMethod </span><span style="color: gray;"><> </span><span style="color: red;">'Express'<br>               </span><span style="color: gray;">);<br><br>   </span><span style="color: blue;">OPEN </span><span style="color: black;">c</span><span style="color: gray;">;<br><br>   </span><span style="color: blue;">FETCH </span><span style="color: black;">NEXT </span><span style="color: blue;">FROM </span><span style="color: black;">c </span><span style="color: blue;">INTO </span><span style="color: rgb(67, 67, 67);">@OrderID</span><span style="color: gray;">;<br>   <br>   </span><span style="color: blue;">WHILE </span><span style="color: magenta;">@@FETCH_STATUS </span><span style="color: blue;">= </span><span style="color: black;">0<br>   </span><span style="color: blue;">BEGIN<br>       SET </span><span style="color: rgb(67, 67, 67);">@body </span><span style="color: blue;">= </span><span style="color: red;">N'OrderID: ' </span><span style="color: gray;">+ </span><span style="color: magenta;">CONVERT</span><span style="color: gray;">(</span><span style="color: blue;">VARCHAR</span><span style="color: gray;">(</span><span style="color: black;">12</span><span style="color: gray;">), </span><span style="color: rgb(67, 67, 67);">@OrderID</span><span style="color: gray;">);<br><br>       </span><span style="color: blue;">EXEC </span><span style="color: black;">msdb.dbo.</span><span style="color: darkred;">sp_send_dbmail<br>           </span><span style="color: rgb(67, 67, 67);">@recipients </span><span style="color: blue;">= </span><span style="color: red;">'shipper@yourcompany.com'</span><span style="color: gray;">,<br>           </span><span style="color: rgb(67, 67, 67);">@subject    </span><span style="color: blue;">= </span><span style="color: red;">N'Order has expedited shipping.'</span><span style="color: gray;">,<br>           </span><span style="color: rgb(67, 67, 67);">@body       </span><span style="color: blue;">= </span><span style="color: rgb(67, 67, 67);">@body</span><span style="color: gray;">;<br><br>       </span><span style="color: blue;">FETCH </span><span style="color: black;">NEXT </span><span style="color: blue;">FROM </span><span style="color: black;">c </span><span style="color: blue;">INTO </span><span style="color: rgb(67, 67, 67);">@OrderID</span><span style="color: gray;">;<br>   </span><span style="color: blue;">END<br><br>   CLOSE </span><span style="color: black;">c</span><span style="color: gray;">;<br>   </span><span style="color: blue;">DEALLOCATE </span><span style="color: black;">c</span><span style="color: gray;">;<br></span><span style="color: blue;">END<br></span><span style="color: blue;">GO</span>

Note that, because we need to execute a stored procedure for each row (since the trigger can be fired for an update that affects more than one row), we need to use a cursor to loop through and handle them one at a time. So, in addition to sending e-mails in the first place, we also add the overhead of a cursor and a loop while the end user waits for the transaction to complete.

In my environments, I have opted to use a queue table and a background job to handle almost any post-DML operation that does not involve a decision to roll back the original statement from within the trigger. Some events, such as updating a last modified column, are usually simple enough to just keep in the trigger; and on their own, do not justify any additional effort. But I would suggest that anything beyond the source table that is not a critical part of the initial transaction should be deferred to background processing when practical. I’ll stick to the e-mail example for now, but other candidates include fetching Active Directory properties for the user who initiated the operation, writing output to a file, or interacting with an external program.

The queue table itself can be quite simple – especially if it only needs to deal with a single type of post-trigger operation for one table (you can of course use a single queue to process as many different types of results as you want). In my case we wanted to keep track of the key (OrderID), the time the queue row was created, and the time the e-mail went out. So, for this example, it would look like this:

<span style="color: blue;">CREATE TABLE </span><span style="color: black;">dbo.OrderShippingQueue<br></span><span style="color: gray;">(<br>   </span><span style="color: black;">QueueID         </span><span style="color: blue;">INT IDENTITY</span><span style="color: gray;">(</span><span style="color: black;">1</span><span style="color: gray;">,</span><span style="color: black;">1</span><span style="color: gray;">) </span><span style="color: blue;">PRIMARY KEY</span><span style="color: gray;">,<br>   </span><span style="color: black;">OrderID         </span><span style="color: blue;">INT </span><span style="color: gray;">NOT NULL<br>                   </span><span style="color: blue;">FOREIGN KEY REFERENCES </span><span style="color: black;">dbo.Orders</span><span style="color: gray;">(</span><span style="color: black;">OrderID</span><span style="color: gray;">),<br>   </span><span style="color: black;">ObservationTime </span><span style="color: blue;">SMALLDATETIME </span><span style="color: gray;">NOT NULL<br>                   </span><span style="color: blue;">DEFAULT </span><span style="color: magenta;">CURRENT_TIMESTAMP</span><span style="color: gray;">,<br>   </span><span style="color: black;">ProcessTime    </span><span style="color: blue;"> SMALLDATETIME </span><span style="color: gray;">NULL<br>);</span>

And the stored procedure to process the queue, borrowing from the trigger above, simply looks for rows in the queue table with a NULL ProcessTime. This procedure can easily be scheduled using SQL Server Agent:

<span style="color: blue;">CREATE PROCEDURE </span><span style="color: black;">dbo.Order_ProcessShippingQueue<br></span><span style="color: blue;">AS<br>BEGIN<br>   SET NOCOUNT ON</span><span style="color: gray;">;<br><br>   </span><span style="color: blue;">DECLARE <br>       </span><span style="color: rgb(67, 67, 67);">@OrderID </span><span style="color: blue;">INT</span><span style="color: gray;">, <br>       </span><span style="color: rgb(67, 67, 67);">@body    </span><span style="color: blue;">NVARCHAR</span><span style="color: gray;">(</span><span style="color: magenta;">MAX</span><span style="color: gray;">);<br><br>   </span><span style="color: blue;">DECLARE </span><span style="color: black;">c </span><span style="color: blue;">CURSOR <br>       </span><span style="color: blue;">LOCAL FORWARD_ONLY STATIC READ_ONLY<br>       </span><span style="color: blue;">FOR<br>       SELECT </span><span style="color: black;">i.OrderID<br>           </span><span style="color: blue;">FROM </span><span style="color: black;">dbo.OrderShippingQueue<br>           </span><span style="color: blue;">WHERE </span><span style="color: black;">ProcessTime </span><span style="color: blue;">IS </span><span style="color: gray;">NULL;<br><br>   </span><span style="color: blue;">OPEN </span><span style="color: black;">c</span><span style="color: gray;">;<br><br>   </span><span style="color: blue;">FETCH </span><span style="color: black;">NEXT </span><span style="color: blue;">FROM </span><span style="color: black;">c </span><span style="color: blue;">INTO </span><span style="color: rgb(67, 67, 67);">@OrderID</span><span style="color: gray;">;<br>   <br>   </span><span style="color: blue;">WHILE </span><span style="color: magenta;">@@FETCH_STATUS </span><span style="color: blue;">= </span><span style="color: black;">0<br>   </span><span style="color: blue;">BEGIN<br>       SET </span><span style="color: rgb(67, 67, 67);">@body </span><span style="color: blue;">= </span><span style="color: red;">N'OrderID: ' </span><span style="color: gray;">+ </span><span style="color: magenta;">CONVERT</span><span style="color: gray;">(</span><span style="color: blue;">VARCHAR</span><span style="color: gray;">(</span><span style="color: black;">12</span><span style="color: gray;">), </span><span style="color: rgb(67, 67, 67);">@OrderID</span><span style="color: gray;">);<br><br>       </span><span style="color: blue;">EXEC </span><span style="color: black;">msdb.dbo.</span><span style="color: darkred;">sp_send_dbmail<br>           </span><span style="color: rgb(67, 67, 67);">@recipients </span><span style="color: blue;">= </span><span style="color: red;">'shipper@yourcompany.com'</span><span style="color: gray;">,<br>           </span><span style="color: rgb(67, 67, 67);">@subject    </span><span style="color: blue;">= </span><span style="color: red;">N'Order has expedited shipping.'</span><span style="color: gray;">,<br>           </span><span style="color: rgb(67, 67, 67);">@body       </span><span style="color: blue;">= </span><span style="color: rgb(67, 67, 67);">@body</span><span style="color: gray;">;<br><br>       </span><span style="color: blue;">UPDATE </span><span style="color: black;">dbo.OrderShippingQueue<br>           </span><span style="color: blue;">SET </span><span style="color: black;">ProcessTime </span><span style="color: blue;">= </span><span style="color: magenta;">CURRENT_TIMESTAMP<br>           </span><span style="color: blue;">WHERE </span><span style="color: black;">OrderID </span><span style="color: blue;">= </span><span style="color: rgb(67, 67, 67);">@OrderID</span><span style="color: gray;">;<br><br>       </span><span style="color: blue;">FETCH </span><span style="color: black;">NEXT </span><span style="color: blue;">FROM </span><span style="color: black;">c </span><span style="color: blue;">INTO </span><span style="color: rgb(67, 67, 67);">@OrderID</span><span style="color: gray;">;<br>   </span><span style="color: blue;">END<br><br>   CLOSE </span><span style="color: black;">c</span><span style="color: gray;">;<br>   </span><span style="color: blue;">DEALLOCATE </span><span style="color: black;">c</span><span style="color: gray;">;<br></span><span style="color: blue;">END<br></span><span style="color: blue;">GO</span>

And finally, the trigger can now be changed to the following, eliminating both the e-mail activity and the cursor for a much simpler, set-based insert into the queue table:

<span style="color: blue;">ALTER TRIGGER </span><span style="color: black;">dbo.Order_ShippingNotify<br>   </span><span style="color: blue;">ON </span><span style="color: black;">dbo.Orders<br>   </span><span style="color: blue;">FOR UPDATE</span><span style="color: gray;">, </span><span style="color: blue;">INSERT<br>AS<br>BEGIN<br>   SET NOCOUNT ON</span><span style="color: gray;">;<br><br>   </span><span style="color: blue;">INSERT </span><span style="color: black;">dbo.OrderShippingQueue</span><span style="color: gray;">(</span><span style="color: black;">OrderID</span><span style="color: gray;">)<br>   </span><span style="color: blue;">SELECT </span><span style="color: black;">i.OrderID<br>     </span><span style="color: blue;">FROM <br>       </span><span style="color: black;">inserted </span><span style="color: blue;">AS </span><span style="color: black;">i<br>     </span><span style="color: magenta;">LEFT </span><span style="color: gray;">OUTER </span><span style="color: blue;">JOIN <br>       </span><span style="color: black;">deleted </span><span style="color: blue;">AS </span><span style="color: black;">d<br>       </span><span style="color: blue;">ON </span><span style="color: black;">i.OrderID </span><span style="color: blue;">= </span><span style="color: black;">d.OrderID<br>     </span><span style="color: blue;">WHERE <br>       </span><span style="color: black;">i.ShippingMethod </span><span style="color: blue;">= </span><span style="color: red;">'Express'<br>       </span><span style="color: gray;">AND<br>       (<br>           </span><span style="color: black;">d.OrderID </span><span style="color: blue;">IS </span><span style="color: gray;">NULL <br>           OR </span><span style="color: black;">d.ShippingMethod </span><span style="color: gray;"><> </span><span style="color: red;">'Express'<br>       </span><span style="color: gray;">);<br></span><span style="color: blue;">END<br></span><span style="color: blue;">GO</span>

If you need to send out different e-mails for different states, then you can add columns to the queue table that will make those choices obvious to the consuming stored procedure. For example, you may want the trigger to send out another e-mail, if in the meantime a customer downgrades their shipping choice, so that you don’t continue expediting it. You may also want to add additional logic that will prevent an e-mail in the first place, if someone has upgraded to express shipping and then downgraded back to normal or slow.

Also, you could choose to add an additional table to store the metadata about the e-mail content depending on the state, rather than bake subjects and bodies into the stored procedure code – this can also include whether a different recipient should receive e-mails that reflect these different states.

And you could go a step further than this, and use Service Broker to handle the insertion into the queue table, or even process the sending of the e-mail for you (I’ll leave that for a future tip). One advantage of the background job is that you can easily fine-tune the frequency that the queue table is polled – if you want it to run every minute, or you only need it to run once an hour, this is a simple change to the job’s schedule. Another advantage is that you have an automatic retry mechanism as well as an audit trail that is as permanent as you want it to be.

Next Steps

Leave a Reply

Your email address will not be published. Required fields are marked *