Tuesday, July 29, 2008

Trigger integration between two DBMS instances

One method to replicate tables between databases is to use triggers.

Using triggers is both immediate and synchronous, which is desirable as long as the solution performs adequately. Performance is an issue if you are using distributed transactions with two-phased commit. Long wait times in the UI for simple updates are just unacceptable.

I recently completed one integration job with triggers and here's what I found.

Job description
Synchronize customer tables between two databases running on different servers in the same network. The old "legacy" database is a client-server application running on SQL Server 2000. The new web application runs on SQL Server Express 2005.

Tables to syncronize in four-part syntax, somewhat simplified:
[SQL2000].[legacy].[dbo].[person] with
[SQL2005].[webapp].[dbo].[person]

[SQL2000].[legacy].[dbo].[company] with
[SQL2005].[webapp].[dbo].[company]

Synchronization method:
AFTER/FOR TRIGGER

While writing T-SQL triggers you should set up test cases.

A simple test case:
  1. read values before operation
  2. perform an operation
  3. read values after operation
  4. compare values

For step 4 I suggest comparing values manually at first. When initial development is completed you should automate this phase too, just to have a test case ready for later maintenance work.

An example of 1.-3., running at legacyapp database

-- Test company
SET @companyID = ...

-- Initial state
SELECT *
FROM [legacyapp].[dbo].[company]
WHERE companyID= @companyID

SELECT *
FROM [SQL2005].[webapp].[dbo].[company]
WHERE legacyapp_companyID = @companyID

-- Perform Operation
UPDATE [legacyapp].[dbo].[Client]
SET [CompanyName] = 'TestCompany'
WHERE companyID = @companyID

-- Result state
SELECT *
FROM [legacyapp].[dbo].[company]
WHERE companyID= @companyID

SELECT *
FROM [SQL2005].[webapp].[dbo].[company]
WHERE legacyapp_companyID = @companyID

Test cases are necessary in debugging. In a distributed setting I found that the most common symptom of trouble is not an error message but a deadlock: your query just hangs.

Be aware of deadlocks!

MSDTC or SQL Server can detect some distributed deadlocks. For example, if two triggers fire each other they would end up playing ping pong forever in a livelock. Instead of this, I got an error message:

Transaction context in use by another session.

I couldn't find a way out of this. Apparently SQL Server uses locks that make insert/delete/update triggers that fire each other impossible. Well, on a second thought, this is a sane and good policy you actually want your DBMS to enforce. As a consequence, in order to get two-way synchronization I had to program in application logic the replication from webapp to the legacy app.

I would guess that inside a single DBMS you could tweak locking to get mutually firing triggers to work. For example, I tried to implement a monitor that checks the cause of the fired trigger and cuts short if it is due to the other trigger.

DECLARE @monitor data_type
SET @monitor = (SELECT condition_field FROM Inserted)
IF @monitor IS NULL OR @monitor <> condition
BEGIN
... triggered transaction
END

IF @monitor = condition
BEGIN
UPDATE table
SET condition_field= NULL
WHERE companyID= @companyID -- identify inserted tuple
END

In distributed setting this approach didn't work. Inserted tuple is probably exclusively locked and cannot be updated in a trigger. Or maybe I got confused because of other deadlock issues like when writing a wrong data type to the destination field...

Be careful with data types!

Declare parameters to the type you are going to write, not the type you are going to read. This is critical in UPDATE transactions.

An example:
  1. You update a value in [SQL2000].[legacy].[dbo].[person], firing a trigger
  2. In the trigger, you read the updated value in a parameter
  3. You update [SQL2005].[webapp].[dbo].[person] with the parameter
In phase 2 you should declare the parameter to the data type you are writing in phase 3. It helps to name the parameter according to the destination field.

DECLARE @destination destination_data_type
SET @destination = (SELECT source_field FROM Inserted)

Updating a text or ntext field is impossible this way because you cannot read them to a parameter at all. But hey, wait a minute... why use parameters in the first place?

Because all update statements are not equal!

You can try to propagate an update to webapp.person without parameters like this:

SET @personID = (SELECT personID FROM Inserted)

UPDATE [SQL2005].[webapp].[dbo].[person]
SET [Firstname] = Firstname
[Lastname] = Lastname
FROM [legacyapp].[dbo].[person]
WHERE personID = @personID

Unfortunately, this method does not work. You can't select new values directly from legacyapp tables directly in the UPDATE statement of webapp table. Don't ask me why. I guess this has something to do with MSDTC, but the query just hangs forever.

You do have to read new values beforehand to parameters:

UPDATE [SQL2005].[webapp].[dbo].[person]
SET [Firstname] = @Firstname
[Lastname] = @Lastname
WHERE personID = @personID

Finally, don't trust what I say!
(but you still might want to do as I did)

What I have really done is just an example of cargo cult programming. I did not understand the causes of these issues but found solutions anyway by trial and error.

Thursday, July 10, 2008

Outlook 2003 Meeting Requests, Part 1: Message Format

Problem: How to create a proper Outlook-compliant meeting request. That is, a request that Outlook shows as an accept/decline dialog:



Before describing a solution, I should emphasize that I'm using a Windows XP Pro (English) with Finnish regional settings and there are probably some localization issues here. For example, GMail does not accept any meeting requests I send from Outlook and Outlook sees only invite.ics attachment from GMail, no accept/decline dialog:




