feat(cv)!: queue durable processing
CI and Deploy / test (pull_request) Failing after 1m39s
CI and Deploy / deploy (pull_request) Has been skipped

CV upload now returns 202 with an owner-scoped operation instead of holding the request through parsing. Existing review approval remains required.

BREAKING CHANGE: profile-cv upload responses use the durable operation contract.
This commit is contained in:
cesnimda
2026-08-09 15:11:03 +02:00
parent 39e98049ea
commit c3c5af8329
9 changed files with 614 additions and 141 deletions
@@ -152,6 +152,12 @@ public sealed partial class ProfileCvController : ControllerBase
}
var artifact = await SaveUploadArtifactAsync(user, file, HttpContext.RequestAborted);
var activeRun = await FindActiveRunAsync(user.Id, "upload", null, artifact.Sha256, HttpContext.RequestAborted);
if (activeRun is not null)
{
TryDeleteCvArtifactFile(artifact.StoragePath);
return await EnqueueRunAsync(activeRun, HttpContext.RequestAborted);
}
_db.CvUploadArtifacts.Add(artifact);
await _db.SaveChangesAsync(HttpContext.RequestAborted);
@@ -163,42 +169,12 @@ public sealed partial class ProfileCvController : ControllerBase
ParserVersion = ParserVersion,
NormalizerVersion = NormalizerVersion,
LlmPromptVersion = LlmPromptVersion,
Status = "running",
Status = "queued",
StartedAtUtc = DateTimeOffset.UtcNow,
};
_db.CvExtractionRuns.Add(run);
await _db.SaveChangesAsync(HttpContext.RequestAborted);
try
{
var result = await ExtractStructuredCvFromFileAsync(file, extension, HttpContext.RequestAborted);
run.RawExtractedText = result.RawText;
run.NormalizedText = result.NormalizedText;
run.StructuredProfileJson = StructuredCvProfileJson.Serialize(result.StructuredCv);
run.Status = "pending_review";
run.CompletedAtUtc = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(HttpContext.RequestAborted);
await PruneExtractionRunsAsync(user.Id, HttpContext.RequestAborted);
return Ok(new
{
imported = false,
pendingReview = true,
characters = result.NormalizedText.Length,
artifactId = artifact.Id,
extractionRunId = run.Id,
status = run.Status,
});
}
catch (Exception ex)
{
run.Status = "failed";
run.ErrorMessage = ex.Message;
run.CompletedAtUtc = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(HttpContext.RequestAborted);
await PruneExtractionRunsAsync(user.Id, HttpContext.RequestAborted);
throw;
}
return await EnqueueRunAsync(run, HttpContext.RequestAborted);
}
[HttpGet("runs")]
@@ -221,11 +197,23 @@ public sealed partial class ProfileCvController : ControllerBase
x.ParserVersion,
x.NormalizerVersion,
x.LlmPromptVersion,
x.ErrorMessage));
x.ErrorMessage,
null));
var runs = _db.Database.IsSqlite()
? (await runsQuery.ToListAsync(HttpContext.RequestAborted)).OrderByDescending(x => x.StartedAtUtc).Take(10).ToList()
: await runsQuery.OrderByDescending(x => x.StartedAtUtc).Take(10).ToListAsync(HttpContext.RequestAborted);
var runIds = runs.Select(run => run.Id.ToString(System.Globalization.CultureInfo.InvariantCulture)).ToList();
var operations = await _db.UserOperations.AsNoTracking()
.Where(operation => operation.TaskType == CvProcessingQueue.TaskType && operation.SubjectId != null && runIds.Contains(operation.SubjectId))
.ToListAsync(HttpContext.RequestAborted);
var latestOperations = operations
.GroupBy(operation => operation.SubjectId!, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.MaxBy(operation => operation.CreatedAtUtc)!);
runs = runs.Select(run => latestOperations.TryGetValue(run.Id.ToString(System.Globalization.CultureInfo.InvariantCulture), out var operation)
? run with { Operation = OperationDto.From(operation) }
: run).ToList();
return Ok(runs);
}
@@ -313,8 +301,7 @@ public sealed partial class ProfileCvController : ControllerBase
}
var run = await CreateQueuedRunAsync(user.Id, artifact.Id, "reprocess", HttpContext.RequestAborted);
await _cvProcessingQueue.EnqueueAsync(run.Id, HttpContext.RequestAborted);
return Accepted(new { queued = true, extractionRunId = run.Id, status = run.Status });
return await EnqueueRunAsync(run, HttpContext.RequestAborted);
}
[HttpPost("rebuild")]
@@ -326,8 +313,7 @@ public sealed partial class ProfileCvController : ControllerBase
if (string.IsNullOrWhiteSpace(user.ProfileCvText)) return BadRequest("Add or import CV text before rebuilding it.");
var run = await CreateQueuedRunAsync(user.Id, user.CurrentCvUploadArtifactId, "rebuild", HttpContext.RequestAborted);
await _cvProcessingQueue.EnqueueAsync(run.Id, HttpContext.RequestAborted);
return Accepted(new { queued = true, extractionRunId = run.Id, status = run.Status });
return await EnqueueRunAsync(run, HttpContext.RequestAborted);
}
[HttpPost("rewrite-section")]
@@ -527,8 +513,7 @@ public sealed partial class ProfileCvController : ControllerBase
if (string.IsNullOrWhiteSpace(user.ProfileCvText)) return BadRequest("Add or import CV text before improving it.");
var run = await CreateQueuedRunAsync(user.Id, user.CurrentCvUploadArtifactId, "improve", HttpContext.RequestAborted);
await _cvProcessingQueue.EnqueueAsync(run.Id, HttpContext.RequestAborted);
return Accepted(new { queued = true, extractionRunId = run.Id, status = run.Status });
return await EnqueueRunAsync(run, HttpContext.RequestAborted);
}
private static string BuildRewriteSourceText(string? sectionName, string? sourceText, StructuredCvProfile structuredCv)