fix(cv-import): bound source retention and guard worker recovery

This commit is contained in:
cesnimda
2026-10-26 14:41:53 +01:00
parent e8f76a9dea
commit 9e1726c61d
14 changed files with 115 additions and 9 deletions
@@ -27,14 +27,22 @@ public sealed partial class ProfileCvController
.ExecuteUpdateAsync(s => s.SetProperty(o => o.ProgressStage, stage), cancellationToken);
if (updated != 1) throw new OperationCanceledException("The document processing lease is no longer active.", cancellationToken);
}
await Stage("cv_extracting");
await Stage("cv_validating_file");
var artifact = await _db.CvUploadArtifacts.IgnoreQueryFilters().AsNoTracking().FirstOrDefaultAsync(a => a.Id == run.ArtifactId && a.OwnerUserId == run.OwnerUserId, cancellationToken);
if (artifact is null || !System.IO.File.Exists(artifact.StoragePath)) throw new InvalidOperationException("The stored source document is unavailable.");
await using var stream = System.IO.File.OpenRead(artifact.StoragePath);
if (!string.Equals(Convert.ToHexString(await SHA256.HashDataAsync(stream, cancellationToken)), artifact.Sha256, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("The source document changed. Upload it again.");
stream.Position = 0;
await Stage("cv_extracting");
var document = await _aiService.ExtractCvDocumentAsync(stream, artifact.OriginalFileName, artifact.MimeType, cancellationToken)
?? throw new InvalidOperationException("Document extraction is unavailable. The source was retained for retry.");
await Stage("cv_interpreting");
var profile = ParseImportDocument(document);
await Stage("cv_identifying_sections");
var blocks = document.Blocks;
await Stage("cv_analysing");
var candidate = CvDocumentInterpreter.Parse(blocks);
await Stage("cv_validating_details");
var profile = CvDocumentReview.Ground(candidate, document);
await Stage("cv_preparing_review");
await Stage("cv_review_ready");
cancellationToken.ThrowIfCancellationRequested();
var documentJson = JsonSerializer.Serialize(document);
@@ -66,9 +74,11 @@ public sealed partial class ProfileCvController
try { await ValidateImportAsync(file, extension, HttpContext.RequestAborted); }
catch (InvalidDataException) { return BadRequest("The document signature or archive is invalid or unsupported."); }
if (await _db.CvExtractionRuns.CountAsync(r => r.OwnerUserId == user.Id && r.Status == "pending_review", HttpContext.RequestAborted) >= ExtractionRunRetentionCount) return Conflict("Review or discard an existing import before uploading another document.");
if (await _db.CvUploadArtifacts.CountAsync(a => a.OwnerUserId == user.Id && a.StoragePath != "", HttpContext.RequestAborted) >= 100) return Conflict("Remove or finish existing imports before uploading more documents.");
var artifact = await SaveUploadArtifactAsync(user, file, HttpContext.RequestAborted);
artifact.OriginalFileName = Path.GetFileName(file.FileName.Replace('\\', '/'));
artifact.MimeType = extension == ".pdf" ? "application/pdf" : "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
var stored = false;
try
{
var active = await FindActiveRunAsync(user.Id, "document_import", null, artifact.Sha256, HttpContext.RequestAborted);
@@ -76,9 +86,10 @@ public sealed partial class ProfileCvController
var run = new CvExtractionRun { OwnerUserId = user.Id, Artifact = artifact, Trigger = "document_import", ParserVersion = DocumentParserVersion, NormalizerVersion = "source-grounded-v1", LlmPromptVersion = "none", Status = "queued" };
_db.CvExtractionRuns.Add(run);
await _db.SaveChangesAsync(HttpContext.RequestAborted);
stored = true;
return await EnqueueRunAsync(run, HttpContext.RequestAborted);
}
catch { if (artifact.Id == 0) TryDeleteCvArtifactFile(artifact.StoragePath); throw; }
catch { if (!stored) TryDeleteCvArtifactFile(artifact.StoragePath); throw; }
}
private static async Task ValidateImportAsync(IFormFile file, string extension, CancellationToken cancellationToken)
@@ -514,6 +514,11 @@ public sealed partial class ProfileCvController : ControllerBase
}
catch (OperationCanceledException)
{
if (run.ParserVersion == DocumentParserVersion && operationContext is not null)
{
_db.Entry(run).State = EntityState.Detached;
throw;
}
run.Status = "queued";
run.ErrorMessage = "CV processing was interrupted before completion.";
run.CompletedAtUtc = null;
@@ -522,6 +527,12 @@ public sealed partial class ProfileCvController : ControllerBase
}
catch (Exception ex)
{
if (run.ParserVersion == DocumentParserVersion && operationContext is not null &&
!await _db.UserOperations.AsNoTracking().AnyAsync(o => o.Id == operationContext.Lease.OperationId && o.LeaseToken == operationContext.Lease.LeaseToken && o.Status == OperationStatuses.Running && o.CancellationRequestedAtUtc == null && o.LeaseExpiresAtUtc > DateTime.UtcNow, CancellationToken.None))
{
_db.Entry(run).State = EntityState.Detached;
throw new OperationCanceledException("The import worker lease is no longer active.");
}
var generationFailure = ex as AiGenerationException;
var retryable = generationFailure?.Retryable == true;
var failureMessage = generationFailure?.Message