fix(cv-import): bound source retention and guard worker recovery
This commit is contained in:
@@ -25,11 +25,19 @@ public class CvImportRetentionTests
|
||||
await using var db = TestHostFactory.CreateInMemoryDb();
|
||||
var file = Path.Combine(paths.CvArtifactsRoot, "synthetic.pdf");
|
||||
await File.WriteAllTextAsync(file, "Synthetic source");
|
||||
var orphanDirectory = Directory.CreateDirectory(Path.Combine(paths.CvArtifactsRoot, "user-1")).FullName;
|
||||
var orphan = Path.Combine(orphanDirectory, "20260101010101-" + Guid.NewGuid().ToString("N") + ".pdf");
|
||||
await File.WriteAllTextAsync(orphan, "Interrupted upload");
|
||||
File.SetLastWriteTimeUtc(orphan, DateTime.UtcNow.AddDays(-8));
|
||||
var unrelated = Path.Combine(orphanDirectory, "unrelated.pdf");
|
||||
await File.WriteAllTextAsync(unrelated, "Preserve unrelated file");
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var run = new CvExtractionRun { OwnerUserId = "user-1", ParserVersion = "docling-v1", Trigger = "document_import", Status = status, StartedAtUtc = now.AddDays(-days), RawExtractedText = "Source", DocumentJson = "{}", StructuredProfileJson = "{}", Artifact = new() { OwnerUserId = "user-1", StoragePath = file } };
|
||||
db.CvExtractionRuns.Add(run);
|
||||
await db.SaveChangesAsync();
|
||||
await CvImportRetentionHostedService.SweepAsync(db, paths, now, default);
|
||||
Assert.False(File.Exists(orphan));
|
||||
Assert.True(File.Exists(unrelated));
|
||||
Assert.Equal(!expires, File.Exists(file));
|
||||
Assert.Equal(expires, run.DocumentJson is null);
|
||||
Assert.Equal(expires && status == "pending_review" ? "expired" : status, run.Status);
|
||||
|
||||
@@ -19,6 +19,23 @@ namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class CvProcessingOperationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Cancellation_of_an_expired_import_operation_cannot_resurrect_its_review()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
await fixture.SeedUserAsync();
|
||||
await using var scope = fixture.Provider.CreateAsyncScope();
|
||||
using var owner = scope.ServiceProvider.GetRequiredService<CurrentUserService>().UseBackgroundUser("user-1");
|
||||
var response = Response(await CreateController(scope.ServiceProvider).Upload(File()));
|
||||
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
|
||||
var run = await db.CvExtractionRuns.SingleAsync();
|
||||
run.ParserVersion = ProfileCvController.DocumentParserVersion;
|
||||
run.Status = "expired";
|
||||
await db.SaveChangesAsync();
|
||||
Assert.True(await scope.ServiceProvider.GetRequiredService<UserOperationStore>().RequestCancellationAsync(response.Operation!.Id, default));
|
||||
await db.Entry(run).ReloadAsync();
|
||||
Assert.Equal("expired", run.Status);
|
||||
}
|
||||
[Fact]
|
||||
public async Task Upload_is_durable_idempotent_and_stops_at_review_gate()
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -28,6 +28,14 @@ Scans and image-only PDFs fail cleanly with OCR disabled. Encrypted documents, a
|
||||
|
||||
The adapter is a qualification seam; the active import path remains deterministic and does not call it. Actual provider/model compatibility, factual relationship validation, EN/Bokmål language quality, adversarial semantics, GPU behavior and model accuracy remain **BLOCKED**. Enabling production inference requires a separately approved immutable artifact and isolated qualification; environment configuration is not acceptance.
|
||||
|
||||
The local transport disables environment proxies and rejects redirects, so an operator-configured local address cannot redirect document content to an external service.
|
||||
|
||||
## Isolated Linux development image
|
||||
|
||||
`Dockerfile.cv-import-dev` layers the current source and separate interpreter over an explicitly inspected existing runtime. It is not referenced by production Compose. Prepare a fresh context containing that Dockerfile, app.py/parser_child.py/cv_import_ai.py, the CV import lock and a `wheels/` directory downloaded with the lock's hashes for Linux/Python 3.12. Tag the inspected base ID locally, pass that tag as `CV_IMPORT_BASE_IMAGE`, and build with `--network none --pull=false`. Verify the tag resolves to the inspected ID before building. No registry publication occurs.
|
||||
|
||||
The Linux child caches trusted platform metadata before installing its subprocess/network deny hook, then parses untrusted bytes. HOME/USERPROFILE point only at the private work directory; they never expose the host's model cache. The actual child retains POSIX CPU/address-space limits. `.artifacts/cv-import/real-sidecar-api-03.log` records PDF 200, DOCX 200, scan 422 through FastAPI and the real isolated Docling subprocess. Earlier failed API/build attempts are retained. Base-image/dependency security still inherits the open advisory qualifications; these runtime checks do not close them.
|
||||
|
||||
## Current focused evidence
|
||||
|
||||
- `.artifacts/cv-import/sidecar-focused-02.log`: 23 passed, including real native PDF/DOCX conversion and synthetic model-adapter tests; no generative inference.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
- [x] CV-IMPORT-00: baseline, entry provider gate, audit and implementation plan.
|
||||
- [x] CV-IMPORT-01: secure upload and real Docling extraction (native PDF layout/OCR limitations explicit).
|
||||
- [x] CV-IMPORT-02: grounded structured interpretation and disabled local AI qualification seam.
|
||||
- [ ] CV-IMPORT-03: genuine progress, recovery, cancellation and retention.
|
||||
- [x] CV-IMPORT-03: genuine progress, lease/cancellation recovery and bounded retention; full final freeze remains separate.
|
||||
- [ ] CV-IMPORT-04: review/corrections/conflicts/source UI.
|
||||
- [ ] CV-IMPORT-05: revisioned transactional Career Profile persistence.
|
||||
- [ ] CV-IMPORT-06: accuracy/security/resilience qualification.
|
||||
@@ -14,3 +14,5 @@ Evidence: entry actual MariaDB 64/64 PASS, no skips; `.artifacts/cv-import/entry
|
||||
Known open acceptance gates inherited from CV-UX: live model BLOCKED; human accessibility/language/device/printing OPEN; hosted MariaDB/isolation BLOCKED/UNVERIFIED; Python advisory qualification CONDITIONAL; production and Phase 10 NO-GO.
|
||||
|
||||
Extraction subcheckpoint pushed `3ea8ff9`. Backend reviewed integration focused 51 PASS and actual provider 69 PASS; qualification remains conditional until the final frozen-source lanes. Root continued implementation after delegated workers exhausted their service usage; unfinished worker edits were inspected and tested, not assumed complete.
|
||||
|
||||
CV-IMPORT-03 evidence: `lifecycle-green-01` 52 focused backend PASS; `backend-full-01` 1,250 PASS, zero skips. Expired-operation cancellation regression failed before the shared status guards. Linux real sidecar API attempt 03 passed PDF/DOCX and refused the scan in network-disabled/read-only isolation. Earlier attempts remain failed: Linux platform metadata subprocess and missing private HOME were fixed at the child boundary. Source retention includes orphan generated upload files and preserves unrelated files. Original source inspection is unavailable after expiry; approved canonical history is retained.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Isolated development image only. Supply an explicitly inspected existing runtime image ID.
|
||||
ARG CV_IMPORT_BASE_IMAGE=jobtracker-cv-import-legacy:required
|
||||
FROM ${CV_IMPORT_BASE_IMAGE}
|
||||
USER root
|
||||
COPY requirements-cv-import.lock /tmp/cv-import.lock
|
||||
COPY wheels/ /tmp/cv-import-wheels/
|
||||
RUN python -m venv /opt/cv-import && /opt/cv-import/bin/python -m pip install --no-index --find-links=/tmp/cv-import-wheels --require-hashes -r /tmp/cv-import.lock
|
||||
COPY app.py parser_child.py cv_import_ai.py /app/
|
||||
ENV CV_IMPORT_PYTHON=/opt/cv-import/bin/python \
|
||||
CV_IMPORT_AI_ENABLED=false \
|
||||
SKIP_MODEL_LOAD=1 \
|
||||
AI_SERVICE_SKIP_MODEL_LOAD=1 \
|
||||
HF_HUB_OFFLINE=1 \
|
||||
TRANSFORMERS_OFFLINE=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1
|
||||
WORKDIR /app
|
||||
USER 10001
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8001"]
|
||||
@@ -1222,6 +1222,8 @@ def _parser_environment(temp_root: str) -> dict[str, str]:
|
||||
"TMPDIR": temp_root,
|
||||
"TEMP": temp_root,
|
||||
"TMP": temp_root,
|
||||
"HOME": temp_root,
|
||||
"USERPROFILE": temp_root,
|
||||
})
|
||||
return environment
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import json
|
||||
import os
|
||||
import re
|
||||
from urllib.parse import urlsplit
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.request import Request, build_opener, HTTPRedirectHandler, ProxyHandler
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -51,10 +51,15 @@ def _validate_config(config):
|
||||
raise ValueError("Only local provider addresses are supported.")
|
||||
|
||||
|
||||
class _NoRedirect(HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
raise ValueError("Local inference redirects are not allowed.")
|
||||
|
||||
|
||||
def _ollama(config, payload):
|
||||
request = Request(config.base_url.rstrip("/") + "/api/generate", data=json.dumps(payload).encode(), headers={"Content-Type": "application/json"})
|
||||
# The application adapter never installs/pulls models or changes a model cache.
|
||||
with urlopen(request, timeout=config.timeout_seconds) as response:
|
||||
with build_opener(ProxyHandler({}), _NoRedirect()).open(request, timeout=config.timeout_seconds) as response:
|
||||
body = response.read(256 * 1024 + 1)
|
||||
if len(body) > 256 * 1024:
|
||||
raise ValueError("Model output exceeded its bound.")
|
||||
|
||||
@@ -377,6 +377,9 @@ def main() -> int:
|
||||
data = Path(args.input).read_bytes()
|
||||
if args.mode == "document":
|
||||
os.environ.update({"HF_HUB_OFFLINE": "1", "HF_HUB_DISABLE_TELEMETRY": "1", "TRANSFORMERS_OFFLINE": "1"})
|
||||
# Cache trusted platform metadata before the document sandbox denies Linux uname.
|
||||
import platform
|
||||
platform.platform()
|
||||
sys.addaudithook(_deny_import_side_effects)
|
||||
result = extract_document(args.extension.lower(), data)
|
||||
else:
|
||||
|
||||
@@ -924,3 +924,10 @@ def test_endpoints_stay_open_when_no_token_is_configured(monkeypatch):
|
||||
response = client.post("/summarize", json={"text": "Platform engineering role."})
|
||||
|
||||
assert response.status_code == 503
|
||||
|
||||
|
||||
def test_parser_home_is_private_work_directory_not_host_model_cache(monkeypatch):
|
||||
module = load_app_module(monkeypatch)
|
||||
environment = module._parser_environment("private-parser-work")
|
||||
assert environment.get("HOME") == "private-parser-work"
|
||||
assert environment.get("USERPROFILE") == "private-parser-work"
|
||||
|
||||
Reference in New Issue
Block a user