Fix workflow commit atomicity, error path leak, and UI error boundary
Quality Gate / gate (push) Failing after 48s

Phase 1 of docs/reviews/2026-08-23-code-review.md.

HIGH-01: process_queued_job committed page evidence and the terminal job
status in separate transactions, so a crash between them left a transcript
persisted against a job stuck in PROCESSING that the worker never reclaims.
The final page's write is now deferred into _finalize_batch_outcome so it
shares the terminal transaction. Intermediate pages remain individually
durable, and the terminal commit is shielded against cancellation the same
way per-page writes already were.

HIGH-04: added tests/integration/test_pipeline_atomicity.py covering both
Transaction B and Transaction C. Confirmed failing against the previous
implementation before the fix.

HIGH-03: classify_unexpected_error interpolated the raw exception into
AppError.message, which the UI renders and the API serializes, leaking the
database path from OperationalError. message is now generic. Because message
also feeds format_error_detail, which writes evidence records, the root cause
is preserved on a new internal-only AppError.detail field rather than
discarded.

HIGH-02: replaced 8 hand-rolled ui.notify error calls in home_page and
people_page with error_presenter.show_error, restoring the correlation
error_id, canonical category, and suggestion. Added an AST guard to
test_ui_boundaries.py so pages cannot hand-roll error notifications again.

Docs updated per documentation-sync: the message/detail split in
docs/error_handling.md and the multi-page atomicity rule in
services.instructions.md.

Verification: ruff clean, 381 tests passing, ty unchanged at 10 known
SQLAlchemy descriptor false positives.

