Analyzer rule
LC058: TransactionScope without async flow spans an await
LC058 flags a TransactionScope created without TransactionScopeAsyncFlowOption.Enabled in async code, where the transaction is lost after an await.
- Default severity
- Warning
- Code fix
- Available
- Category
- Reliability
- Domain
- Execution & Async
Change the severity in .editorconfig:
[*.cs]
dotnet_diagnostic.LC058.severity = error
LC058: TransactionScope without async flow spans an await
In Plain Terms
You put a “reserved” sign on your table and went to the bar. When you came back, a different waiter was on shift and had never heard of your reservation.
Goal
Detect a TransactionScope created without TransactionScopeAsyncFlowOption.Enabled in an async method, lambda or local function when an await runs while the scope is still open.
The Problem
By default a TransactionScope stores the ambient transaction (Transaction.Current) on the current thread. An await can resume the rest of the method on a different thread, and there the ambient transaction is gone:
- EF Core enlists in
Transaction.Currentwhen it opens the connection, so commands after theawaitcan run outside the transaction and are not rolled back. - Disposing the scope on the new thread throws
InvalidOperationException: “A TransactionScope must be disposed on the same thread that it was created.”
// Violation: SaveChangesAsync may resume on another thread.
using var scope = new TransactionScope();
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
scope.Complete();
The EF Core documentation on using System.Transactions calls out TransactionScopeAsyncFlowOption.Enabled for async code.
The Fix
Pass TransactionScopeAsyncFlowOption.Enabled, using the constructor overload that matches the arguments you already pass:
using var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled);
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
scope.Complete();
using var scope = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled);
Analyzer Logic
ID: LC058
Category: Reliability
Severity: Warning
- Find
new System.Transactions.TransactionScope(...)(including target-typednew()) whose constructor takes noTransactionScopeAsyncFlowOption, or is passed the constantTransactionScopeAsyncFlowOption.Suppress. - Require the scope to be created in an
asyncmethod, lambda or local function. - Require the scope to be owned by a
usingstatement or ausingdeclaration. - Report when an
await,await foreachorawait usingin the same method runs while the scope is alive: inside theusingstatement’s body, or after theusingdeclaration in the same block.
When it stays quiet (non-goals)
- The scope passes
TransactionScopeAsyncFlowOption.Enabled, or an option value that is not a constant. - Synchronous code, including a non-async method that returns a
Task. - No
awaitruns while the scope is alive: the awaits come before the scope, after theusingstatement, or after the block that holds theusingdeclaration ends. - An
awaitinside a lambda or local function declared within the scope. That code runs when it is called, not as part of the scope. - A scope that is not owned by a
using, for example one disposed by hand in afinally. The rule does not track manual disposal.
Code Fix
Adds TransactionScopeAsyncFlowOption.Enabled as the last argument when TransactionScope has an overload with the same parameters plus the async flow option: (), (TransactionScopeOption), (TransactionScopeOption, TimeSpan), (TransactionScopeOption, TransactionOptions), (Transaction) and (Transaction, TimeSpan). An explicit Suppress is replaced with Enabled. When the other arguments are named, the new one is named too (asyncFlowOption:). The fix adds using System.Transactions; when the file does not import it.
The overloads that take an EnterpriseServicesInteropOption have no async flow counterpart, so they get no fix. The fixer compiles the result and offers nothing when the rewrite would add an error.
Test Cases
Violations
using (var scope = new TransactionScope()) { await db.SaveChangesAsync(ct); scope.Complete(); }
using var scope = new TransactionScope(TransactionScopeOption.Required, options); await db.SaveChangesAsync(ct);
using var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Suppress); await db.SaveChangesAsync(ct);
Valid
using var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled); await db.SaveChangesAsync(ct);
using (var scope = new TransactionScope()) { db.SaveChanges(); scope.Complete(); }