Monday, January 12, 2009

Smart Clients and System.Transactions Part 3 - Enlistments

This is the third installment of an ongoing series about using System.Transactions as the fundament for client-side infrastructure for managing changes among loosely coupled  components.

Previous posts: Introduction, Timeout


The way you hook into the change infrastructure is by building a provider that implements System.Transactions.IEnlistmentNotification. The provider in this context is an object that knows how to get hold of domain objects, and its main purpose is being a facade that abstracts away all details having to do with getting hold of objects from the server or cache. The provider knows how to detect when one of its objects changes. I usually do this by having the provider listen to events that the domain objects raise whenever changed. One elegant way of doing this is by having the domain objects implement System.ComponentModel.INotifyPropertyChange.

As changes are detected the provider will want to register with the ambient transaction. This is an operation known as enlisting. The System.Transactions functionality implements a two-phase commit scheme, and once enlisted the provider will be a part of this scheme: it will automatically receive method calls (Prepare, Commit, Rollback) at the appropriate times.

Here's some code showing a prototypical provider (a lot of functionality that you probably would need is not shown here):

public class TransactionalProvider<T> : IEnlistmentNotification where T : INotifyPropertyChanged
{
private IRepository repository;
private readonly Dictionary<INotifyPropertyChanged, INotifyPropertyChanged> changeMap =
new Dictionary<INotifyPropertyChanged, INotifyPropertyChanged>();
private bool enlisted = false;

public TransactionalProvider(IRepository repository)
{
this.repository = repository;
}

public T GetObject(Guid id)
{
T result = repository.GetObject<T>(id);
if (result == null) return default(T);
result.PropertyChanged += OnObjectChanged;
return result;
}

void OnObjectChanged(object sender, PropertyChangedEventArgs e)
{
if (Transaction.Current == null)
throw new TransactionException(
String.Format("Attempted to change object {0} while not in ntransaction", sender));

if(!enlisted)
{
Transaction.Current.EnlistVolatile(this, EnlistmentOptions.None);
enlisted = true;
}

T changedObj = (T) sender;
if(!changeMap.ContainsKey(changedObj))
{
changeMap.Add(changedObj, changedObj);
}
}

public void Prepare(PreparingEnlistment preparingEnlistment)
{
// Code for sending changes (stored in changeMap)
// into the transaction change infrastructure
// goes here.

preparingEnlistment.Prepared();
}

public void Commit(Enlistment enlistment)
{
enlisted = false; // Consider the consequences if I forget this
changeMap.Clear();
enlistment.Done();
}

// Code omitted for clarity
}


The provider enlists with the ambient transaction as it detects changing objects in the method OnObjectChanged. What happens if the provider by accident enlists two times? You might be tempted to think that the transaction notices this and either raises an exception or just lets it pass, i.e. that the list of enlistments is represented internally as a hashtable/dictionary. However, this is not the case. What happens if you enlist twice is that you will receive all the transactional messages (Prepare, Commit, Rollback) twice. This almost certainly is not what you want.



So, the task of knowing if the provider already is enlisted or not falls on you. It would have been very helpful if there existed an API for checking if any given object was enlisted or not (e.g. Transaction.Current.IsEnlisted(this) ), but unfortunately no such API exists, hence the boolean instance variable enlisted that you can see in the example above. Not too complicated, and it works, but this approach is a little too brittle for my taste. First, you have to implement this kind of enlistment checking functionality in each and every transactional provider you implement (thus violating the DRY principle), and second consider the consequences if you forget to reset the boolean when exiting the transaction. This would amount to an absolutely catastrophic bug: the application does not throw exceptions, and seems to be working as intended, however only the first transaction the provider participated in went as planned. After that the provider thought it already was enlisted, and it never enlisted again. None of the changes under its jurisdiction where ever sent to the server! Better solve this problem once and for all.



Enter IAmbientTransaction and AmbientTransaction which implements it.



public interface IAmbientTransaction
{
void Enlist(IEnlistmentNotification enlistmentNotification, EnlistmentOptions options);
}


public class AmbientTransaction : IAmbientTransaction, IEnlistmentNotification
{
private readonly Dictionary<IEnlistmentNotification, IEnlistmentNotification> enlistmentMap =
new Dictionary<IEnlistmentNotification, IEnlistmentNotification>();

public AmbientTransaction()
{}

public void Enlist(IEnlistmentNotification enlistmentNotification, EnlistmentOptions options)
{
if (Transaction.Current == null)
throw new TransactionException(
String.Format(
"Attempted to enlist enlistment notification {0} while not inntransaction",
enlistmentNotification));

if (!enlistmentMap.ContainsKey(enlistmentNotification))
{
enlistmentMap.Add(enlistmentNotification, enlistmentNotification);
}
if(!enlistmentMap.ContainsKey(this))
{
enlistmentMap.Add(this, this);
}
}

public void Prepare(PreparingEnlistment preparingEnlistment)
{
preparingEnlistment.Prepared();
}

public void Commit(Enlistment enlistment)
{
enlistmentMap.Clear();
enlistment.Done();
}

public void Rollback(Enlistment enlistment)
{
enlistmentMap.Clear();
enlistment.Done();
}
// Code omitted
}


This is functionality implements a gateway to the System.Transactions functionality. I.e. the providers no longer reference System.Transactions directly, but the IAmbientTransaction (the ambient transaction is auto-wired into the provider by the IoC container of choice) contract. A few interesting points about this code:




  • The ambient transaction itself implements IEnlistmentNotification. The sole reason for this is so that it too can receive the message sends from the transaction in the coordinated two-phase commit. It wants this messages in order to clean up its its internal state (enlistentMap) at exactly the correct point in time (in the Commit method).


  • It raises an exception when and if an enlistment ever tries to enlist when not in transaction. This eliminates the need for having such a check in every provider.



Check out the new and improved version of the method OnObjectChanged of the provider:



void OnObjectChanged(object sender, PropertyChangedEventArgs e)
{
ambientTransaction.Enlist(this, EnlistmentOptions.None);

T changedObj = (T) sender;
if(!changeMap.ContainsKey(changedObj))
{
changeMap.Add(changedObj, changedObj);
}
}


Much better, I think. All details concerning transactions (enlisting and raising exceptions when changes occur outside of transaction) are delegated to the ambient transaction, while the sole remaining responsibility of the provider is to maintain its own state.



Next time: the transaction sink.




No comments:

Post a Comment