Google Website Translator Gadget

Sunday, 21 November 2010

TransactionScope Fluent Interface

During the past 2 weeks I’ve been spending a lot of time investigating the performance of Pragma On Key, our company’s Enterprise Asset Management System (EAMS).  Part of the investigation was to resolve some issues we were encountering with deadlocks occurring for some of the longer running transactions.  We are using the TransactionScope class added in .NET 2.0 to mark blocks of code in our Application Controllers as participating in a transaction.  Out of the box, the default IsolationLevel used by new a TransactionScope instance is Serializable.  This ensures the best data integrity by preventing dirty reads, nonrepeatable reads and phantom rows.  However, all of this comes at the cost of concurrency as the range lock acquired by the DBMS prevents other transactions of updating or deleting rows in the range.   So I set off to carefully consider the locking requirements for the different transactions to make sure we are acquiring the right level of locking for the different transactions.

I’ve always found the different settings of the TransactionScope class to be rather confusing.  I always need to remind myself what the different items of the IsolationLevel and TransactionScopeOption enumerations actually mean.  There is also the tricky scenario where, when passing in a new TransactionOptions instance to set a different IsolationLevel, you need to remember to set the transaction Timeout value if you are using a custom Timeout setting in your web.config file.  So I decided to create a TransactionScopeBuilder class with a fluent interface to construct new TransactionScope instances with the idea to try and hide the complexity and also provide a more intent revealing interface of what the actual TransactionScope will do.  I’ll start by showing some examples usages of the builder and then conclude with the source code for the builder itself. 

The first call on the builder is a method to indicate the transactional behaviour of the scope through using the TransactionScopeOption enumeration.  To start a new transaction:

1 // RequiresNew 2 TransactionScope scope = TransactionScopeBuilder.StartNew(); 3 // Required 4 TransactionScope scope = TransactionScopeBuilder.StartOrJoinAmbient(); 5 // Suppress 6 TransactionScope scope = TransactionScopeBuilder.IgnoreAmbient();

The next step is to then set the TransactionOptions.  To set the Isolation Level, call any of the following methods on the builder:

1 // IsolationLevel.ReadUncommitted 2 TransactionScope scope = TransactionScopeBuilder.StartNew().AllowDirtyReads(); 3 // IsolationLevel.ReadCommitted 4 TransactionScope scope = TransactionScopeBuilder.StartNew().PreventDirtyReads(); 5 // IsolationLevel.RepeatableRead 6 TransactionScope scope = TransactionScopeBuilder.StartNew().PreventNonRepeatableReads(); 7 // IsolationLevel.Serializable 8 TransactionScope scope = TransactionScopeBuilder.StartNew().PreventPhantomReads();

Having the method indicate what scenarios it will allow/prevent makes for easier reading IMO.  Lastly we can set the timeout associated with the transaction by calling any of the following methods on the builder:

1 TransactionScope scope = TransactionScopeBuilder.StartNew().RunsFor(TimeSpan.FromMinutes(3)); 2 TransactionScope scope = TransactionScopeBuilder.StartNew().RunsForMinutes(3); 3 TransactionScope scope = TransactionScopeBuilder.StartNew().RunsForSeconds(180);

By defining an implicit cast operator from my TransactionScopeBuilder to a TransactionScope, I can complete the construction process without having to do any additional method calls as illustrated above.  However, for scenarios where you need don’t want to assign the constructed instance to a variable (like your integration tests where you simply want to always rollback), you can complete the construction process by calling the Instance method as shown below:

1 using (TransactionScopeBuilder.StartNew().PreventPhantomReads().Instance)

Putting all of this together, let’s look at the syntax for creating a nested TransactionScope instance:

1 using (TransactionScopeBuilder.StartNew().PreventPhantomReads().Instance) 2 { 3 using (TransactionScope scope = TransactionScopeBuilder.StartOrJoinAmbient() 4 .PreventDirtyReads() 5 .RunsFor(TimeSpan.FromMinutes(3))) 6 { 7 Assert.That(scope, Is.Not.Null); 8 Assert.That(Transaction.Current.IsolationLevel, Is.EqualTo(IsolationLevel.Serializable)); 9 } 10 }

Some other benefits I get from encapsulating the construction logic in the builder are:

  1. Ability to add logging to show when new instances are created and what the settings for the new transaction are.
  2. Ability to enforce my own default settings for constructing new instances through reading values from configuration etc.
  3. Ability to auto-escalate the nested transaction to the IsolationLevel of the ambient transaction.  As all transactions participating in the same ambient transaction needs to use the same IsolationLevel, this is especially helpful in some scenarios where the scope might differ based on the context in the which the nested transaction is being created.

The code is pretty self-explanatory, so instead of going through it line-by-line, it just include it for you to have a look at:

