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
@@ -66,6 +66,20 @@ public sealed class CvImportRetentionHostedService(IServiceScopeFactory scopes,
removed++;
}
await db.SaveChangesAsync(token);
var registered = (await db.CvUploadArtifacts.IgnoreQueryFilters().AsNoTracking().Select(a => a.StoragePath).ToListAsync(token))
.Where(p => !string.IsNullOrWhiteSpace(p)).Select(Path.GetFullPath).ToHashSet(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal);
foreach (var directory in Directory.EnumerateDirectories(paths.CvArtifactsRoot).Take(1000))
{
if ((File.GetAttributes(directory) & FileAttributes.ReparsePoint) != 0) continue;
foreach (var file in Directory.EnumerateFiles(directory).Take(1000))
{
if (registered.Contains(Path.GetFullPath(file)) || !System.Text.RegularExpressions.Regex.IsMatch(Path.GetFileName(file), @"^\d{14}-[a-f0-9]{32}\.(pdf|docx)$", System.Text.RegularExpressions.RegexOptions.IgnoreCase)
|| File.GetLastWriteTimeUtc(file) > now.AddDays(-7).UtcDateTime || (File.GetAttributes(file) & FileAttributes.ReparsePoint) != 0) continue;
try { File.Delete(file); removed++; }
catch (IOException) { }
catch (UnauthorizedAccessException) { }
}
}
return removed;
}
}
+1 -1
View File
@@ -119,7 +119,7 @@ public sealed class CvProcessingOperationHandler : IAiOperationHandler
{
var completedAtUtc = status == "failed" || status == "cancelled" ? DateTimeOffset.UtcNow : (DateTimeOffset?)null;
return services.GetRequiredService<JobTrackerContext>().CvExtractionRuns
.Where(run => run.Id == runId)
.Where(run => run.Id == runId && run.Status != "expired" && run.Status != "applied")
.ExecuteUpdateAsync(setters => setters
.SetProperty(run => run.Status, status)
.SetProperty(run => run.ErrorMessage, message)
+1 -1
View File
@@ -475,7 +475,7 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
: (DateTimeOffset?)null;
return db.CvExtractionRuns.IgnoreQueryFilters()
.Where(run => run.Id == runId && run.OwnerUserId == operation.OwnerUserId)
.Where(run => run.Id == runId && run.OwnerUserId == operation.OwnerUserId && run.Status != "expired" && run.Status != "applied")
.ExecuteUpdateAsync(setters => setters
.SetProperty(run => run.Status, runStatus)
.SetProperty(run => run.ErrorMessage, message)