fix(cv-qa): protect import lifecycle races and terminal receipts

This commit is contained in:
cesnimda
2026-10-27 00:27:57 +01:00
parent cf0e5c23de
commit 89974a257e
8 changed files with 250 additions and 11 deletions
@@ -239,6 +239,8 @@ public sealed partial class ProfileCvController
}, HttpContext.RequestAborted);
}
catch (DbUpdateConcurrencyException) { return Conflict(new { code = "revision_conflict", message = "The career profile changed. Refresh the comparison and review your decisions." }); }
// MariaDB can report a changed run as a snapshot conflict before EF sees zero affected rows.
catch (DbUpdateException ex) when (ex.InnerException is MySqlConnector.MySqlException { Number: 1020 }) { return Conflict(new { code = "revision_conflict", message = "The import changed. Refresh the comparison and review your decisions." }); }
catch (ArgumentException ex) { return BadRequest(ex.Message); }
catch (CareerProjectionException ex) { return BadRequest(ex.Message); }
}
@@ -311,6 +311,7 @@ public sealed partial class ProfileCvController : ControllerBase
}, HttpContext.RequestAborted);
}
catch (DbUpdateConcurrencyException) { return Conflict(new { code = "revision_conflict", message = "The career profile changed. Review the import again before accepting." }); }
catch (DbUpdateException ex) when (ex.InnerException is MySqlConnector.MySqlException { Number: 1020 }) { return Conflict(new { code = "revision_conflict", message = "The import changed. Review it again before accepting." }); }
catch (CareerProjectionException ex) { return BadRequest(ex.Message); }
}
@@ -325,7 +326,12 @@ public sealed partial class ProfileCvController : ControllerBase
if (run.Status != "pending_review") return Conflict("This extraction run is not awaiting review.");
run.Status = "discarded";
await _db.SaveChangesAsync(HttpContext.RequestAborted);
try { await _db.SaveChangesAsync(HttpContext.RequestAborted); }
catch (DbUpdateException ex) when (ex is DbUpdateConcurrencyException || ex.InnerException is MySqlConnector.MySqlException { Number: 1020 })
{
_db.Entry(run).State = EntityState.Detached;
return Conflict("This extraction run changed. Refresh before discarding it.");
}
return NoContent();
}
+2
View File
@@ -290,6 +290,8 @@ namespace JobTrackerApi.Data
modelBuilder.Entity<CvExtractionRun>()
.HasIndex(x => new { x.OwnerUserId, x.StartedAtUtc });
modelBuilder.Entity<CvExtractionRun>().Property(x => x.Status).IsConcurrencyToken();
modelBuilder.Entity<CvExtractionRun>()
.HasOne(x => x.Artifact)
.WithMany()
@@ -1230,6 +1230,7 @@ namespace JobTrackerApi.Migrations
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsConcurrencyToken()
.IsRequired()
.HasColumnType("TEXT");
@@ -53,15 +53,25 @@ public sealed class CvImportRetentionHostedService(IServiceScopeFactory scopes,
catch (IOException) { continue; }
catch (UnauthorizedAccessException) { continue; }
artifact.StoragePath = "";
run.DocumentJson = null;
run.RawExtractedText = null;
run.NormalizedText = null;
if (run.Status != "applied")
for (var attempt = 0; ; attempt++)
{
run.StructuredProfileJson = null;
if (run.Status is "pending_review" or "queued" or "running") run.Status = "expired";
run.CompletedAtUtc ??= now;
run.ErrorMessage = "The source document expired. Upload it again to start a new review.";
run.DocumentJson = null;
run.RawExtractedText = null;
run.NormalizedText = null;
if (run.Status != "applied")
{
run.StructuredProfileJson = null;
if (run.Status is "pending_review" or "queued" or "running") run.Status = "expired";
run.CompletedAtUtc ??= now;
run.ErrorMessage = "The source document expired. Upload it again to start a new review.";
}
try { await db.SaveChangesAsync(token); break; }
catch (DbUpdateConcurrencyException ex) when (attempt < 2 && ex.Entries.All(e => ReferenceEquals(e.Entity, run)))
{
// Retry source cleanup against the new status; never replay canonical profile writes.
await db.Entry(run).ReloadAsync(token);
if (db.Entry(run).State == EntityState.Detached) break;
}
}
removed++;
}
+4 -2
View File
@@ -355,6 +355,8 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
await using var transaction = await BeginTransactionAsync(cancellationToken);
var operation = await db.UserOperations.FirstOrDefaultAsync(item => item.Id == operationId, cancellationToken);
if (operation is null || operation.Status is not (OperationStatuses.Failed or OperationStatuses.Cancelled)) return false;
if (operation.TaskType == CvProcessingQueue.TaskType && operation.SubjectType == CvProcessingQueue.SubjectType
&& await SynchronizeCvRunAsync(operation, OperationStatuses.Queued, null, UtcNow, cancellationToken) != 1) return false;
operation.Status = OperationStatuses.Queued;
operation.AttemptCount = 0;
operation.AvailableAtUtc = UtcNow;
@@ -371,7 +373,6 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
var existingNotification = await db.UserNotifications.FirstOrDefaultAsync(item => item.OperationId == operationId, cancellationToken);
if (existingNotification is not null) db.UserNotifications.Remove(existingNotification);
await db.SaveChangesAsync(cancellationToken);
await SynchronizeCvRunAsync(operation, OperationStatuses.Queued, null, UtcNow, cancellationToken);
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
return true;
}
@@ -475,7 +476,8 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
: (DateTimeOffset?)null;
return db.CvExtractionRuns.IgnoreQueryFilters()
.Where(run => run.Id == runId && run.OwnerUserId == operation.OwnerUserId && run.Status != "expired" && run.Status != "applied")
.Where(run => run.Id == runId && run.OwnerUserId == operation.OwnerUserId
&& (run.Status == "queued" || run.Status == "running" || run.Status == "pending_review" || run.Status == "failed" || run.Status == "cancelled"))
.ExecuteUpdateAsync(setters => setters
.SetProperty(run => run.Status, runStatus)
.SetProperty(run => run.ErrorMessage, message)