In previous article, we looked at how TransactionScope (ambient transaction) simplifies transactional consistency. But how does it do this? How does such a simple and convenient mechanism solve this problem? How exactly is a transaction passed between threads is asynchronous communication? And how does the process of rollback and commit work?
In this article we will try to figure it out with you.
Structure:
-
TransactionScopeandTransaction.Current - How exactly does
ExecutionContext'know' aboutContextKeythroughAsyncLocaland what happens whenawaitoccurs - How
Transactionis passed toNpgsql - How do
Commit()andRollback()work - Conclusion
1. TransactionScope and Transaction.Current
sources:
The TransactionScope class in .NET is used to automatically manage transactions through the ambient transactions mechanism. You can read more about its application here
Now let's try to figure out what happens when we create a TransactionScope.
Exp:
using var scope = new TransactionScope();
// our repo methods.
scope.Complete();
For understanding, I suggest you open the TransactionScope.cs source code in parallel - source.
NOTE: For easy of reading, find the creation of the public TransactionScope(...) instance.
At the time of writing this is line 53.
Steps:
- Validate and set
AsyncFlowinValidateAndSetAsyncFlowOption - Trying to see if there is a transaction already created or if we have to create a new one in
NeedToCreateTransaction(the name might be a bit weird, that's ok (-: )- In the
CommonInitializemethod usingTransaction.GetCurrentTransactionAndScope(...)we try to get the current transaction and scope:- We look at the current transaction in current thread
- If there is one we return otherwise
null.
- If there is one we return otherwise
- We look at the current transaction in current thread
- Call
ValidateAsyncFlowOptionAndESInteropOption- In short, it is forbidden to use
AsyncFlowin single-threaded mode.
- In short, it is forbidden to use
- In the
- Check scope option, if
TransactionScope.Required- We return
falseif there is a transaction, ortrueif we need to create one.
- We return
- Creation is done via
CommitableTransaction- This is the actual managed transaction object that will be either
Commit()orRollback()in the end. (TransactionScopeautomatically completes the transaction only ifComplete()has been called and there are no exceptions or other processed errors. Otherwise, the transaction is rolled back when leaving the using scope)
- This is the actual managed transaction object that will be either
- Make a
Clone()transaction for subsequent transfer toTransaction.Current- Why? The
Clone()method returns a transaction object without the ability to doCommit(). Isolation and single point control mechanism.
- Why? The
- Set
TransactionScopeand specify the transaction as ambient available viaTransaction.CurrentinPushScope()- Here we are interested in the
CallContextCurrentData.CreateOrGetCurrentDatamethod, it is operation is as follows:-
AsyncLocal<ContextKey>- responsible for 'where we are'. -
ConditionalWeakTable<ContextKey, ContextData- stores the actual state of the transaction. - => we add our
ContextKeytransaction on these objects. - Now
ExecutionContextviaAsyncLocal'knows' whichContextKeyis active and we can get the state from it.- See p.2 for exactly how this works.
-
-
SetCurrent- set the current transaction.
- Here we are interested in the
2. How exactly does ExecutionContext 'know' about ContextKey through AsyncLocal and what happens when await occurs
ExecutionContext - This is the container of the logical execution context, which includes:
-
AsyncLocal<T>values CallContextHttpContextSecurityContext- and other 'logical' thread data
Its main task:
Automatically copy and restore all values associated with the current execution context when you switch between threads, tasks, etc.
Where is AsyncLocal in this?
Example from Transaction.cs
private static readonly AsyncLocal<ContextKey?> s_currentTransaction = new AsyncLocal<ContextKey?>();
When we do:
s_currentTransaction.Value = someContextKey;
Its mean, that:
- The current
ExecutionContextis saved - Then, when continuing (on another thread/task), it will be restored
- And
s_currentTransaction.Valuewill be equal tosomeContextKeyagain.
What does AsyncLocal do under the hood?
-
AsyncLocal<T>is registered in theExecutionContextvia the .NET infrastructure - When any
await,ThreadPool.QueueUserWorkItem,Task.Run():- Copying of ExecutionContext occurs
- Along with it - all
AsyncLocalare copied
3. How Transaction is passed to Npgsql
This is where we have to touch on ADO.NET
ADO.NET is a low-level data access platform in .NET that:
- Allow working with databases (SQL Server, PostgreSQL, Oracle, etc.)
- Supports connection, executing SQL queries, reading results, transaction read Microsoft documentation for more details - here
Npgsql is an ADO.NET provider for PostgreSQL. In this article we will consider it, but the principle of transaction retrieval itself should be almost the same for other providers.
When you work with TransactionScope or Transaction.Current and open SqlConnection, NpgsqlConnection and etc, the following happens:
Inside Npgsql when you call NpgsqlConnection.Open(), Transaction.Current is checked, and it it is not null, the driver itself call EnlistTransaction(...), thus attaching the connection to the transaction.
NOTE: What happens if there is no transaction? - Then your request will work in normal autocommit mode. So be careful when creating TransactionScope to avoid making this situation
Steps:
- You call
asyncmethod insideTransactionScope
using var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled))
await _repo.DoSmth();_
scope.Complete();
- Inside the
_repo.DoSmth()method you open a connection to the database - When the connection is opened, the binding to the transaction takes place:
-
NpgsqlConnectionsource - In the
OpenAsync()method, we definevar enlistToTransaction = Settings.Enlist ? Transaction.Current : null -
Settings.Enlist- answers whether to try to connect to the current transaction or not.- By default this value =
true. Documentation - here
- By default this value =
- => we take
Transaction.Current- Where the next chain of calls will be:
Transaction.Current => GetCurrentTransactionAndScope(...) => LookupContextData(...) => TryGetCurrentData(...) => s_currentTransaction.Value
- Where the next chain of calls will be:
-
FINISH: Congratulations, we have figured out how a transaction works within TransactionScope.
But that is not all. Let's try to understand what IDBTransaction has to do with it and how exactly Commit and Rollback will work.
4. How do Commit() and Rollback() work
We have worked out that:
-
Transaction.Current- is simply a global reference to the current active transaction in the thread orAsyncLocal - It does not commit or rollback
- It provides you a transaction that you can:
-
EnlistTransaction(tx)is a method that:- Remember
Transaction.Currentas the active transaction - Creates a real
BEGINat the PostgreSQL level - Waits for a
Commit()orRollback()command from .NET and does not do theCOMMITitself.
- Remember
-
Actually Commit() is only called manually by the user via scope.Complete().
If scope.Complete() has not been called, then Rollback() will be executed when scope.Dispose() is called automatically or manually.
What does IDbTransaction have to do with this?
In brief, it has nothing to do with:
- IDbTransaction is an
ADO.NETinterface for manual managing - It allows you to manually manage a transaction
- has
Commit()andRollback()likeTransactionScope()
Despite this TransactionScope and IDBTransaction do not overlap directly, only relatively.
How then does TransactionScope work without IDBTransaction?
When you use TransactionScope with NpgsqlConnection Npgsql registers a special VilatileResourceManager object in Transaction.Current via the
transaction.EnlistVolatile(volatileResourceManager, EnlistmentOptions.None);
This VilatileResourceManager implements:
-
ISinglePhaseNotification(for an one-phase commit) -
IEnlistmentNotification(becauseISinglePhaseNotificationinherits it) (for a two-phase commit)
When we call scope.Complete() = just sets the flag _complete = true
scope.Dispose() => call Commit() from CommitableTransaction
Where resource.SinglePhaseCommit(enlistment) will subsequently be called.
On the Npgsql side, the SinglePhaseCommit implementation will call _connector.ExecuteInternal(“Commit”) where _connector = NpgsqlConnector;
The NpgsqConnector itself does the following:
- Formats the SQL-query into binary or text PostgreSQL protocol
- Sends the command via TCP (via
Socket,Stream) - Processes the response (e.g.,
CommandComplete) - Updates the internal transaction state in
connector.TrasactionStatus
NOTE: This article only considers the single-phase commit scenario, where only one resource in invilved in a transaction.
Conclusion
As a result, we have seen how exactly TransactionScope works under the hood and what makes it possible to write such simple and elegant code.
Since this article serves as a companion to C# Ambient Transactions: What They Are and Why They Matter
I’ve added transaction logging at each step in the demo project, which you can see via the console. The code is here
console log exp.
[Endpoint] Start: Thread: 7
[Endpoint] Start: Transaction.Current:
=> [TxFactory] Creating TransactionScope
=> [TxFactory] Thread: 7
=> [TxFactory] TransactionScope created. Transaction.Current ID: c37ac104-0513-4445-b2cd-ae4687fb5598:1
=> => [DB] UpdateBalanca: UserId: 1. Before OpenAsync. Thread: 7
=> => [DB] UpdateBalanca: UserId: 1. Transaction.Current: c37ac104-0513-4445-b2cd-ae4687fb5598:1
=> => [DB] UpdateBalanca: UserId: UserId: 1. After OpenAsync. Still in Transaction: c37ac104-0513-4445-b2cd-ae4687fb5598:1
=> => [DB] UpdateBalanca: UserId: 2. Before OpenAsync. Thread: 7
=> => [DB] UpdateBalanca: UserId: 2. Transaction.Current: c37ac104-0513-4445-b2cd-ae4687fb5598:1
=> => [DB] UpdateBalanca: UserId: UserId: 2. After OpenAsync. Still in Transaction: c37ac104-0513-4445-b2cd-ae4687fb5598:1
=> => [DB] AddLog: UserFrom: 1 -> UserTo: 2. Before OpenAsync. Thread: 7
=> => [DB] AddLog: UserFrom: 1 -> UserTo: 2. Transaction.Current: c37ac104-0513-4445-b2cd-ae4687fb5598:1
=> => [DB] AddLog: UserFrom: 1 -> UserTo: 2. After OpenAsync. Still in Transaction: c37ac104-0513-4445-b2cd-ae4687fb5598:1
[Endpoint] Stop: After [TxFactory].Dispose: Thread: 7
[Endpoint] Stop: After [TxFactory].Dispose: Transaction.Current:
Thank you for your attention.
Top comments (0)