From adb192cad7abcff3774865ec57acdec8497a2e48 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Tue, 22 Sep 2026 16:54:47 -0300 Subject: [PATCH] Release uplift gate when transaction setup fails --- .../Implementation/UpliftDataService.cs | 31 ++++---- .../UpliftDataServiceTransactionTests.cs | 77 +++++++++++++++++++ 2 files changed, 94 insertions(+), 14 deletions(-) create mode 100644 SeaHavenIndustries.Tests/UpliftDataServiceTransactionTests.cs diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs index 7db6a01..1d72cf5 100644 --- a/SeaHaven.DataServices/Implementation/UpliftDataService.cs +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -535,22 +535,25 @@ namespace SeaHaven.DataServices.Implementation { var gate = WorkOrderGates.GetOrAdd(workOrderId, _ => new SemaphoreSlim(1, 1)); await gate.WaitAsync(cancellationToken); - await using var transaction = _context.Database.IsRelational() - ? await _context.Database.BeginTransactionAsync(cancellationToken) - : null; try { - await LockWorkOrderRowAsync(workOrderId, cancellationToken); - var result = await work(cancellationToken); - if (transaction is not null) - await transaction.CommitAsync(cancellationToken); - return result; - } - catch - { - if (transaction is not null) - await transaction.RollbackAsync(cancellationToken); - throw; + await using var transaction = _context.Database.IsRelational() + ? await _context.Database.BeginTransactionAsync(cancellationToken) + : null; + try + { + await LockWorkOrderRowAsync(workOrderId, cancellationToken); + var result = await work(cancellationToken); + if (transaction is not null) + await transaction.CommitAsync(cancellationToken); + return result; + } + catch + { + if (transaction is not null) + await transaction.RollbackAsync(cancellationToken); + throw; + } } finally { diff --git a/SeaHavenIndustries.Tests/UpliftDataServiceTransactionTests.cs b/SeaHavenIndustries.Tests/UpliftDataServiceTransactionTests.cs new file mode 100644 index 0000000..f585766 --- /dev/null +++ b/SeaHavenIndustries.Tests/UpliftDataServiceTransactionTests.cs @@ -0,0 +1,77 @@ +using System.Data.Common; +using Data.SeaHavenIndustries; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using SeaHaven.DataServices.Implementation; + +namespace SeaHavenIndustries.Tests; + +public sealed class UpliftDataServiceTransactionTests +{ + [Fact] + public async Task ExecuteWorkOrderMutationAsync_ReleasesGate_WhenTransactionInitializationFails() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + + var failingOptions = new DbContextOptionsBuilder() + .UseSqlite(connection) + .AddInterceptors(new ThrowOnceTransactionInterceptor()) + .Options; + + await using (var failedContext = new ApplicationDbContext(failingOptions)) + { + var failedService = new UpliftDataService(failedContext); + + await Assert.ThrowsAsync(() => + failedService.ExecuteWorkOrderMutationAsync( + 387, + _ => Task.FromResult(true), + CancellationToken.None)); + } + + var retryOptions = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + await using var retryContext = new ApplicationDbContext(retryOptions); + var retryService = new UpliftDataService(retryContext); + + var retry = retryService.ExecuteWorkOrderMutationAsync( + 387, + _ => Task.FromResult("entered"), + CancellationToken.None); + + Assert.Equal("entered", await retry.WaitAsync(TimeSpan.FromSeconds(1))); + } + + private sealed class ThrowOnceTransactionInterceptor : DbTransactionInterceptor + { + private int _remaining = 1; + + public override InterceptionResult TransactionStarting( + DbConnection connection, + TransactionStartingEventData eventData, + InterceptionResult result) + { + ThrowOnce(); + return base.TransactionStarting(connection, eventData, result); + } + + public override ValueTask> TransactionStartingAsync( + DbConnection connection, + TransactionStartingEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + ThrowOnce(); + return base.TransactionStartingAsync(connection, eventData, result, cancellationToken); + } + + private void ThrowOnce() + { + if (Interlocked.Exchange(ref _remaining, 0) == 1) + throw new InvalidOperationException("Forced transaction initialization failure"); + } + } +}