How to ambient transaction work under the hood


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:
TransactionScope
andTransaction.Current
How exactly does
ExecutionContext
'know' aboutContextKey
throughAsyncLocal
and what happens whenawait
occursHow
Transaction
is passed toNpgsql
How do
Commit()
andRollback()
workConclusion
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
AsyncFlow
inValidateAndSetAsyncFlowOption
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
CommonInitialize
method 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
Call
ValidateAsyncFlowOptionAndESInteropOption
- In short, it is forbidden to use
AsyncFlow
in single-threaded mode.
- In short, it is forbidden to use
Check scope option, if
TransactionScope.Required
- We return
false
if there is a transaction, ortrue
if 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. (TransactionScope
automatically 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
TransactionScope
and specify the transaction as ambient available viaTransaction.Current
inPushScope()
Here we are interested in the
CallContextCurrentData.CreateOrGetCurrentData
method, 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
ContextKey
transaction on these objects.Now
ExecutionContext
viaAsyncLocal
'knows' whichContextKey
is active and we can get the state from it.- See p.2 for exactly how this works.
SetCurrent
- set the current transaction.
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>
valuesCallContext
HttpContext
SecurityContext
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
ExecutionContext
is savedThen, when continuing (on another thread/task), it will be restored
And
s_currentTransaction.Value
will be equal tosomeContextKey
again.
What does AsyncLocal
do under the hood?
AsyncLocal<T>
is registered in theExecutionContext
via the .NET infrastructureWhen any
await
,ThreadPool.QueueUserWorkItem
,Task.Run
()
:Copying of ExecutionContext occurs
Along with it - all
AsyncLocal
are 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
async
method insideTransactionScope
using var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled))
await _repo.DoSmth();_
scope.Complete();
Inside the
_repo.DoSmth()
method you open a connection to the databaseWhen the connection is opened, the binding to the transaction takes place:
NpgsqlConnection
sourceIn 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
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.Current
as the active transactionCreates a real
BEGIN
at the PostgreSQL levelWaits for a
Commit()
orRollback()
command from .NET and does not do theCOMMIT
itself.
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.NET
interface for manual managingIt 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
(becauseISinglePhaseNotification
inherits 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.
Subscribe to my newsletter
Read articles from Sergei Petrov directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