Co-authored-by: Copilot App <[email protected]>
This commit is contained in:
Jim Lancaster
2026-08-23 18:02:04 -05:00
co-authored by Copilot App
parent 8d3c60fce1
commit de18c2e9da
9 changed files with 356 additions and 23 deletions
@@ -0,0 +1,144 @@
"""Atomicity guards for the workflow transaction boundaries.
`.github/instructions/services.instructions.md` ("Workflow Transaction Boundaries")
requires that transcript content and the paired terminal/retry job status change
succeed or roll back together. The existing pipeline tests assert the happy-path
end state, which passes identically whether those writes shared one commit or used
two, so a split-commit regression was invisible to the suite.
These tests inject a fault between the paired writes. They fail if the pair is
split across separate transactions.
"""
import contextlib
from pathlib import Path
from uuid import uuid4
import pytest
from transcription.db.models import Document
from transcription.db.models import Job
from transcription.db.models import JobSource
from transcription.db.models import JobStatus
from transcription.db.models import Source
from transcription.providers.base import TranscriptionResult
from transcription.services import ServiceBundle
from transcription.services.workflows import advance_job
from transcription.services.workflows import process_next_queued_job
FIXTURE_IMAGE = Path("tests/fixtures/images/real/Book Two - page 02.jpg")
async def _seed_single_page_job(services: ServiceBundle) -> tuple[Job, Document]:
"""Create a QUEUED job with exactly one linked source."""
async with services.jobs._session_scope() as session:
document = Document(id=uuid4(), name="atomicity-doc")
session.add(document)
await session.flush()
job = Job(document_id=document.id, status=JobStatus.QUEUED)
session.add(job)
await session.flush()
source = Source(
document_id=document.id,
page_number=1,
upload_name="page-1.jpg",
filename="page-1.jpg",
file_path=str(FIXTURE_IMAGE),
file_hash="a" * 64,
file_size_bytes=1,
)
session.add(source)
await session.flush()
session.add(JobSource(job_id=job.id, source_id=source.id))
await session.commit()
return job, document
@pytest.mark.integration
class TestWorkflowTransactionAtomicity:
"""Verify paired transcript and job-status writes share one transaction."""
@pytest.mark.asyncio
async def test_transcript_is_not_committed_when_terminal_status_write_fails(
self,
default_session_factory,
monkeypatch,
):
"""Transaction B: transcript and TRANSCRIBED must roll back together.
A single-page job whose terminal status write fails must not leave the
transcript persisted. If the page outcome commits in its own transaction,
the attempt survives while the job never reaches TRANSCRIBED, which is the
stranded-job state the contract exists to prevent.
"""
services = ServiceBundle.from_session_factory(default_session_factory)
job, _document = await _seed_single_page_job(services)
job_id = job.id
async def _succeeds(*args, **kwargs):
_ = (args, kwargs)
return TranscriptionResult(text="atomic page text", provider="fixture", model="model")
monkeypatch.setattr("transcription.services.workflows.transcribe_document_image", _succeeds)
original_mark = services.jobs.mark_job_status
terminal_statuses = {JobStatus.TRANSCRIBED, JobStatus.PARTIAL_SUCCESS, JobStatus.FAILED}
async def _fail_terminal_write(job_id_arg, status, session=None):
if status in terminal_statuses:
raise RuntimeError("injected fault between transcript and terminal status writes")
return await original_mark(job_id_arg, status, session=session)
monkeypatch.setattr(services.jobs, "mark_job_status", _fail_terminal_write)
assert await process_next_queued_job(services=services) is True
attempts = await services.evidence.list_execution_attempts(job_id=job_id)
transcribed = [attempt for attempt in attempts if attempt.raw_transcription]
assert transcribed == [], (
"Transcript was committed even though the paired terminal status write failed. "
"The page outcome and the terminal status must share one transaction."
)
@pytest.mark.asyncio
async def test_retry_status_and_count_are_not_persisted_when_finalization_fails(
self,
default_session_factory,
default_settings,
monkeypatch,
):
"""Transaction C: QUEUED transition and retry increment must roll back together.
A fault while finalizing the retry write must leave the job exactly as it
was. A split write would requeue the job without incrementing retry_count,
letting it retry without bound.
"""
services = ServiceBundle.from_session_factory(default_session_factory)
job, _document = await _seed_single_page_job(services)
job_id = job.id
async with services.jobs._session_scope() as session:
failed_job = await session.get(Job, job_id)
assert failed_job is not None
failed_job.status = JobStatus.FAILED
await session.commit()
retry_settings = default_settings.model_copy(update={"worker_max_retries": 1})
async def _boom(**kwargs):
_ = kwargs
raise RuntimeError("injected fault during retry finalization")
monkeypatch.setattr(services.jobs, "_finalize", _boom)
reloaded = await services.jobs.read_job(job_id=job_id)
with contextlib.suppress(RuntimeError):
await advance_job(job=reloaded, services=services, settings=retry_settings)
async with services.jobs._session_scope() as session:
final = await session.get(Job, job_id)
assert final is not None
assert final.status == JobStatus.FAILED, "Job was requeued despite the retry write failing."
assert final.retry_count == 0, "retry_count was persisted despite the retry write failing."
+29 -1
View File
@@ -7,6 +7,7 @@ from transcription.errors import ErrorCategory
from transcription.errors import build_error_envelope
from transcription.errors import canonical_error_category
from transcription.errors import classify_unexpected_error
from transcription.errors import format_error_detail
from transcription.errors import new_error_id
@@ -45,10 +46,37 @@ class TestAppErrorHelpers:
assert isinstance(err, AppError)
assert err.category == ErrorCategory.INTERNAL_UNEXPECTED
assert "unit.test" in err.message
assert "boom" in err.message
# The raw exception text must stay out of the user-facing message: it is rendered
# by the UI presenter and serialized into API envelopes, and unexpected exceptions
# routinely embed local filesystem paths.
assert "boom" not in err.message
assert err.suggestion
assert err.error_id
def test_unexpected_error_does_not_leak_filesystem_paths(self):
"""User-facing and API-facing text must not carry local filesystem paths.
`.github/instructions/error-handling.instructions.md` forbids leaking local
filesystem paths in user-facing output. A SQLAlchemy OperationalError embeds the
database path and an OSError embeds the storage root, so the generic catch-all
path is where that leak would occur. The cause is retained on `detail`, which is
internal-only, so evidence records and logs keep full diagnostic value.
"""
secret_path = r"C:\Github\transcription\data\transcription.db"
exc = OSError(f"unable to open database file: {secret_path}")
err = classify_unexpected_error(exc, operation="worker.process_job")
envelope = build_error_envelope(err)
assert secret_path not in err.message
assert secret_path not in envelope.message
assert secret_path not in err.suggestion
# Internal surfaces keep the root cause.
assert err.detail is not None
assert secret_path in err.detail
assert secret_path in format_error_detail(err)
def test_envelope_categories_use_canonical_contract_values(self):
"""API/UI envelope categories are normalized to canonical short identifiers."""
expected_mapping = {
+39
View File
@@ -113,3 +113,42 @@ def test_only_the_designated_owners_construct_a_raw_table():
if _calls_ui_table(ast.parse(path.read_text(encoding="utf-8")))
)
assert set(offenders) == TABLE_OWNERS
def _notifies_negative(tree: ast.Module) -> bool:
"""Return True if the module calls ``ui.notify(..., type="negative")``."""
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
func = node.func
if not (
isinstance(func, ast.Attribute)
and func.attr == "notify"
and isinstance(func.value, ast.Name)
and func.value.id == "ui"
):
continue
for keyword in node.keywords:
if (
keyword.arg == "type"
and isinstance(keyword.value, ast.Constant)
and keyword.value.value == "negative"
):
return True
return False
def test_no_page_hand_rolls_error_notifications():
"""HIGH-02: `ui.instructions.md:42` routes all error display through error_presenter.
Hand-rolled ``ui.notify(str(exc), type="negative")`` discards the correlation
``error_id``, the canonical category, and the actionable suggestion that
``show_error`` renders, leaving the user with nothing to report. Eight such sites
existed in ``home_page`` and ``people_page``; this keeps them from returning.
"""
offenders = sorted(
path.stem for path in _page_paths() if _notifies_negative(ast.parse(path.read_text(encoding="utf-8")))
)
assert offenders == [], (
f"Pages must render errors via error_presenter.show_error, not ui.notify: {offenders}"
)