TransactionScope - tempting, but insidious
ADO.NET 2.0 was released a long time ago, and with it the System.Transactions assembly containing the TransactionScope class, a guide to the world of easy and easy-to-use transactions. In today's article, I will consider some of the nuances that arise when using this leaky, but such a nice abstraction.
So, starting with ADO.NET 2.0, in order to wrap your code in a transaction, it is enough for the developer to place it inside the TransactionScope block:
I used the most important parameters in the constructor - let's look at them (in reverse order).
Good old IsolationLevel . Enum IsolationLevel includes as many as 7 isolation levels, but do not flatter yourself - these values are interpreted only as recommendations to the ADO.NET provider, and you can only use levels that are supported by your DBMS.
By default, the highest isolation level is used - Serializable, and I even came across criticism in this regard: they say that it’s notpatsian by the standard (it is recommended to use Read Committed in the standard as default). To me, this solution is the other way round: by default, the most reliable mode is used, and if necessary, to improve performance or overcome deadlocks - you can always switch to a softer mode.
By the way, you cannot change the Isolation Level during a transaction.
Enum TransactionScopeOption contains three values: Requires, RequiresNew, Suppress, which determine the behavior when entering the TransactionScope block. The behavior under all possible cases of TransactionScopeOption is well described in msdn , and I just summarize:
Note that in RequiresNew and Suppress modes, any TransactionScope is root, while in Requires mode, you can use nested TransactionScope. Nested TransactionScope is visually very similar to classic nested transactions (MySQL lovers known as Savepoints). But this is a false analogy, and the following example will explain why:
Note that in Method2 we did not call transactionScope2.Complete, which means transactionScope2 will roll back. In the case of classic nested transactions, we can roll back the internal transaction without rolling back the root one. Here both TransactionScope work within the same transaction, which means that if at least one of the internal transactionScope does not call Complete, the transaction will be marked for rollback, and when you exit the root TransactionScope, rollback will occur (commit / rollback transactions always occur when you exit the root TransactionScope ) And if in the root TransactionScope you try to call Complete (as in Method1), and the transaction has already been marked for rollback, TransactionAbortedException will be thrown.
Regarding nested TransactionScope, there is one unpleasant feature: at the time of writing the code, we do not know whether Complete of the current TransactionScope will mean commit transactions. Let's say we have the following method:
, as well as the method that calls it:
And all would be fine, but over time, a higher-level service appears that calls TransactionMethod from its internal TransactionScope:
And here the call to transactionScope.Complete () inside TransactionMethod no longer leads to a transaction commit, which means that the underlying logic, tied to the fact that a transaction has already committed, will fail.
Although, in fairness, it is worth noting that the described situation is quite specific, and, as a rule, the developer does not care if a commit occurs when exiting the current transactionScope or one of the overlying ones.
Now it is time to focus on two other TransactionScopeOption values: RequiresNew and Suppress. I rarely had to use these modes. Moreover, if I am not mistaken, I did this only once, and just when solving the problem described in the previous article .
The issue of using or not using RequiresNew and Suppress, of course, is determined by the requirements of the algorithm, but I have some prejudices about this. The fact is that TransactionScope in the RequiresNew and Suppress modes, in the presence of operations modifying the state of the database, makes it impossible to use the old trick when the integration test code is in a transaction, which is rolled back at the end of the test, thereby restoring the state of the database:
If TransactionScope in the Required mode is created in the test code, then they will hook to the TransactionScope test, which means we can roll back all the changes. If the code contains TransactionScope in the RequiresNew or Suppress mode, we cannot roll back the result of their work from the test TransactionScope. It is worth noting that the presence of logic tied at the time of the transaction commit (as in the previous example) also makes it impossible to use this technique.
Finally, I note that TransactionScope is local to the thread (because its implementation is based on the ThreadStatic variable). If you need to use one transaction from several flows, use the DependentTransaction class .
That’s probably all. TransactionScope is beautiful, but insidious - do not forget about it :)
So, starting with ADO.NET 2.0, in order to wrap your code in a transaction, it is enough for the developer to place it inside the TransactionScope block:
using (var transactionScope = new TransactionScope(TransactionScopeOption.Suppress, new TransactionOptions() { IsolationLevel = IsolationLevel.Serializable })
{
//код внутри транзакции
transactionScope.Complete();
}
I used the most important parameters in the constructor - let's look at them (in reverse order).
IsolationLevel
Good old IsolationLevel . Enum IsolationLevel includes as many as 7 isolation levels, but do not flatter yourself - these values are interpreted only as recommendations to the ADO.NET provider, and you can only use levels that are supported by your DBMS.
By default, the highest isolation level is used - Serializable, and I even came across criticism in this regard: they say that it’s not
By the way, you cannot change the Isolation Level during a transaction.
TransactionScopeOption
Enum TransactionScopeOption contains three values: Requires, RequiresNew, Suppress, which determine the behavior when entering the TransactionScope block. The behavior under all possible cases of TransactionScopeOption is well described in msdn , and I just summarize:
- Requires (default value) requires a transaction. Upon entering the block, either the transaction of the parent TransactionScope (if any) will be used, or a new transaction will be created.
- RequiresNew always requires a new transaction.
- Suppress executes block code outside the transaction
Note that in RequiresNew and Suppress modes, any TransactionScope is root, while in Requires mode, you can use nested TransactionScope. Nested TransactionScope is visually very similar to classic nested transactions (MySQL lovers known as Savepoints). But this is a false analogy, and the following example will explain why:
public void Method1()
{
using (var transactionScope1 = new TransactionScope(TransactionScopeOption.Requires))
{
Method2();
transactionScope1.Complete();
}
}
public void Method2()
{
using (var transactionScope2 = new TransactionScope(TransactionScopeOption.Requires))
{
//some code
}
}
Note that in Method2 we did not call transactionScope2.Complete, which means transactionScope2 will roll back. In the case of classic nested transactions, we can roll back the internal transaction without rolling back the root one. Here both TransactionScope work within the same transaction, which means that if at least one of the internal transactionScope does not call Complete, the transaction will be marked for rollback, and when you exit the root TransactionScope, rollback will occur (commit / rollback transactions always occur when you exit the root TransactionScope ) And if in the root TransactionScope you try to call Complete (as in Method1), and the transaction has already been marked for rollback, TransactionAbortedException will be thrown.
Regarding nested TransactionScope, there is one unpleasant feature: at the time of writing the code, we do not know whether Complete of the current TransactionScope will mean commit transactions. Let's say we have the following method:
public void TransactionMethod(TransactionScopeOption.Requires)
{
using (var transactionScope = new TransactionScope(TransactionScopeOptions.Requires))
{
...
transactionScope.Complete();
}
//логика, завязанная на то, что транзакция уже закоммичена
}
, as well as the method that calls it:
public void CallingMethod1()
{
//...
TransactionMethod();
//...
}
And all would be fine, but over time, a higher-level service appears that calls TransactionMethod from its internal TransactionScope:
public void CallingMethod1()
{
//...
using (var transactionScope = new TransactionScope(TransactionScopeOptions.Requires))
{
//...
TransactionMethod();
//...
transactionScope.Complete();
}
//...
}
And here the call to transactionScope.Complete () inside TransactionMethod no longer leads to a transaction commit, which means that the underlying logic, tied to the fact that a transaction has already committed, will fail.
Although, in fairness, it is worth noting that the described situation is quite specific, and, as a rule, the developer does not care if a commit occurs when exiting the current transactionScope or one of the overlying ones.
Now it is time to focus on two other TransactionScopeOption values: RequiresNew and Suppress. I rarely had to use these modes. Moreover, if I am not mistaken, I did this only once, and just when solving the problem described in the previous article .
The issue of using or not using RequiresNew and Suppress, of course, is determined by the requirements of the algorithm, but I have some prejudices about this. The fact is that TransactionScope in the RequiresNew and Suppress modes, in the presence of operations modifying the state of the database, makes it impossible to use the old trick when the integration test code is in a transaction, which is rolled back at the end of the test, thereby restoring the state of the database:
[Test]
public void void IntegrationTest()
{
using (new TransactionScope())
{
//код теста
//не вызываем Complete
}
}
If TransactionScope in the Required mode is created in the test code, then they will hook to the TransactionScope test, which means we can roll back all the changes. If the code contains TransactionScope in the RequiresNew or Suppress mode, we cannot roll back the result of their work from the test TransactionScope. It is worth noting that the presence of logic tied at the time of the transaction commit (as in the previous example) also makes it impossible to use this technique.
Finally, I note that TransactionScope is local to the thread (because its implementation is based on the ThreadStatic variable). If you need to use one transaction from several flows, use the DependentTransaction class .
That’s probably all. TransactionScope is beautiful, but insidious - do not forget about it :)