Quick answer: Call Dispose() on the old TransactionScope, never reuse a transaction ID, flush or enumerate stale MSDTC transactions with dtcping, and if a specific GUID is stuck, recycle the MSDTC service to clear it.
What's actually happening here is that something handed MSDTC (or the SQL Server resource manager) a transaction object whose identifier is already registered on that coordinator. The COM+ epoch matters: every transaction gets a GUID plus a sequence number. If you submit the same combination twice — from a retry loop, a cloned scope, or a COM+ component that never released its context — the resource manager rejects the second enlist with XACT_E_DUPLICATE_TRANSID. The error surfaces as 0x8004D10B in HRESULT form, which sometimes gets rendered as a decimal like -2147163893 depending on the language.
The scenario I keep seeing in the wild: an ASP.NET app wraps a TransactionScope in a retry helper. The helper catches a transient SQL deadlock, rebuilds the work, but keys the retry off the original transaction ID it stashed in a cache. Second attempt enlists a fresh scope that MSDTC sees as the same transaction. Boom. The connection string check passes, the database is healthy, but the enlist dies before a single row is touched.
Fix steps
- Kill the retry-reuse pattern. If your code creates a new
TransactionScopeinside a retry loop, make sure the scope is fully disposed before the next iteration starts. Wrap it in ausingblock and never stash the transaction object outside it.
If you were holding thefor (int attempt = 0; attempt < 3; attempt++) { using (var scope = new TransactionScope(TransactionScopeOption.Required)) { DoWork(); // enlist happens here scope.Complete(); } // Dispose fires, GUID releases }TransactionScopeoutside the loop because "it's faster to reuse," stop. You can't reuse it. That's the whole error. - Look for double-enlist in ADO.NET. Opening two
SqlConnectionobjects inside the same scope against the same SQL Server instance, then issuing commands on both, can confuse the enlistment if the connection pool returns a connection that still has an ambient transaction reference from a prior request. AddEnlist=falseto the connection string temporarily and enlist manually — if the error clears, you've found the culprit. Then fix the pool hygiene instead of leavingEnlist=falsein production. - Check MSDTC itself for a stuck transaction. On the SQL Server host, open Component Services (
dcomcnfg), walk to Component Services → Computers → My Computer → Distributed Transaction Coordinator → Transaction Statistics. Set the statistic filter to show all. If you see a transaction in theIn DoubtorPreparedstate that matches the GUID from your error, that's your duplicate. Right-click → Resolve → Commit or Abort depending on what the log says. - Recycle MSDTC if a specific GUID stays wedged. Sometimes the transaction is orphaned and neither Commit nor Abort clears it. From an elevated prompt:
That wipes the coordinator's in-memory transaction table. Any application with an in-flight distributed transaction during the restart will get a different error (usuallynet stop msdtc net start msdtcXACT_E_CONNECTION_DOWN), so do this during a maintenance window. On a cluster, fail the MSDTC resource over instead —net stopon a clustered instance fights the cluster service. - Verify promotion is happening where you think. A single
SqlConnectionagainst one SQL Server stays a local transaction and never touches MSDTC. The moment you open a second connection, or hit a second resource manager (a second SQL Server, an MQ queue, an Oracle link), the transaction promotes. Promotion is not transactional in the sense people assume — if promotion fails halfway, the local transaction can be left registered while the distributed one never gets a clean ID. Check Transaction Statistics for a stale local registration matching the SQL Server connection's SPID.
If the main fix doesn't work
- Bypass MSDTC entirely. If you can't reproduce with a single connection, you don't actually need a distributed transaction — you need a local one. Collapse the two connections into one, or use
TransactionScopeOption.Suppressaround the read-only side. - Restart the application pool, not just the app. ASP.NET keeps ambient transaction state on the thread. A stale
Transaction.Currentfrom a previous request thread that got recycled into the pool with a live GUID will fire this error on the next request.iisresetis heavy; recycling the app pool is usually enough. - Check COM+ components. If the error comes from a legacy COM+ serviced component, its
SetComplete/SetAbortcalls have to match. CallingSetCompletetwice on the same context, or calling it after the context was already returned to the pool, throws this exact HRESULT. Trace the component with the COM+ Trace Viewer. - Look at the SQL Server error log around the timestamp. Enlistment failures sometimes leave breadcrumbs like
The transaction manager has disabled its support for remote/network transactions.That's a different problem (DTC network access), but it can surface asXACT_E_DUPLICATE_TRANSIDif the enlist half-succeeds before the network check fails.
Prevention
Treat transaction IDs as single-use. The reason step 1 works is that a TransactionScope's identifier is only valid inside its own lifetime — dispose it and the GUID is retired. If you're caching transaction objects, serializing them, or passing them across thread boundaries (looking at you, Task.Run inside a scope), you're going to keep hitting this. Also keep retry logic outside the scope boundary, not inside it. And on any server that runs distributed work, monitor MSDTC's transaction statistics weekly — a growing list of prepared-but-not-committed transactions is the warning sign that precedes this error by hours or days.
One more thing: don't fix this by disabling transaction promotion with
TransactionScopeOption.Suppressunless you've confirmed the second resource doesn't need to be part of the same unit of work. That "fix" trades a loud error for silent data inconsistency, which is worse.