1 /// <summary> 2 /// Builder Pattern to create configured <see cref = "TransactionScope" /> instances. 3 /// </summary> 4 public sealed class TransactionScopeBuilder 5 { 6 private static ILogger _logger; 7 private static IConfigRegistry _config = ConfigRegistry.Instance; 8 9 private readonly bool _autoEscalateToAmbientIsolationLevel; 10 private IsolationLevel _isolationLevel; 11 private readonly bool _joinExisting; 12 private readonly TransactionScopeOption _scopeOptions; 13 private TimeSpan _timeout; 14 15 private TransactionScopeBuilder(TransactionScopeOption scopeOptions, bool autoEscalateToAmbientIsolationLevel) 16 { 17 Check.ArgumentNotNull(scopeOptions, "scopeOptions"); 18 19 _scopeOptions = scopeOptions; 20 _joinExisting = scopeOptions == TransactionScopeOption.Required; 21 _autoEscalateToAmbientIsolationLevel = autoEscalateToAmbientIsolationLevel; 22 _isolationLevel = IsolationLevel.ReadCommitted; 23 _timeout = Config.DatabaseTransactionTimeout; 24 } 25 26 #region Properties 27 28 /// <summary> 29 /// Constructs the <see cref = "TransactionScope" /> instance. 30 /// </summary> 31 /// <returns></returns> 32 public TransactionScope Instance 33 { 34 get { return Build(this); } 35 } 36 37 /// <summary> 38 /// Gets or sets the logger. 39 /// </summary> 40 public static ILogger Logger 41 { 42 get 43 { 44 if (_logger == null) 45 { 46 _logger = LoggerFactory.GetLogger(typeof(TransactionScopeBuilder)); 47 } 48 49 return _logger; 50 } 51 set { _logger = value; } 52 } 53 54 /// <summary> 55 /// Gets or sets the configuration registry. 56 /// </summary> 57 /// <summary> 58 /// Gets or sets the configuration registry. 59 /// </summary> 60 public static IConfigRegistry Config 61 { 62 get { return _config; } 63 set { _config = value; } 64 } 65 66 private IsolationLevel IsolationLevel 67 { 68 get { return _isolationLevel; } 69 } 70 71 private TransactionScopeOption ScopeOptions 72 { 73 get { return _scopeOptions; } 74 } 75 76 private TimeSpan Timeout 77 { 78 get { return _timeout; } 79 } 80 81 #endregion 82 83 #region Methods 84 85 /// <summary> 86 /// Creates a new <see cref = "TransactionScope" /> that ignores the ambient 87 /// transaction through using <see cref = "TransactionScopeOption.Suppress" /> option. 88 /// </summary> 89 public static TransactionScopeBuilder IgnoreAmbient() 90 { 91 return new TransactionScopeBuilder(TransactionScopeOption.Suppress, false); 92 } 93 94 /// <summary> 95 /// Creates a new <see cref = "TransactionScope" /> that starts a new ambient 96 /// transaction through using <see cref = "TransactionScopeOption.RequiresNew" /> option. 97 /// </summary> 98 public static TransactionScopeBuilder StartNew() 99 { 100 return new TransactionScopeBuilder(TransactionScopeOption.RequiresNew, false); 101 } 102 103 /// <summary> 104 /// Creates a new <see cref = "TransactionScope" /> that joins the existing ambient 105 /// transaction if it exists or alternatively starts a new one through using 106 /// <see cref = "TransactionScopeOption.Required" /> option. 107 /// </summary> 108 /// <param name = "autoEscalateToAmbientIsolationLevel">Flag to indicate whether to automatically use 109 /// the same <see cref = "IsolationLevel" /> as the existing ambient transaction.</param> 110 /// <exception cref="InvalidOperationException"> 111 /// Thrown for auto escalation when the isolation level requested by nested transaction is 112 /// finer grained than the existing ambient transaction's isolation level. 113 /// </exception> 114 public static TransactionScopeBuilder StartOrJoinAmbient(bool autoEscalateToAmbientIsolationLevel = true) 115 { 116 return new TransactionScopeBuilder(TransactionScopeOption.Required, autoEscalateToAmbientIsolationLevel); 117 } 118 119 /// <summary> 120 /// Allow dirty reads using <see cref = "System.Transactions.IsolationLevel.ReadUncommitted" /> 121 /// </summary> 122 public TransactionScopeBuilder AllowDirtyReads() 123 { 124 return SetIsolationLevel(IsolationLevel.ReadUncommitted); 125 } 126 127 /// <summary> 128 /// Prevent dirty reads using <see cref = "System.Transactions.IsolationLevel.ReadCommitted" /> 129 /// </summary> 130 public TransactionScopeBuilder PreventDirtyReads() 131 { 132 return SetIsolationLevel(IsolationLevel.ReadCommitted); 133 } 134 135 /// <summary> 136 /// Prevent non-repeatable reads using <see cref = "System.Transactions.IsolationLevel.RepeatableRead" /> 137 /// </summary> 138 public TransactionScopeBuilder PreventNonRepeatableReads() 139 { 140 return SetIsolationLevel(IsolationLevel.RepeatableRead); 141 } 142 143 /// <summary> 144 /// Prevent phantom reads using <see cref = "System.Transactions.IsolationLevel.Serializable" /> 145 /// </summary> 146 public TransactionScopeBuilder PreventPhantomReads() 147 { 148 return SetIsolationLevel(IsolationLevel.Serializable); 149 } 150 151 /// <summary> 152 /// Sets the timeout for the transaction in minutes. 153 /// </summary> 154 /// <param name = "minutes">The minutes to run before timeout.</param> 155 public TransactionScopeBuilder RunsForMinutes(int minutes) 156 { 157 return RunsFor(TimeSpan.FromMinutes(minutes)); 158 } 159 160 /// <summary> 161 /// Sets the timeout for the transaction in seconds. 162 /// </summary> 163 /// <param name = "seconds">The seconds to run before timeout.</param> 164 public TransactionScopeBuilder RunsForSeconds(int seconds) 165 { 166 return RunsFor(TimeSpan.FromSeconds(seconds)); 167 } 168 169 /// <summary> 170 /// Sets the timeout for the transaction. 171 /// </summary> 172 /// <param name = "timeout">The timeout.</param> 173 public TransactionScopeBuilder RunsFor(TimeSpan timeout) 174 { 175 _timeout = timeout; 176 return this; 177 } 178 179 private static TransactionScope Build(TransactionScopeBuilder scopeBuilder) 180 { 181 if (Logger.IsDebugEnabled) 182 { 183 if (scopeBuilder.ScopeOptions == TransactionScopeOption.Required && Transaction.Current != null) 184 { 185 Logger.Debug("Creating nested transaction (Scope={0};Isolation Level={1};Timeout={2}).", scopeBuilder.ScopeOptions, scopeBuilder.IsolationLevel, scopeBuilder.Timeout); 186 } 187 else 188 { 189 Logger.Debug("Creating new transaction (Scope={0};Isolation Level={1};Timeout={2}).", scopeBuilder.ScopeOptions, scopeBuilder.IsolationLevel, scopeBuilder.Timeout); 190 } 191 } 192 193 return new TransactionScope(scopeBuilder.ScopeOptions, new TransactionOptions 194 { 195 IsolationLevel = scopeBuilder.IsolationLevel, 196 Timeout = scopeBuilder.Timeout 197 }); 198 } 199 200 private TransactionScopeBuilder SetIsolationLevel(IsolationLevel requiredLevel) 201 { 202 IsolationLevel acceptedLevel = requiredLevel; 203 if (_joinExisting && _autoEscalateToAmbientIsolationLevel && Transaction.Current != null) 204 { 205 // Can only escalate to ambient level if the required level is a less-fine grained lock 206 // IsolationLevel Enum values: Serializable = 0; RepeatableRead = 1; ReadCommitted = 2; ReadUncommitted = 3 207 if (requiredLevel >= Transaction.Current.IsolationLevel) 208 { 209 acceptedLevel = Transaction.Current.IsolationLevel; 210 if (Logger.IsDebugEnabled && requiredLevel > Transaction.Current.IsolationLevel) 211 { 212 Logger.Debug("Building a nested transaction (Isolation Level={0}) that will auto escalate to the Ambient transaction (Isolation Level={1})", requiredLevel, acceptedLevel); 213 } 214 } 215 else 216 { 217 throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, "TransactionScope could not be auto escalated. Requested level = {0}, Ambient Level = {1}", requiredLevel, Transaction.Current.IsolationLevel)); 218 } 219 } 220 _isolationLevel = acceptedLevel; 221 return this; 222 } 223 224 #endregion 225 226 /// <summary> 227 /// Performs an implicit conversion from 228 /// <see cref = "Pragma.OnKey.Core.TransactionScopeBuilder" /> to 229 /// <see cref = "System.Transactions.TransactionScope" /> by building the actual instance. 230 /// </summary> 231 /// <param name = "scopeBuilder">The scope builder.</param> 232 /// <returns>The result of the conversion.</returns> 233 public static implicit operator TransactionScope(TransactionScopeBuilder scopeBuilder) 234 { 235 return Build(scopeBuilder); 236 } 237 } 238

Hope you find this useful for helping you manage your own TransactionScope instances!

1 comment:

  1. Nice! I especially like the idea of auto-escalating the isolation level.

    ReplyDelete