Call me crazy but triggers are usually bad news. In my experience they tend to rise to the top of the queue for bug fixing more frequently than their relatively small SLOC would belie, and often have performance problems that don't become apparent until months or years down the track as record counts increase.
But recently I was asked to create one and learned a thing or two about the importance of testing them thoroughly. Here's a sample database and table.
If Not Exists (Select Top 1 0 From sys.databases Where name = 'TriggerTest')
Create Database TriggerTest
Go
Use TriggerTest
Go
If Object_Id('dbo.Invoice') Is Not Null
Drop Table dbo.Invoice
Go
Create Table Invoice (
InvoiceID Int Identity(1,1) Primary Key,
InvoiceNumber Nvarchar(12)
)
Go
Create Index Invoice_InvoiceNumber On dbo.Invoice (
InvoiceNumber
)
Go
The situation was straightforward and one that may be all too familiar:
- An order-entry application accepted invoice numbers that were keyed in manually by users.
- Things changed (!) and the application would now also be hosted online, requiring it to generate its own invoice numbers.
- The business did not want to change the web services to do this, for their own reasons.
- But the web GUI could ensure a placeholder invoice number ("WEB") was used for online orders.
- The trigger's job is to alter this to "WEB000000001″, and rise roughly sequentially. If any numbers are missed it's not the end of the world.
- It had to run on SQL Server 2008.
I may have gone a little overboard on my first attempt. I'm only a little embarrassed.
Attempt 1
If Exists (Select Top 1 0 From sys.triggers Where name = 'Invoice_InvoiceNumberWEB')
Drop Trigger dbo.Invoice_InvoiceNumberWEB
Go
Create Trigger dbo.Invoice_InvoiceNumberWEB On Invoice
After Insert
As
Begin
If Exists (Select Top 1 0 From Inserted Where InvoiceNumber = 'WEB')
Begin
Set Nocount On
-- Get the last WEB integer, null is acceptable
Declare @LastInvoiceNumber Int
Select @LastInvoiceNumber = Right(Max(InvoiceNumber), 9)
From Invoice
Where InvoiceNumber Like 'WEB%'
And Len(InvoiceNumber) = 12
And Right(InvoiceNumber, 9) Not Like '%[^0-9]%'
-- Print @LastInvoiceNumber
-- Calculate an incremental integer for each WEB row and apply the change
; With Cte As (
Select Inserted.InvoiceID,
Row_Number() Over (Order By Inserted.InvoiceID) As RowNumber
From Inserted
Where InvoiceNumber = 'WEB'
)
Update Invoice
Set InvoiceNumber = 'WEB' + Right(Replace(Space(9), ' ', '0') + Cast(Coalesce(@LastInvoiceNumber, 0) + Cte.RowNumber As NvarChar(Max)), 9)
From Invoice
Join Cte
On Invoice.InvoiceID = Cte.InvoiceID
End
End
Go
It looked okay to me. It handled multiple rows, and if there's a WEB invoice number passed in it works out the last WEB invoice number in the table and increments it for each row being added. What could possibly go wrong? Here were some statements I used each time for testing:
Insert Invoice (InvoiceNumber) Values ('ABCDEFGHIJKL') -- Traditional record
Insert Invoice (InvoiceNumber) Values ('WEB000000001') -- Our own number to throw things off
Insert Invoice (InvoiceNumber) Values ('WEB') -- Assign a number, it should be 2
Insert Invoice (InvoiceNumber) Values ('WEB00000000A') -- A test to make sure letters are ignored
Insert Invoice (InvoiceNumber) Values ('WEB') -- Assign a number, it should be 3
Select InvoiceID,
InvoiceNumber
From Invoice
And the basic results, as I expected:
InvoiceID | InvoiceNumber |
---|---|
1 | ABCDEFGHIJKL |
2 | WEB000000001 |
3 | WEB000000002 |
4 | WEB00000000A |
5 | WEB000000003 |
Everything seemed perfect but I was aware that this application would see a bit more load than a single set of statements executed sequentially. So I created a PowerShell script to test a heavily loaded scenario.
If you're familiar with PowerShell there is a cmdlet called Start-Job you can use to create asynchronously running processes. Unfortunately it's very, very slow and takes up a lot of memory so it not very good for this kind of concurrency testing. Instead I used the psasync cmdlet and its worker threads. (I want to fork that to use standard semantics like Start-Job2 down the track).
function Test-Invoice_InvoiceNumber {
Cls
Import-Module psasync
Import-Module SQLPS -DisableNameChecking
# Clean up the table
Invoke-Sqlcmd -ServerInstance . -Database TriggerTest -Query "Truncate Table Invoice; Dbcc Checkident('dbo.Invoice', Reseed, 1)"
$maxThreads = 25
$asyncPipelines = @()
$asyncPool = Get-RunspacePool $maxThreads
for ($i = 1; $i -le $maxThreads; $i++) {
# Create a thread
$asyncPipelines += Invoke-Async -RunspacePool $asyncPool -ScriptBlock {
try {
# Connect to the server and add a WEB entry
$con = New-Object System.Data.SqlClient.SqlConnection("Data Source=.;Database=TriggerTest;Integrated Security=True;")
$con.Open()
$cmd = $con.CreateCommand()
$cmd.CommandText = "BEGIN TRAN Insert dbo.Invoice (InvoiceNumber) Values ('WEB') COMMIT"
$cmd.ExecuteNonQuery() | Out-Null
"Success"
} catch {
try {
# Try to just show the base message
$_.Exception.InnerException.Message
} catch {
$_
}
}
if ($cmd -ne $null) { $cmd.Dispose() }
if ($con -ne $null) { $con.Close() }
}
}
do {
# Display a running tally
$jobCountRunning = (Receive-AsyncStatus -Pipelines $asyncPipelines | Where-Object { $_.Status -eq "Running" }).Count
$jobCountCompleted = (Receive-AsyncStatus -Pipelines $asyncPipelines | Where-Object { $_.Status -eq "Completed" }).Count
Write-Progress -Id 1 -Activity "Waiting To Finish" `
-Status "Running Jobs" `
-CurrentOperation "$jobCountCompleted jobs completed, $jobCountRunning jobs left to go" `
-PercentComplete ($jobCountCompleted / $asyncPipelines.Count * 100)
Start-Sleep -Milliseconds 500
} while ($jobCountRunning -ne 0)
# Finish up
$results = @()
$results = Receive-AsyncResults -Pipelines $asyncPipelines
$asyncPool.Close()
Write-Host "These are the results:"
$results | %{ "`t$_" }
Write-Host
Write-Host "Successful inserts: " ($results -eq "Success" | Measure-Object).Count
Write-Host "Failed inserts: " ($maxThreads - ($results -eq "Success" | Measure-Object).Count)
}
Test-Invoice_InvoiceNumber
This was the output.
These are the results: Success Success Success Success Transaction (Process ID 60) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction. Transaction (Process ID 65) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction. Transaction (Process ID 74) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction. Transaction (Process ID 62) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction. Transaction (Process ID 58) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction. Transaction (Process ID 64) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction. Transaction (Process ID 67) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction. Transaction (Process ID 69) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction. Transaction (Process ID 71) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction. Transaction (Process ID 73) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction. Success Transaction (Process ID 70) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction. Transaction (Process ID 68) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction. Transaction (Process ID 66) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction. Transaction (Process ID 59) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction. Transaction (Process ID 61) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction. Transaction (Process ID 79) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction. Transaction (Process ID 88) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction. Transaction (Process ID 86) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction. Transaction (Process ID 63) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction. Transaction (Process ID 57) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction. Successful jobs: 5 Failed jobs: 20
Shock! Horror! Deadlocks that would have caused web services to have their connections killed immediately, a career-ending problem if it made it to production. Funnily (not important, but interesting) it also threw off identity values in the table in the process:
InvoiceID | InvoiceNumber |
---|---|
1 | WEB000000001 |
2 | WEB000000002 |
3 | WEB000000003 |
4 | WEB000000004 |
15 | WEB000000005 |
I don't normally encounter deadlocks but I suspected it was caused by the trigger trying to work out the most recent sequence number. I had an idea to get around it but first I wanted to see if it was only deadlocking because I was using the same table I was inserting into. Maybe if I put the sequential number into a table of its own:
Attempt 2
If Object_Id('dbo.InvoiceNumberHolder') Is Not Null
Drop Table dbo.InvoiceNumberHolder
Go
Create Table dbo.InvoiceNumberHolder (
InvoiceNumberHolderId Int Identity (1,1),
LastInvoiceNumber Decimal(9, 0) Not Null
)
Go
Insert dbo.InvoiceNumberHolder (LastInvoiceNumber) Values (0)
Go
If Exists (Select Top 1 0 From sys.triggers Where name = 'Invoice_InvoiceNumberWEB')
Drop Trigger dbo.Invoice_InvoiceNumberWEB
Go
Create Trigger dbo.Invoice_InvoiceNumberWEB On Invoice
After Insert
As
Begin
If Exists (Select Top 1 0 From Inserted Where InvoiceNumber = 'WEB')
Begin
Set Nocount On
-- Get the last WEB integer, null is acceptable
Declare @LastInvoiceNumber Int = (Select LastInvoiceNumber From dbo.InvoiceNumberHolder)
-- Print @LastInvoiceNumber
-- Calculate an incremental integer for each WEB row and apply the fix
; With Cte As (
Select Inserted.InvoiceID,
Row_Number() Over (Order By Inserted.InvoiceID) As RowNumber
From Inserted
Where InvoiceNumber = 'WEB'
)
Update Invoice
Set InvoiceNumber = 'WEB' + Right(Replace(Space(9), ' ', '0') + Cast(Coalesce(@LastInvoiceNumber, 0) + Cte.RowNumber As NvarChar(Max)), 9)
From Invoice
Join Cte
On Invoice.InvoiceID = Cte.InvoiceID
Update InvoiceNumberHolder
Set LastInvoiceNumber = Coalesce(LastInvoiceNumber, 0) + @@rowcount
End
End
Go
Having done this I confirmed with my basic test:
InvoiceID | InvoiceNumber |
---|---|
1 | ABCDEFGHIJKL |
2 | WEB000000001 |
3 | WEB000000001 |
4 | WEB00000000A |
5 | WEB000000002 |
Note that we had two WEB000000001, one that was manually inserted and the system didn't know about, and one that was generated. This was acceptable to the business (because users would not normally manually enter invoice numbers with that prefix). But at least we know. And under load?
These are the results: Success Success Success Success Success Success Transaction (Process ID 62) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction. Success Success Transaction (Process ID 60) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction. Transaction (Process ID 63) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction. Transaction (Process ID 64) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction. Transaction (Process ID 65) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction. Transaction (Process ID 66) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction. Transaction (Process ID 67) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction. Transaction (Process ID 68) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction. Transaction (Process ID 69) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction. Transaction (Process ID 70) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction. Transaction (Process ID 71) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction. Transaction (Process ID 72) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction. Transaction (Process ID 73) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction. Transaction (Process ID 74) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction. Transaction (Process ID 75) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction. Transaction (Process ID 76) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction. Transaction (Process ID 77) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction. Successful inserts: 8 Failed inserts: 17
Surprise (or not if you know locking intimately), deadlocks!
InvoiceID | InvoiceNumber |
---|---|
1 | WEB000000003 |
2 | WEB000000004 |
3 | WEB000000005 |
4 | WEB000000006 |
5 | WEB000000007 |
6 | WEB000000008 |
7 | WEB000000009 |
10 | WEB000000010 |
Then I had a better idea. How about using SQL Server soft locks? So that critical portion of the trigger can only run one at a time?
Attempt 3
If Exists (Select Top 1 0 From sys.triggers Where name = 'Invoice_InvoiceNumberWEB')
Drop Trigger dbo.Invoice_InvoiceNumberWEB
Go
Create Trigger dbo.Invoice_InvoiceNumberWEB On Invoice
After Insert
As
Begin
Exec sp_GetAppLock @Resource='Invoice_InvoiceNumberWEB', @LockMode='Exclusive', @LockTimeout=10000
If Exists (Select Top 1 0 From Inserted Where InvoiceNumber = 'WEB')
Begin
Set Nocount On
-- Get the last WEB integer, null is acceptable
Declare @LastInvoiceNumber Int
Select @LastInvoiceNumber = Right(Max(InvoiceNumber), 9)
From Invoice
Where InvoiceNumber Like 'WEB%'
And Len(InvoiceNumber) = 12
And Right(InvoiceNumber, 9) Not Like '%[^0-9]%'
-- Print @LastInvoiceNumber
-- Calculate an incremental integer for each WEB row and apply the fix
; With Cte As (
Select INSERTED.InvoiceID,
Row_Number() Over (Order By Inserted.InvoiceID) As RowNumber
From Inserted
Where InvoiceNumber = 'WEB'
)
Update Invoice
Set InvoiceNumber = 'WEB' + Right(Replace(Space(9), ' ', '0') + Cast(Coalesce(@LastInvoiceNumber, 0) + Cte.RowNumber As NvarChar(Max)), 9)
From Invoice
Join Cte
On Invoice.InvoiceID = Cte.InvoiceID
End
Exec sp_ReleaseAppLock @Resource='Invoice_InvoiceNumberWEB'
End
Go
I won't paste the results, but the results were still deadlocks! Now I was dusting the chalk off my hands and getting serious. I would rewrite the trigger into a more compact statement and try using an SQL Server SEQUENCE datatype. (In retrospect I don't need the Row_Number section, but I was just hacking the above script quickly to try it out.)
Sequence Attempt
If Exists (Select Top 1 0 From sys.sequences Where schema_id = schema_id('dbo') And name = 'InvoiceNumber')
Drop Sequence dbo.InvoiceNumber
Go
Create Sequence dbo.InvoiceNumber As Int Start With 1 Increment By 1
Go
If Exists (Select Top 1 0 From sys.triggers Where name = 'Invoice_InvoiceNumberWEB')
Drop Trigger dbo.Invoice_InvoiceNumberWEB
Go
Create Trigger dbo.Invoice_InvoiceNumberWEB On Invoice
After Insert
As
Begin
Set Nocount On
; With InsertedRow As (
Select Inserted.InvoiceID,
Row_Number() Over (Order By Inserted.InvoiceID) As RowNumber
From Inserted
Where InvoiceNumber = 'WEB'
)
Update Invoice
Set InvoiceNumber = 'WEB' + Right(Replace(Space(9), ' ', '0') + Cast(Next Value For dbo.InvoiceNumber As NvarChar(Max)), 9)
From Invoice
Join InsertedRow
On Invoice.InvoiceID = InsertedRow.InvoiceID
End
Go
These are the results: Success Success Success Success Success Success Success Success Success Success Success Success Success Success Success Success Success Success Success Success Success Success Success Success Success Successful inserts: 25 Failed inserts: 0
Success! All the InvoiceIDs were sequential, too. It all seemed great. I mean for SQL Server 2012, and this had to work on SQL Server 2008. I had almost wasted my time except that it led me to Google search where people were talking about the lack of SEQUENCE in SQL Server 2008 and using an identity table instead.
I didn't really understand how that would work, I already had an identity column in my table and that wasn't going to be suitable for my sequence number. There were no code samples but I decided they might be talking about having a dedicated table, and so set out to create my last trigger using everything I had learned so far:
Final Attempt
If Object_Id('dbo.InvoiceNumberWEB') Is Not Null
Drop Table dbo.InvoiceNumberWEB
Go
If Not Exists (Select Top 1 0 From sys.tables Where name = 'InvoiceNumberWEB')
Begin
Create Table dbo.InvoiceNumberWEB (
InvoiceSequence Int Identity(1, 1) Primary Key,
InvoiceID Int Not Null
)
End
Go
If Exists (Select Top 1 0 From sys.triggers Where name = 'Invoice_InvoiceNumberWEB')
Drop Trigger dbo.Invoice_InvoiceNumberWEB
Go
Create Trigger dbo.Invoice_InvoiceNumberWEB On dbo.Invoice
After Insert
As
Begin
Set Nocount On
Declare @Id Table (
InvoiceSequence Int,
InvoiceID Int
)
Insert dbo.InvoiceNumberWEB
Output Inserted.InvoiceSequence, Inserted.InvoiceID
Into @Id
Select Trigger_Inserted.InvoiceID
From Inserted Trigger_Inserted
Where Trigger_Inserted.InvoiceNumber = 'WEB'
Update Invoice
Set InvoiceNumber = 'WEB' + Right(Replace(Space(9), ' ', '0') + Cast(Coalesce(Id.InvoiceSequence, 0) As NvarChar(Max)), 9)
From Invoice
Join @Id Id
On Invoice.InvoiceID = Id.InvoiceID
End
Go
Success! It worked like a real SEQUENCE and with no more deadlocks. It does, occasionally, skip numbers if you insert a transaction and rollback, but this was acceptable.
You can see the part where I insert the existing InvoiceID into my sequence table (dbo.InvoiceNumberWEB). I didn't want to do that and just wanted to have SequenceNumber, but it turns out you can't insert that way while using the Output statement and getting your identity columns back. So, it was required. No matter.
Conclusion
What I learned, aside from having a blind spot in understanding internal SQL Server locking (I'll get to it one day) is that you really need to test your triggers under load.
I know some people will immediately reject this entire post and say a trigger just shouldn't be used at all. That's arguable but businesses want what they want and someone has to provide it. As long as it is tested properly and works in production I don't feel particularly strongly about it.