shoc-backend/SeaHaven.DataServices/Helpers/WorkOrderMutationLock.cs
Alexandre Brandizzi 77dc3d85c3 Serialize a CRM cancel with uplift creation on the work order lock
The per-work-order gate moves into a shared data-layer helper. A CRM
mutation that cancels an existing work order now runs under it, so a
create in flight either commits first and is cancelled, or sees the
cancelled work order.
2026-09-25 19:15:38 -03:00

79 lines
3.1 KiB
C#

using System.Collections.Concurrent;
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
namespace SeaHaven.DataServices.Helpers
{
/// <summary>
/// The per-work-order gate that serializes work-order-scoped invariants (uplift create,
/// decide, revoke, and cancelling a work order's pending uplifts): an in-process gate per
/// work order, a transaction, and an update lock on the work order row on SQL Server.
/// Every caller shares the same gates, so a cancel and a create on one work order never
/// interleave.
/// </summary>
public static class WorkOrderMutationLock
{
private static readonly ConcurrentDictionary<int, SemaphoreSlim> Gates = new();
/// <summary>
/// Runs <paramref name="work"/> under the work order's gate and row lock. The
/// transaction commits when the work returns and <paramref name="commitWhen"/> (when
/// given) accepts its result; otherwise, or when the work throws, it rolls back.
/// </summary>
public static async Task<T> RunAsync<T>(
ApplicationDbContext context,
int workOrderId,
Func<CancellationToken, Task<T>> work,
CancellationToken cancellationToken,
Func<T, bool>? commitWhen = null)
{
var gate = Gates.GetOrAdd(workOrderId, _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(cancellationToken);
try
{
await using var transaction = context.Database.IsRelational()
? await context.Database.BeginTransactionAsync(cancellationToken)
: null;
try
{
await LockRowAsync(context, workOrderId, cancellationToken);
var result = await work(cancellationToken);
if (transaction is not null)
{
if (commitWhen is null || commitWhen(result))
await transaction.CommitAsync(cancellationToken);
else
await transaction.RollbackAsync(cancellationToken);
}
return result;
}
catch
{
if (transaction is not null)
await transaction.RollbackAsync(cancellationToken);
throw;
}
}
finally
{
gate.Release();
}
}
private static async Task LockRowAsync(
ApplicationDbContext context,
int workOrderId,
CancellationToken cancellationToken)
{
if (context.Database.ProviderName?.Contains("SqlServer", StringComparison.OrdinalIgnoreCase) != true)
return;
await context.workOrders
.FromSqlRaw(
"SELECT * FROM [workOrders] WITH (UPDLOCK, ROWLOCK, HOLDLOCK) WHERE [Id] = {0}",
workOrderId)
.Select(workOrder => workOrder.Id)
.FirstOrDefaultAsync(cancellationToken);
}
}
}