fix(ocr): stop documents getting stuck in "Processing" on OCR failure - #216
Merged
Conversation
Documents could be orphaned in `ongoing_ocr` ("Processing") forever: the
graph workflow set that status up front, but on failure it re-threw without
resetting it, and the Azure submit activity retried deterministic 4xx errors
(e.g. a password-protected PDF) before failing. Such documents could neither
finish nor be deleted (deleteDocument refuses in-flight docs).
Three guards so a failed run always reaches a terminal status:
- Reject password-protected PDFs at normalization via mupdf `needsPassword()`
-> `conversion_failed`, so they never enter OCR. Only open-password PDFs are
rejected; permission-only encrypted PDFs (readable by OCR) still pass, so the
pdf-lib pass keeps `ignoreEncryption: true`.
- Make deterministic Azure 4xx (except 429) non-retryable in the submit
activity so the workflow fails fast instead of burning retries; 429/5xx stay
retryable.
- On workflow failure, transition the document from an in-flight status to
`failed` (guarded against clobbering progressed/cancelled docs; never masks
the original error).
Also surface the specific failure reason to the upload caller: a
password-protected PDF now returns `code: "password_protected"` with an
actionable message instead of the generic `conversion_failed`.
Both `failed` and `conversion_failed` are terminal/purgeable, so the ephemeral
cleanup janitor reclaims blobs + Temporal records as usual.
Tests: submit-to-azure-ocr (retryable vs non-retryable), graph-workflow
failure-path hook, pdf-normalization password probe + code, document.service
and upload.controller code/reason surfacing. Docs in docs-md updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
POST /api/documents/:documentId/reprocess re-runs a document's workflow from the beginning using its existing normalized PDF. This is the supported way to retry a broken or stuck run, and (looped over IDs) to clear stuck backlogs without direct DB/Temporal edits. Eligible only for `failed` or stuck `ongoing_ocr` documents. Each guard failure returns 409 with a specific reason: wrong state, missing/purged source file, in-flight run, or no workflow configuration. 404 if missing, 403 if not a group member, 202 on success with the new workflow execution id. The route lives on OcrController (whose base path is `api`, so the URL is unchanged) rather than DocumentController: DocumentModule importing OcrModule would create a module-evaluation cycle (OcrService already depends on DocumentService). OcrController already has both services available. - OcrService.reprocessDocument: guards + delegate to requestOcr, which starts graph-<id> (Temporal default ALLOW_DUPLICATE reuse permits a new run since the prior one is closed) and sets the document back to ongoing_ocr. - TemporalClientService.isWorkflowRunning: live-run check; false when no execution exists (orphaned doc whose prior run already closed). Pairs with the OCR failure-handling fixes on this branch: a re-run of a permanently-bad document now fails fast and lands in `failed` instead of re-stranding in `ongoing_ocr`, so looping the endpoint over the backlog converges. Tests: OcrService guards + delegate, isWorkflowRunning (running/closed/ not-found), and OcrController route (404/403/202). Design spec under docs/superpowers/specs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Revert the non-retryable ApplicationFailure for deterministic 4xx Azure responses so Temporal applies the activity retry policy for all submit failures (4xx and 5xx alike); the graph-workflow failure-path hook still drives the document to terminal failed once retries are exhausted, so documents no longer strand in Processing. Update the submit-to-azure-ocr test and OCR_FAILURE_HANDLING.md, reformat graph-workflow.test.ts to satisfy biome, and regenerate the OpenAPI spec to pick up the reprocess endpoint and password_protected upload descriptions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
alex-struk
marked this pull request as ready for review
June 29, 2026 20:33
alex-struk
requested review from
NoorChasib,
antsand,
dbarkowsky and
kmandryk
as code owners
June 29, 2026 20:33
dbarkowsky
approved these changes
Jun 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Documents could be orphaned in
ongoing_ocr("Processing" in the UI) forever. The graph workflow setongoing_ocrup front, but on failure it re-threw without resetting the status, so a permanently-failing OCR run (e.g. a password-protected PDF that Azure rejects) could neither finish nor be deleted (deleteDocumentrefuses in-flight docs).On prod this stranded ~233 documents in "Processing". Investigation showed the dominant trigger is password-protected PDFs: Azure Document Intelligence rejects them with
400 InvalidRequest / UnsupportedContent("File may be password protected").Fix — guards so a failed run always reaches a terminal status
pdf-normalization.service.ts) via mupdfneedsPassword()→conversion_failed, so they never enter OCR. Only open-password PDFs are rejected; permission-only encrypted PDFs (readable by OCR) still pass, so the pdf-lib pass keepsignoreEncryption: true.graph-workflow.ts): on failure the document moves from an in-flight status tofailed, guarded so a progressed (extracted/awaiting_review) or cancelled doc is never clobbered, and a status-update error never masks the original error.Azure OCR submit errors (
4xxand5xxalike) stay retryable — the submit activity throws a plainError, so Temporal applies the activity's retry policy. Once retries are exhausted the workflow fails and guard #2 drives the document tofailed, so even a permanently-failing submit reaches a terminal status (after the retry budget) rather than stranding in "Processing".Plus: surface the specific reason to the upload caller — a password-protected PDF now returns
code: "password_protected"with an actionable message instead of the genericconversion_failed.Both
failedandconversion_failedare terminal/purgeable, so the ephemeral cleanup janitor reclaims blobs + Temporal records as usual.Re-run (reprocess) endpoint
POST /api/documents/:documentId/reprocessre-runs afailedor stuckongoing_ocrdocument's workflow from its existing normalized PDF (group-scoped). Guards return409for a non-runnable state, missing/purged source file, an in-flight run, or a missing workflow config. Looping this endpoint over IDs clears stuck backlogs.Verification
submit-to-azure-ocr(retryable 4xx/5xx/429), fullgraph-workflowsuite incl. the failure-path hook,pdf-normalizationpassword probe + code,ocr.service/ocr.controllerreprocess guards, anddocument.service/upload.controllercode/reason surfacing — all green; both apps type-check clean. (Note:graph-workflow.test.tsneeds the time-skipping test server fromtemporal.download, which is firewalled in some CI/sandboxes — pre-stage that binary if needed.)code: password_protectedwith the specific message, lands inconversion_failed, never starts OCR, and the ephemeral janitor purges its blobs from storage.Docs
docs-md/DOCUMENT_IMAGE_NORMALIZATION.mdupdated; newdocs-md/OCR_FAILURE_HANDLING.md(status lifecycle + the two guards + cleanup interaction). OpenAPI spec (docs/assets/openapi.json) regenerated for the reprocess endpoint and thepassword_protectedupload response.Follow-up (not in this PR)
Once merged & live, re-run the ~233 already-stuck
ongoing_ocrdocuments via the reprocess endpoint so they reach a terminal status and get cleaned up. Separately, the prod app-pg pgBackRest backup repo is full (failed WAL archiving / DB restarts) — tracked independently.🤖 Generated with Claude Code