A .NET Solution?


Surprisingly, it turns out that Microsoft does not provide a convenient .Net solution for creating Outlook compatible meeting requests. No handy classes in System.Net.Mail, for example. Only available solution is to reverse engineer Outlook-generated meeting requests from scratch. For this, you need to have access to the raw message with SMTP headers included.

I recommend sending a request from Outlook to Gmail and opening it with the "show original" function:













To generate an identical message in .NET 1.1 you need to program directly with CDO, not with System.Web.Mail. It doesn't provide enough functionality to get the job done properly. With System.Web.Mail you can send a request as an iCalendar attachment but you won't get the accept/deny dialog to work.

Chuck Sphor has shown how to compose a meeting request with System.Net.Mail. (.NET 2.0) I couldn't try this the last time around because we were still using .Net 1.1. But I will return to this later...

Perhaps the most time-consuming problem is to find the correct MIME format. The format I got to work with Outlook (but not with GMail) is given below. It consists of three parts:
  • A plain text message body
  • an HTML message body
  • an Outlook-compatible iCalendar attachment.
Finding a correct iCalendar by trial and error is pain. I still don't have a clear understanding what are the minimum requirements to get the accept/deny -dialog work.

MIME Header:

Content-class: urn:content-classes:calendarmessage
MIME-Version: 1.0
Content-Type: multipart/alternative;
boundary="----_=_NextPart_001_01C86837.267B72A8"


Plain text part

------_=_NextPart_001_01C86837.267B72A8
Content-Type: text/plain;
charset="iso-8859-1"
Content-Transfer-Encoding: quoted-printable

When: 5. helmikuuta 2008 6:30-7:00 (GMT+02:00) Helsinki, Kyiv, Riga, =
Sofia, Tallinn, Vilnius.
Where: fsd

*~*~*~*~*~*~*~*~*~*

HTML part

------_=_NextPart_001_01C86837.267B72A8
Content-Type: text/html;
charset="iso-8859-1"
Content-Transfer-Encoding: quoted-printable

HTML headers...


When: 5. helmikuuta 2008 6:30-7:00 =
(GMT+02:00) Helsinki, Kyiv, Riga, Sofia, Tallinn, Vilnius.

Where: fsd


*~*~*~*~*~*~*~*~*~*



iCalendar attachment


------_=_NextPart_001_01C86837.267B72A8
Content-class: urn:content-classes:calendarmessage
Content-Type: text/calendar;
method=REQUEST;
name="meeting.ics"
Content-Transfer-Encoding: 8bit

BEGIN:VCALENDAR
METHOD:REQUEST
PRODID:Microsoft CDO for Microsoft Exchange
VERSION:2.0
BEGIN:VTIMEZONE
TZID:(GMT+02.00) Athens/Istanbul/Minsk
X-MICROSOFT-CDO-TZID:7
BEGIN:STANDARD
DTSTART:16010101T040000
TZOFFSETFROM:+0300
TZOFFSETTO:+0200
RRULE:FREQ=YEARLY;WKST=MO;INTERVAL=1;BYMONTH=10;BYDAY=-1SU
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:16010101T030000
TZOFFSETFROM:+0200
TZOFFSETTO:+0300
RRULE:FREQ=YEARLY;WKST=MO;INTERVAL=1;BYMONTH=3;BYDAY=-1SU
END:DAYLIGHT
END:VTIMEZONE
BEGIN:VEVENT
DTSTAMP:20080205T203907Z
DTSTART;TZID="(GMT+02.00) Athens/Istanbul/Minsk":20080205T063000
SUMMARY:From Outlook to Gmail
UID:040000008200E00074C5B7101A82E008000000001098E3EA4768C801000000000000000
0100000004FDA693286B8C9499A0455CD25355E23
ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN="receiver@
gmail.com":MAILTO:
ORGANIZER;CN="Meeting Organizer":MAILTO:
LOCATION:fsd
DTEND;TZID="(GMT+02.00) Athens/Istanbul/Minsk":20080205T070000
DESCRIPTION:\N
SEQUENCE:0
PRIORITY:5
CLASS:
CREATED:20080205T203905Z
LAST-MODIFIED:20080205T203905Z
STATUS:CONFIRMED
TRANSP:OPAQUE
X-MICROSOFT-CDO-BUSYSTATUS:BUSY
X-MICROSOFT-CDO-INSTTYPE:0
X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY
X-MICROSOFT-CDO-ALLDAYEVENT:FALSE
X-MICROSOFT-CDO-IMPORTANCE:1
X-MICROSOFT-CDO-OWNERAPPTID:-370858024
X-MICROSOFT-CDO-APPT-SEQUENCE:0
X-MICROSOFT-CDO-ATTENDEE-CRITICAL-CHANGE:20080205T203907Z
X-MICROSOFT-CDO-OWNER-CRITICAL-CHANGE:20080205T203907Z
BEGIN:VALARM
ACTION:DISPLAY
DESCRIPTION:REMINDER
TRIGGER;RELATED=START:-PT00H15M00S
END:VALARM
END:VEVENT
END:VCALENDAR

------_=_NextPart_001_01C86837.267B72A8--


In Part 2 I will explain how to use CDO to create the message with all necessary parts.