Welcome to mirror list, hosted at ThFree Co, Russian Federation.

OleTxTests.cs « tests « System.Transactions.Local « libraries « src - github.com/dotnet/runtime.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
blob: e646c09a59326c6535022031bb68d58b833b2e29 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.IO;
using System.Runtime.InteropServices;
using System.Threading;
using Microsoft.DotNet.RemoteExecutor;
using Xunit;
using Xunit.Sdk;

namespace System.Transactions.Tests;

#nullable enable

[PlatformSpecific(TestPlatforms.Windows)]
[SkipOnMono("COM Interop not supported on Mono")]
public class OleTxTests : IClassFixture<OleTxTests.OleTxFixture>
{
    private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(1);

    public OleTxTests(OleTxFixture fixture)
    {
    }

    [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotWindowsNanoServer))]
    [InlineData(Phase1Vote.Prepared, Phase1Vote.Prepared, EnlistmentOutcome.Committed, EnlistmentOutcome.Committed, TransactionStatus.Committed)]
    [InlineData(Phase1Vote.Prepared, Phase1Vote.ForceRollback, EnlistmentOutcome.Aborted, EnlistmentOutcome.Aborted, TransactionStatus.Aborted)]
    [InlineData(Phase1Vote.ForceRollback, Phase1Vote.Prepared, EnlistmentOutcome.Aborted, EnlistmentOutcome.Aborted, TransactionStatus.Aborted)]
    public void Two_durable_enlistments_commit(Phase1Vote vote1, Phase1Vote vote2, EnlistmentOutcome expectedOutcome1, EnlistmentOutcome expectedOutcome2, TransactionStatus expectedTxStatus)
        => Test(() =>
        {
            using var tx = new CommittableTransaction();

            try
            {
                var enlistment1 = new TestEnlistment(vote1, expectedOutcome1);
                var enlistment2 = new TestEnlistment(vote2, expectedOutcome2);

                tx.EnlistDurable(Guid.NewGuid(), enlistment1, EnlistmentOptions.None);
                tx.EnlistDurable(Guid.NewGuid(), enlistment2, EnlistmentOptions.None);

                Assert.Equal(TransactionStatus.Active, tx.TransactionInformation.Status);
                tx.Commit();
            }
            catch (TransactionInDoubtException)
            {
                Assert.Equal(TransactionStatus.InDoubt, expectedTxStatus);
            }
            catch (TransactionAbortedException)
            {
                Assert.Equal(TransactionStatus.Aborted, expectedTxStatus);
            }

            Retry(() => Assert.Equal(expectedTxStatus, tx.TransactionInformation.Status));
        });

    [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNotWindowsNanoServer))]
    public void Two_durable_enlistments_rollback()
        => Test(() =>
        {
            using var tx = new CommittableTransaction();

            var enlistment1 = new TestEnlistment(Phase1Vote.Prepared, EnlistmentOutcome.Aborted);
            var enlistment2 = new TestEnlistment(Phase1Vote.Prepared, EnlistmentOutcome.Aborted);

            tx.EnlistDurable(Guid.NewGuid(), enlistment1, EnlistmentOptions.None);
            tx.EnlistDurable(Guid.NewGuid(), enlistment2, EnlistmentOptions.None);

            tx.Rollback();

            Assert.False(enlistment1.WasPreparedCalled);
            Assert.False(enlistment2.WasPreparedCalled);

            // This matches the .NET Framework behavior
            Retry(() => Assert.Equal(TransactionStatus.Aborted, tx.TransactionInformation.Status));
        });

    [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotWindowsNanoServer))]
    [InlineData(0)]
    [InlineData(1)]
    [InlineData(2)]
    public void Volatile_and_durable_enlistments(int volatileCount)
        => Test(() =>
        {
            using var tx = new CommittableTransaction();

            if (volatileCount > 0)
            {
                TestEnlistment[] volatiles = new TestEnlistment[volatileCount];
                for (int i = 0; i < volatileCount; i++)
                {
                    // It doesn't matter what we specify for SinglePhaseVote.
                    volatiles[i] = new TestEnlistment(Phase1Vote.Prepared, EnlistmentOutcome.Committed);
                    tx.EnlistVolatile(volatiles[i], EnlistmentOptions.None);
                }
            }

            var durable = new TestEnlistment(Phase1Vote.Prepared, EnlistmentOutcome.Committed);

            // Creation of two phase durable enlistment attempts to promote to MSDTC
            tx.EnlistDurable(Guid.NewGuid(), durable, EnlistmentOptions.None);

            tx.Commit();

            Retry(() => Assert.Equal(TransactionStatus.Committed, tx.TransactionInformation.Status));
        });

    protected static bool IsRemoteExecutorSupportedAndNotNano => RemoteExecutor.IsSupported && PlatformDetection.IsNotWindowsNanoServer;

    [ConditionalFact(nameof(IsRemoteExecutorSupportedAndNotNano))]
    public void Promotion()
    {
        Test(() =>
        {
            // This simulates the full promotable flow, as implemented for SQL Server.

            // We are going to spin up two external processes.
            // 1. The 1st external process will create the transaction and save its propagation token to disk.
            // 2. The main process will read that, and propagate the transaction to the 2nd external process.
            // 3. The main process will then notify the 1st external process to commit (as the main's transaction is delegated to it).
            // 4. At that point the MSDTC Commit will be triggered; enlistments on both the 1st and 2nd processes will be notified
            //    to commit, and the transaction status will reflect the committed status in the main process.
            using var tx = new CommittableTransaction();

            string propagationTokenFilePath = Path.GetTempFileName();
            string exportCookieFilePath = Path.GetTempFileName();
            using var waitHandle1 = new EventWaitHandle(initialState: false, EventResetMode.ManualReset, "System.Transactions.Tests.OleTxTests.Promotion1");
            using var waitHandle2 = new EventWaitHandle(initialState: false, EventResetMode.ManualReset, "System.Transactions.Tests.OleTxTests.Promotion2");
            using var waitHandle3 = new EventWaitHandle(initialState: false, EventResetMode.ManualReset, "System.Transactions.Tests.OleTxTests.Promotion3");

            RemoteInvokeHandle? remote1 = null, remote2 = null;

            try
            {
                remote1 = RemoteExecutor.Invoke(Remote1, propagationTokenFilePath, new RemoteInvokeOptions { ExpectedExitCode = 42 });

                // Wait for the external process to start a transaction and save its propagation token
                Assert.True(waitHandle1.WaitOne(Timeout));

                // Enlist the first PSPE. No escalation happens yet, since its the only enlistment.
                var pspe1 = new TestPromotableSinglePhaseNotification(propagationTokenFilePath);
                Assert.True(tx.EnlistPromotableSinglePhase(pspe1));
                Assert.True(pspe1.WasInitializedCalled);
                Assert.False(pspe1.WasPromoteCalled);
                Assert.False(pspe1.WasRollbackCalled);
                Assert.False(pspe1.WasSinglePhaseCommitCalled);

                // Enlist the second PSPE. This returns false and does nothing, since there's already an enlistment.
                var pspe2 = new TestPromotableSinglePhaseNotification(propagationTokenFilePath);
                Assert.False(tx.EnlistPromotableSinglePhase(pspe2));
                Assert.False(pspe2.WasInitializedCalled);
                Assert.False(pspe2.WasPromoteCalled);
                Assert.False(pspe2.WasRollbackCalled);
                Assert.False(pspe2.WasSinglePhaseCommitCalled);

                // Now generate an export cookie for the 2nd external process. This causes escalation and promotion.
                byte[] whereabouts = TransactionInterop.GetWhereabouts();
                byte[] exportCookie = TransactionInterop.GetExportCookie(tx, whereabouts);

                Assert.True(pspe1.WasPromoteCalled);
                Assert.False(pspe1.WasRollbackCalled);
                Assert.False(pspe1.WasSinglePhaseCommitCalled);

                // Write the export cookie and start the 2nd external process, which will read the cookie and enlist in the transaction.
                // Wait for it to complete.
                File.WriteAllBytes(exportCookieFilePath, exportCookie);
                remote2 = RemoteExecutor.Invoke(Remote2, exportCookieFilePath, new RemoteInvokeOptions { ExpectedExitCode = 42 });
                Assert.True(waitHandle2.WaitOne(Timeout));

                // We now have two external processes with enlistments to our distributed transaction. Commit.
                // Since our transaction is delegated to the 1st PSPE enlistment, Sys.Tx will call SinglePhaseCommit on it.
                // In SQL Server this contacts the 1st DB to actually commit the transaction with MSDTC. In this simulation we'll just use a wait handle to trigger this.
                tx.Commit();
                Assert.True(pspe1.WasSinglePhaseCommitCalled);
                waitHandle3.Set();

                Retry(() => Assert.Equal(TransactionStatus.Committed, tx.TransactionInformation.Status));
            }
            catch
            {
                try
                {
                    remote1?.Process.Kill();
                    remote2?.Process.Kill();
                }
                catch
                {
                }

                throw;
            }
            finally
            {
                File.Delete(propagationTokenFilePath);
            }

            // Disposal of the RemoteExecutor handles will wait for the external processes to exit with the right exit code,
            // which will happen when their enlistments receive the commit.
            remote1?.Dispose();
            remote2?.Dispose();
        });

        static void Remote1(string propagationTokenFilePath)
            => Test(() =>
            {
                using var tx = new CommittableTransaction();

                var outcomeEvent = new AutoResetEvent(false);
                var enlistment = new TestEnlistment(Phase1Vote.Prepared, EnlistmentOutcome.Committed, outcomeReceived: outcomeEvent);
                tx.EnlistDurable(Guid.NewGuid(), enlistment, EnlistmentOptions.None);

                // We now have an OleTx transaction. Save its propagation token to disk so that the main process can read it when promoting.
                byte[] propagationToken = TransactionInterop.GetTransmitterPropagationToken(tx);
                File.WriteAllBytes(propagationTokenFilePath, propagationToken);

                // Signal to the main process that the propagation token is ready to be read
                using var waitHandle1 = new EventWaitHandle(initialState: false, EventResetMode.ManualReset, "System.Transactions.Tests.OleTxTests.Promotion1");
                waitHandle1.Set();

                // The main process will now import our transaction via the propagation token, and propagate it to a 2nd process.
                // In the main process the transaction is delegated; we're the one who started it, and so we're the one who need to Commit.
                // When Commit() is called in the main process, that will trigger a SinglePhaseCommit on the PSPE which represents us. In SQL Server this
                // contacts the DB to actually commit the transaction with MSDTC. In this simulation we'll just use the wait handle again to trigger this.
                using var waitHandle3 = new EventWaitHandle(initialState: false, EventResetMode.ManualReset, "System.Transactions.Tests.OleTxTests.Promotion3");
                Assert.True(waitHandle3.WaitOne(Timeout));

                tx.Commit();

                // Wait for the commit to occur on our enlistment, then exit successfully.
                Assert.True(outcomeEvent.WaitOne(Timeout));
                Environment.Exit(42); // 42 is error code expected by RemoteExecutor
            });

        static void Remote2(string exportCookieFilePath)
            => Test(() =>
            {
                // Load the export cookie and enlist durably
                byte[] exportCookie = File.ReadAllBytes(exportCookieFilePath);
                using var tx = TransactionInterop.GetTransactionFromExportCookie(exportCookie);

                // Now enlist durably. This triggers promotion of the first PSPE, reading the propagation token.
                var outcomeEvent = new AutoResetEvent(false);
                var enlistment = new TestEnlistment(Phase1Vote.Prepared, EnlistmentOutcome.Committed, outcomeReceived: outcomeEvent);
                tx.EnlistDurable(Guid.NewGuid(), enlistment, EnlistmentOptions.None);

                // Signal to the main process that we're enlisted and ready to commit
                using var waitHandle = new EventWaitHandle(initialState: false, EventResetMode.ManualReset, "System.Transactions.Tests.OleTxTests.Promotion2");
                waitHandle.Set();

                // Wait for the main process to commit the transaction
                Assert.True(outcomeEvent.WaitOne(Timeout));
                Environment.Exit(42); // 42 is error code expected by RemoteExecutor
            });
    }

    public class TestPromotableSinglePhaseNotification : IPromotableSinglePhaseNotification
    {
        private string _propagationTokenFilePath;

        public TestPromotableSinglePhaseNotification(string propagationTokenFilePath)
            => _propagationTokenFilePath = propagationTokenFilePath;

        public bool WasInitializedCalled { get; private set; }
        public bool WasPromoteCalled { get; private set; }
        public bool WasRollbackCalled { get; private set; }
        public bool WasSinglePhaseCommitCalled { get; private set; }

        public void Initialize()
            => WasInitializedCalled = true;

        public byte[] Promote()
        {
            WasPromoteCalled = true;

            return File.ReadAllBytes(_propagationTokenFilePath);
        }

        public void Rollback(SinglePhaseEnlistment singlePhaseEnlistment)
            => WasRollbackCalled = true;

        public void SinglePhaseCommit(SinglePhaseEnlistment singlePhaseEnlistment)
        {
            WasSinglePhaseCommitCalled = true;

            singlePhaseEnlistment.Committed();
        }
    }

    [ConditionalFact(nameof(IsRemoteExecutorSupportedAndNotNano))]
    public void Recovery()
    {
        Test(() =>
        {
            // We are going to spin up an external process to also enlist in the transaction, and then to crash when it
            // receives the commit notification. We will then initiate the recovery flow.

            using var tx = new CommittableTransaction();

            var outcomeEvent1 = new AutoResetEvent(false);
            var enlistment1 = new TestEnlistment(Phase1Vote.Prepared, EnlistmentOutcome.Committed, outcomeReceived: outcomeEvent1);
            var guid1 = Guid.NewGuid();
            tx.EnlistDurable(guid1, enlistment1, EnlistmentOptions.None);

            // The propagation token is used to propagate the transaction to that process so it can enlist to our
            // transaction. We also provide the resource manager identifier GUID, and a path where the external process will
            // write the recovery information it will receive from the MSDTC when preparing.
            // We'll need these two elements later in order to Reenlist and trigger recovery.
            byte[] propagationToken = TransactionInterop.GetTransmitterPropagationToken(tx);
            string propagationTokenText = Convert.ToBase64String(propagationToken);
            var guid2 = Guid.NewGuid();
            string secondEnlistmentRecoveryFilePath = Path.GetTempFileName();

            using var waitHandle = new EventWaitHandle(
                initialState: false,
                EventResetMode.ManualReset,
                "System.Transactions.Tests.OleTxTests.Recovery");

            try
            {
                using (RemoteExecutor.Invoke(
                           EnlistAndCrash,
                           propagationTokenText, guid2.ToString(), secondEnlistmentRecoveryFilePath,
                           new RemoteInvokeOptions { ExpectedExitCode = 42 }))
                {
                    // Wait for the external process to enlist in the transaction, it will signal this EventWaitHandle.
                    Assert.True(waitHandle.WaitOne(Timeout));

                    tx.Commit();
                }

                // The other has crashed when the MSDTC notified it to commit.
                // Load the recovery information the other process has written to disk for us and reenlist with
                // the failed RM's Guid to commit.
                var outcomeEvent3 = new AutoResetEvent(false);
                var enlistment3 = new TestEnlistment(Phase1Vote.Prepared, EnlistmentOutcome.Committed, outcomeReceived: outcomeEvent3);
                byte[] secondRecoveryInformation = File.ReadAllBytes(secondEnlistmentRecoveryFilePath);
                _ = TransactionManager.Reenlist(guid2, secondRecoveryInformation, enlistment3);
                TransactionManager.RecoveryComplete(guid2);

                Assert.True(outcomeEvent1.WaitOne(Timeout));
                Assert.True(outcomeEvent3.WaitOne(Timeout));
                Assert.Equal(EnlistmentOutcome.Committed, enlistment1.Outcome);
                Assert.Equal(EnlistmentOutcome.Committed, enlistment3.Outcome);
                Assert.Equal(TransactionStatus.Committed, tx.TransactionInformation.Status);

                // Note: verify manually in the MSDTC console that the distributed transaction is gone
                // (i.e. successfully committed),
                // (Start -> Component Services -> Computers -> My Computer -> Distributed Transaction Coordinator ->
                //           Local DTC -> Transaction List)
            }
            finally
            {
                File.Delete(secondEnlistmentRecoveryFilePath);
            }
        });

        static void EnlistAndCrash(string propagationTokenText, string resourceManagerIdentifierGuid, string recoveryInformationFilePath)
            => Test(() =>
            {
                byte[] propagationToken = Convert.FromBase64String(propagationTokenText);
                using var tx = TransactionInterop.GetTransactionFromTransmitterPropagationToken(propagationToken);

                var crashingEnlistment = new CrashingEnlistment(recoveryInformationFilePath);
                tx.EnlistDurable(Guid.Parse(resourceManagerIdentifierGuid), crashingEnlistment, EnlistmentOptions.None);

                // Signal to the main process that we've enlisted and are ready to accept prepare/commit.
                using var waitHandle = new EventWaitHandle(initialState: false, EventResetMode.ManualReset, "System.Transactions.Tests.OleTxTests.Recovery");
                waitHandle.Set();

                // We've enlisted, and set it up so that when the MSDTC tells us to commit, the process will crash.
                Thread.Sleep(Timeout);
            });
    }

    public class CrashingEnlistment : IEnlistmentNotification
    {
        private string _recoveryInformationFilePath;

        public CrashingEnlistment(string recoveryInformationFilePath)
            => _recoveryInformationFilePath = recoveryInformationFilePath;

        public void Prepare(PreparingEnlistment preparingEnlistment)
        {
            // Received a prepare notification from MSDTC, persist the recovery information so that the main process can perform recovery for it.
            File.WriteAllBytes(_recoveryInformationFilePath, preparingEnlistment.RecoveryInformation());

            preparingEnlistment.Prepared();
        }

        public void Commit(Enlistment enlistment)
            => Environment.Exit(42); // 42 is error code expected by RemoteExecutor

        public void Rollback(Enlistment enlistment)
            => Environment.Exit(1);

        public void InDoubt(Enlistment enlistment)
            => Environment.Exit(1);
    }

    [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNotWindowsNanoServer))]
    public void TransmitterPropagationToken()
        => Test(() =>
        {
            using var tx = new CommittableTransaction();

            Assert.Equal(Guid.Empty, tx.TransactionInformation.DistributedIdentifier);

            var propagationToken = TransactionInterop.GetTransmitterPropagationToken(tx);

            Assert.NotEqual(Guid.Empty, tx.TransactionInformation.DistributedIdentifier);

            var tx2 = TransactionInterop.GetTransactionFromTransmitterPropagationToken(propagationToken);

            Assert.Equal(tx.TransactionInformation.DistributedIdentifier, tx2.TransactionInformation.DistributedIdentifier);
        });

    [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNotWindowsNanoServer))]
    public void GetExportCookie()
        => Test(() =>
        {
            using var tx = new CommittableTransaction();

            var whereabouts = TransactionInterop.GetWhereabouts();

            Assert.Equal(Guid.Empty, tx.TransactionInformation.DistributedIdentifier);

            var exportCookie = TransactionInterop.GetExportCookie(tx, whereabouts);

            Assert.NotEqual(Guid.Empty, tx.TransactionInformation.DistributedIdentifier);

            var tx2 = TransactionInterop.GetTransactionFromExportCookie(exportCookie);

            Assert.Equal(tx.TransactionInformation.DistributedIdentifier, tx2.TransactionInformation.DistributedIdentifier);
        });

    // Test currently skipped, #74745
    private void GetDtcTransaction()
        => Test(() =>
        {
            using var tx = new CommittableTransaction();

            var outcomeReceived = new AutoResetEvent(false);

            var enlistment = new TestEnlistment(
                Phase1Vote.Prepared, EnlistmentOutcome.Committed, outcomeReceived: outcomeReceived);

            Assert.Equal(Guid.Empty, tx.PromoterType);

            tx.EnlistVolatile(enlistment, EnlistmentOptions.None);

            // Forces promotion to MSDTC, returns an ITransaction for use only with System.EnterpriseServices.
            _ = TransactionInterop.GetDtcTransaction(tx);

            Assert.Equal(TransactionStatus.Active, tx.TransactionInformation.Status);
            Assert.Equal(TransactionInterop.PromoterTypeDtc, tx.PromoterType);

            tx.Commit();

            Assert.True(outcomeReceived.WaitOne(Timeout));
            Assert.Equal(EnlistmentOutcome.Committed, enlistment.Outcome);
            Retry(() => Assert.Equal(TransactionStatus.Committed, tx.TransactionInformation.Status));
        });

    private static void Test(Action action)
    {
        // Temporarily skip on 32-bit where we have an issue.
        // ARM64 issue: https://github.com/dotnet/runtime/issues/74170
        if (!Environment.Is64BitProcess || PlatformDetection.IsArm64Process)
        {
            return;
        }

        // In CI, we sometimes get XACT_E_TMNOTAVAILABLE; when it happens, it's typically on the very first
        // attempt to connect to MSDTC (flaky/slow on-demand startup of MSDTC), though not only.
        // This catches that error and retries.
        int nRetries = 5;

        while (true)
        {
            try
            {
                action();
                return;
            }
            catch (TransactionException e) when (e.InnerException is TransactionManagerCommunicationException)
            {
                if (--nRetries == 0)
                {
                    throw;
                }

                Thread.Sleep(500);
            }
        }
    }

    // MSDTC is aynchronous, i.e. Commit/Rollback may return before the transaction has actually completed;
    // so allow some time for assertions to succeed.
    private static void Retry(Action action)
    {
        const int Retries = 50;

        for (var i = 0; i < Retries; i++)
        {
            try
            {
                action();
                return;
            }
            catch (EqualException)
            {
                if (i == Retries - 1)
                {
                    throw;
                }

                Thread.Sleep(100);
            }
        }
    }

    public class OleTxFixture
    {
        // In CI, we sometimes get XACT_E_TMNOTAVAILABLE on the very first attempt to connect to MSDTC;
        // this is likely due to on-demand slow startup of MSDTC. Perform pre-test connecting with retry
        // to ensure that MSDTC is properly up when the first test runs.
        public OleTxFixture()
            => Test(() =>
            {
                using var tx = new CommittableTransaction();

                var enlistment1 = new TestEnlistment(Phase1Vote.Prepared, EnlistmentOutcome.Committed);
                var enlistment2 = new TestEnlistment(Phase1Vote.Prepared, EnlistmentOutcome.Committed);

                tx.EnlistDurable(Guid.NewGuid(), enlistment1, EnlistmentOptions.None);
                tx.EnlistDurable(Guid.NewGuid(), enlistment2, EnlistmentOptions.None);

                tx.Commit();
            });
    }
}