Skip to content

fix(ocr): stop documents getting stuck in "Processing" on OCR failure - #216

Merged
alex-struk merged 3 commits into
developfrom
fix/ocr-failure-handling
Jun 29, 2026
Merged

fix(ocr): stop documents getting stuck in "Processing" on OCR failure#216
alex-struk merged 3 commits into
developfrom
fix/ocr-failure-handling

Conversation

@alex-struk

@alex-struk alex-struk commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Problem

Documents could be orphaned in ongoing_ocr ("Processing" in the UI) forever. The graph workflow set ongoing_ocr up 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 (deleteDocument refuses 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

  1. Reject password-protected PDFs at normalization (pdf-normalization.service.ts) 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.
  2. Failure-path status transition (graph-workflow.ts): on failure the document moves from an in-flight status to failed, 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 (4xx and 5xx alike) stay retryable — the submit activity throws a plain Error, so Temporal applies the activity's retry policy. Once retries are exhausted the workflow fails and guard #2 drives the document to failed, 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 generic conversion_failed.

Both failed and conversion_failed are terminal/purgeable, so the ephemeral cleanup janitor reclaims blobs + Temporal records as usual.

Re-run (reprocess) endpoint

POST /api/documents/:documentId/reprocess re-runs a failed or stuck ongoing_ocr document's workflow from its existing normalized PDF (group-scoped). Guards return 409 for 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

  • Unit/integration: submit-to-azure-ocr (retryable 4xx/5xx/429), full graph-workflow suite incl. the failure-path hook, pdf-normalization password probe + code, ocr.service/ocr.controller reprocess guards, and document.service/upload.controller code/reason surfacing — all green; both apps type-check clean. (Note: graph-workflow.test.ts needs the time-skipping test server from temporal.download, which is firewalled in some CI/sandboxes — pre-stage that binary if needed.)
  • Live (local stack): uploading a genuine open-password PDF returns HTTP 422 code: password_protected with the specific message, lands in conversion_failed, never starts OCR, and the ephemeral janitor purges its blobs from storage.

Docs

docs-md/DOCUMENT_IMAGE_NORMALIZATION.md updated; new docs-md/OCR_FAILURE_HANDLING.md (status lifecycle + the two guards + cleanup interaction). OpenAPI spec (docs/assets/openapi.json) regenerated for the reprocess endpoint and the password_protected upload response.

Follow-up (not in this PR)

Once merged & live, re-run the ~233 already-stuck ongoing_ocr documents 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

alex-struk and others added 3 commits June 29, 2026 11:22
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
alex-struk marked this pull request as ready for review June 29, 2026 20:33
@alex-struk
alex-struk merged commit 3341fac into develop Jun 29, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants