mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-07 08:09:05 +00:00
fix: run work order edit reschedule under the mutation lock
UpdateWorkOrderAsync loaded the work order, applied the shared schedule side effects (RescheduleCount, OriginalDate/OriginalWeek, lifecycle, IsAddOn) and saved with no gate, transaction or row lock, so a concurrent reschedule could interleave with the read-modify-write. The read, the mutation, the save and the audit entry now run through IWorkOrderDataService.ExecuteWorkOrderMutationAsync, which uses the shared WorkOrderMutationLock: the per-work-order in-process gate, a transaction, and an UPDLOCK on the work order row on SQL Server. A reschedule committed by another holder of the lock is read before the edit applies its own date and increments RescheduleCount. Adds a regression test that holds the gate, commits a reschedule, and asserts the edit waits and then builds on the committed schedule.
This commit is contained in:
parent
24436c9864
commit
c02749a4ea
4 changed files with 114 additions and 31 deletions
|
|
@ -206,6 +206,12 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
await _context.SaveChangesAsync();
|
await _context.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<T> ExecuteWorkOrderMutationAsync<T>(
|
||||||
|
int workOrderId,
|
||||||
|
Func<CancellationToken, Task<T>> work,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
=> WorkOrderMutationLock.RunAsync(_context, workOrderId, work, cancellationToken);
|
||||||
|
|
||||||
public async Task DeleteAsync(int id)
|
public async Task DeleteAsync(int id)
|
||||||
{
|
{
|
||||||
var entity = await GetByIdAsync(id);
|
var entity = await GetByIdAsync(id);
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,12 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
Task<WorkOrder> AddAsync(WorkOrder workOrder);
|
Task<WorkOrder> AddAsync(WorkOrder workOrder);
|
||||||
Task UpdateAsync(WorkOrder workOrder);
|
Task UpdateAsync(WorkOrder workOrder);
|
||||||
Task DeleteAsync(int id);
|
Task DeleteAsync(int id);
|
||||||
|
// Runs a read-modify-write on one work order under the shared per-work-order gate,
|
||||||
|
// transaction and row lock (WorkOrderMutationLock).
|
||||||
|
Task<T> ExecuteWorkOrderMutationAsync<T>(
|
||||||
|
int workOrderId,
|
||||||
|
Func<CancellationToken, Task<T>> work,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
// Related entities - Read
|
// Related entities - Read
|
||||||
Task<IEnumerable<WorkOrderContacts>> GetWorkOrderContactsAsync(int workOrderId);
|
Task<IEnumerable<WorkOrderContacts>> GetWorkOrderContactsAsync(int workOrderId);
|
||||||
|
|
|
||||||
|
|
@ -99,39 +99,48 @@ namespace SeaHaven.Services.Implementation
|
||||||
if (!validationResult.IsValid)
|
if (!validationResult.IsValid)
|
||||||
throw new ValidationException(validationResult.Errors);
|
throw new ValidationException(validationResult.Errors);
|
||||||
|
|
||||||
var existingWorkOrder = await _workOrderDataService.GetByIdAsync(id);
|
// The read, the schedule side effects (RescheduleCount, OriginalDate/Week, IsAddOn)
|
||||||
if (existingWorkOrder == null)
|
// and the audit entry run under the work order's mutation gate and row lock, so a
|
||||||
return false;
|
// concurrent reschedule cannot interleave with this read-modify-write.
|
||||||
|
return await _workOrderDataService.ExecuteWorkOrderMutationAsync(
|
||||||
|
id,
|
||||||
|
async _ =>
|
||||||
|
{
|
||||||
|
var existingWorkOrder = await _workOrderDataService.GetByIdAsync(id);
|
||||||
|
if (existingWorkOrder == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
var changes = new List<string>();
|
var changes = new List<string>();
|
||||||
changes.AddRange(TrackChanges(existingWorkOrder, dto));
|
changes.AddRange(TrackChanges(existingWorkOrder, dto));
|
||||||
|
|
||||||
var scheduledDateChanged = dto.ScheduledDate.HasValue
|
var scheduledDateChanged = dto.ScheduledDate.HasValue
|
||||||
&& dto.ScheduledDate.Value.Date != existingWorkOrder.ScheduledDate?.Date;
|
&& dto.ScheduledDate.Value.Date != existingWorkOrder.ScheduledDate?.Date;
|
||||||
var previousIsAddOn = existingWorkOrder.IsAddOn;
|
var previousIsAddOn = existingWorkOrder.IsAddOn;
|
||||||
UpdateWorkOrderDTO.UpdateEntity(dto, existingWorkOrder);
|
UpdateWorkOrderDTO.UpdateEntity(dto, existingWorkOrder);
|
||||||
if (scheduledDateChanged)
|
if (scheduledDateChanged)
|
||||||
{
|
{
|
||||||
var scheduleChanges = WorkOrderBoardFieldMutations.ApplyScheduledDate(
|
var scheduleChanges = WorkOrderBoardFieldMutations.ApplyScheduledDate(
|
||||||
existingWorkOrder,
|
existingWorkOrder,
|
||||||
dto.ScheduledDate!.Value.Date,
|
dto.ScheduledDate!.Value.Date,
|
||||||
WorkOrderBoardFieldNames.ScheduledDate);
|
WorkOrderBoardFieldNames.ScheduledDate);
|
||||||
changes.AddRange(scheduleChanges
|
changes.AddRange(scheduleChanges
|
||||||
.Where(c => c.HasChanged && c.FieldName != WorkOrderBoardFieldNames.ScheduledDate)
|
.Where(c => c.HasChanged && c.FieldName != WorkOrderBoardFieldNames.ScheduledDate)
|
||||||
.Select(c => $"{c.FieldName}: {c.OldValue} → {c.NewValue}"));
|
.Select(c => $"{c.FieldName}: {c.OldValue} → {c.NewValue}"));
|
||||||
existingWorkOrder.IsAddOn = WorkOrderAddonCutoff.RecalculateIsAddOnFromSchedule(
|
existingWorkOrder.IsAddOn = WorkOrderAddonCutoff.RecalculateIsAddOnFromSchedule(
|
||||||
existingWorkOrder.CreatedDate ?? DateTime.UtcNow,
|
existingWorkOrder.CreatedDate ?? DateTime.UtcNow,
|
||||||
existingWorkOrder.ScheduledDate,
|
existingWorkOrder.ScheduledDate,
|
||||||
existingWorkOrder.TargetWeek);
|
existingWorkOrder.TargetWeek);
|
||||||
if (existingWorkOrder.IsAddOn != previousIsAddOn)
|
if (existingWorkOrder.IsAddOn != previousIsAddOn)
|
||||||
changes.Add($"IsAddOn: {previousIsAddOn} → {existingWorkOrder.IsAddOn}");
|
changes.Add($"IsAddOn: {previousIsAddOn} → {existingWorkOrder.IsAddOn}");
|
||||||
}
|
}
|
||||||
await _workOrderDataService.UpdateAsync(existingWorkOrder);
|
await _workOrderDataService.UpdateAsync(existingWorkOrder);
|
||||||
|
|
||||||
if (changes.Any())
|
if (changes.Any())
|
||||||
await LogAuditAsync(id, userId, AuditAction.Edit, string.Join(", ", changes));
|
await LogAuditAsync(id, userId, AuditAction.Edit, string.Join(", ", changes));
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
},
|
||||||
|
CancellationToken.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> DeleteWorkOrderAsync(int id, string userId)
|
public async Task<bool> DeleteWorkOrderAsync(int id, string userId)
|
||||||
|
|
|
||||||
|
|
@ -75,10 +75,72 @@ public sealed class WorkOrderEditAddonRescheduleTests
|
||||||
Assert.Equal(LifecycleStatus.Scheduled, persisted.LifecycleStatus);
|
Assert.Equal(LifecycleStatus.Scheduled, persisted.LifecycleStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ApplicationDbContext CreateContext()
|
[Fact]
|
||||||
|
public async Task EditWorkorder_Reschedule_WaitsForWorkOrderMutationGateAndReadsCommittedSchedule()
|
||||||
|
{
|
||||||
|
const int workOrderId = 41201;
|
||||||
|
var databaseName = Guid.NewGuid().ToString();
|
||||||
|
await using (var seed = CreateContext(databaseName))
|
||||||
|
{
|
||||||
|
seed.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
Id = workOrderId,
|
||||||
|
CreatedDate = new DateTime(2026, 6, 21, 0, 0, 0, DateTimeKind.Utc),
|
||||||
|
ScheduledDate = new DateTime(2026, 7, 6),
|
||||||
|
OriginalDate = new DateOnly(2026, 7, 6),
|
||||||
|
IsAddOn = false
|
||||||
|
});
|
||||||
|
await seed.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// A concurrent reschedule holds the work order's mutation gate and commits its own
|
||||||
|
// date while the edit is waiting.
|
||||||
|
await using var holderContext = CreateContext(databaseName);
|
||||||
|
var held = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var holder = new UpliftDataService(holderContext).ExecuteWorkOrderMutationAsync(
|
||||||
|
workOrderId,
|
||||||
|
async ct =>
|
||||||
|
{
|
||||||
|
held.SetResult();
|
||||||
|
await release.Task;
|
||||||
|
var workOrder = await holderContext.workOrders.SingleAsync(x => x.Id == workOrderId, ct);
|
||||||
|
workOrder.ScheduledDate = new DateTime(2026, 6, 29);
|
||||||
|
workOrder.RescheduleCount++;
|
||||||
|
await holderContext.SaveChangesAsync(ct);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
CancellationToken.None);
|
||||||
|
await held.Task;
|
||||||
|
|
||||||
|
await using var editContext = CreateContext(databaseName);
|
||||||
|
var edit = CreateController(editContext).Editworkorder(new EditWorkorder_DTO
|
||||||
|
{
|
||||||
|
Id = workOrderId,
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 25)
|
||||||
|
});
|
||||||
|
await Task.Delay(200);
|
||||||
|
|
||||||
|
Assert.False(edit.IsCompleted);
|
||||||
|
|
||||||
|
release.SetResult();
|
||||||
|
await holder;
|
||||||
|
Assert.IsType<OkObjectResult>(await edit);
|
||||||
|
|
||||||
|
await using var verify = CreateContext(databaseName);
|
||||||
|
var persisted = await verify.workOrders.SingleAsync(x => x.Id == workOrderId);
|
||||||
|
Assert.Equal(new DateTime(2026, 6, 25), persisted.ScheduledDate);
|
||||||
|
Assert.Equal(2, persisted.RescheduleCount);
|
||||||
|
Assert.True(persisted.IsAddOn);
|
||||||
|
var audit = await verify.WorkOrderAuditLogs.SingleAsync(x => x.WorkOrderId == workOrderId);
|
||||||
|
Assert.Contains("Schedule On: 2026-06-29 → 2026-06-25", audit.FieldName);
|
||||||
|
Assert.Contains("RescheduleCount: 1 → 2", audit.FieldName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ApplicationDbContext CreateContext(string? databaseName = null)
|
||||||
{
|
{
|
||||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
.UseInMemoryDatabase(databaseName ?? Guid.NewGuid().ToString())
|
||||||
.Options;
|
.Options;
|
||||||
return new ApplicationDbContext(options);
|
return new ApplicationDbContext(options);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue