generated from john/python-template
Fix workflow commit atomicity, error path leak, and UI error boundary
Quality Gate / gate (push) Failing after 48s
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:
co-authored by
Copilot App
parent
8d3c60fce1
commit
de18c2e9da
@@ -2,12 +2,15 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from uuid import uuid4
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ErrorCategory(StrEnum):
|
||||
"""Stable error categories defined by docs/error_handling.md."""
|
||||
@@ -40,6 +43,7 @@ class AppError(RuntimeError):
|
||||
suggestion: str = "Retry once. If it persists, review logs and report the error reference id.",
|
||||
retriable: bool = False,
|
||||
error_id: str | None = None,
|
||||
detail: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
@@ -47,6 +51,10 @@ class AppError(RuntimeError):
|
||||
self.suggestion = suggestion
|
||||
self.retriable = retriable
|
||||
self.error_id = error_id or new_error_id()
|
||||
# Internal-only diagnostic text. Persisted to evidence and logs, never rendered
|
||||
# to users or serialized into API envelopes, because it may embed local
|
||||
# filesystem paths and other infrastructure detail.
|
||||
self.detail = detail
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -89,15 +97,43 @@ def build_error_envelope(error: AppError) -> ErrorEnvelope:
|
||||
|
||||
|
||||
def classify_unexpected_error(exc: Exception, *, operation: str) -> AppError:
|
||||
"""Normalize unknown exceptions into internal_unexpected_error."""
|
||||
return AppError(
|
||||
f"Unexpected error during {operation}: {exc}",
|
||||
"""Normalize unknown exceptions into internal_unexpected_error.
|
||||
|
||||
The exception text is deliberately excluded from ``message``. ``AppError.message``
|
||||
is rendered directly to users by the UI error presenter and is serialized into API
|
||||
responses by :func:`build_error_envelope`, and unexpected exceptions routinely embed
|
||||
local filesystem paths (SQLAlchemy ``OperationalError`` carries the database path,
|
||||
``OSError`` carries the storage root). Leaking those is forbidden by
|
||||
``.github/instructions/error-handling.instructions.md``.
|
||||
|
||||
The detail is preserved on ``AppError.detail`` and logged against ``error_id``. That
|
||||
keeps the root cause in evidence records and operator logs, which are internal, while
|
||||
keeping it out of user-facing and API-facing text.
|
||||
"""
|
||||
error = AppError(
|
||||
f"Unexpected error during {operation}.",
|
||||
category=ErrorCategory.INTERNAL_UNEXPECTED,
|
||||
suggestion="Retry once. If it persists, review logs and report the error reference id.",
|
||||
retriable=False,
|
||||
detail=f"{type(exc).__name__}: {exc}",
|
||||
)
|
||||
logger.error(
|
||||
"Unexpected error operation=%s error_id=%s",
|
||||
operation,
|
||||
error.error_id,
|
||||
exc_info=exc,
|
||||
)
|
||||
return error
|
||||
|
||||
|
||||
def format_error_detail(error: AppError) -> str:
|
||||
"""Return a compact persisted failure string for transcript.error_detail."""
|
||||
return f"[{error.category.value}] {error.message} | suggestion={error.suggestion} | error_id={error.error_id}"
|
||||
"""Return a compact persisted failure string for transcript.error_detail.
|
||||
|
||||
This is internal provenance, not user-facing output, so it carries
|
||||
``AppError.detail`` (the root cause) in addition to the user-safe message.
|
||||
"""
|
||||
parts = [f"[{error.category.value}] {error.message}"]
|
||||
if error.detail:
|
||||
parts.append(f"detail={error.detail}")
|
||||
parts.extend((f"suggestion={error.suggestion}", f"error_id={error.error_id}"))
|
||||
return " | ".join(parts)
|
||||
|
||||
@@ -233,6 +233,7 @@ async def process_queued_job( # noqa: PLR0915
|
||||
|
||||
successful_pages: list[_SuccessfulPage] = []
|
||||
failed_pages: list[_FailedPage] = []
|
||||
pending_final_page: _SuccessfulPage | _FailedPage | None = None
|
||||
externally_stopped = False
|
||||
|
||||
prompt_execution = _resolve_job_prompt_execution(source_job=source_job, settings=runtime_settings)
|
||||
@@ -241,7 +242,8 @@ async def process_queued_job( # noqa: PLR0915
|
||||
# latency on the first attempt of every worker process (review log [55]).
|
||||
provider = services.sources.provider
|
||||
|
||||
for source in sources:
|
||||
for index, source in enumerate(sources):
|
||||
is_final_source = index == len(sources) - 1
|
||||
if await _job_no_longer_processing(job_id=job.id, services=services, session=session):
|
||||
externally_stopped = True
|
||||
break
|
||||
@@ -408,12 +410,18 @@ async def process_queued_job( # noqa: PLR0915
|
||||
error.category.value,
|
||||
)
|
||||
|
||||
await _persist_page_outcome_durably(
|
||||
job=job,
|
||||
services=services,
|
||||
page=page_outcome,
|
||||
session=session,
|
||||
)
|
||||
if is_final_source:
|
||||
# The last page's evidence and the job's terminal status must succeed or
|
||||
# roll back together, so this write is deferred into _finalize_batch_outcome.
|
||||
# Earlier pages stay individually durable.
|
||||
pending_final_page = page_outcome
|
||||
else:
|
||||
await _persist_page_outcome_durably(
|
||||
job=job,
|
||||
services=services,
|
||||
page=page_outcome,
|
||||
session=session,
|
||||
)
|
||||
|
||||
if await _job_no_longer_processing(job_id=job.id, services=services, session=session):
|
||||
externally_stopped = True
|
||||
@@ -427,11 +435,12 @@ async def process_queued_job( # noqa: PLR0915
|
||||
elif failed_pages and not successful_pages:
|
||||
terminal_status = JobStatus.FAILED
|
||||
|
||||
updated_job = await _finalize_batch_outcome(
|
||||
updated_job = await _finalize_batch_outcome_durably(
|
||||
job=job,
|
||||
services=services,
|
||||
status=terminal_status,
|
||||
session=session,
|
||||
final_page=pending_final_page,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@@ -546,20 +555,64 @@ async def _job_no_longer_processing(
|
||||
return latest_job.status != JobStatus.PROCESSING
|
||||
|
||||
|
||||
async def _finalize_batch_outcome_durably(
|
||||
*,
|
||||
job: Job,
|
||||
services: ServiceBundle,
|
||||
status: JobStatus,
|
||||
session: AsyncSession | None,
|
||||
final_page: _SuccessfulPage | _FailedPage | None,
|
||||
) -> Job:
|
||||
"""Shield the terminal commit so cancellation cannot discard the last provider call.
|
||||
|
||||
Mirrors ``_persist_page_outcome_durably``. Without this, deferring the final page
|
||||
into the terminal transaction would make that page less durable than the pages
|
||||
before it.
|
||||
"""
|
||||
task = asyncio.create_task(
|
||||
_finalize_batch_outcome(
|
||||
job=job,
|
||||
services=services,
|
||||
status=status,
|
||||
session=session,
|
||||
final_page=final_page,
|
||||
)
|
||||
)
|
||||
try:
|
||||
return await asyncio.shield(task)
|
||||
except asyncio.CancelledError:
|
||||
await task
|
||||
raise
|
||||
|
||||
|
||||
async def _finalize_batch_outcome(
|
||||
*,
|
||||
job: Job,
|
||||
services: ServiceBundle,
|
||||
status: JobStatus,
|
||||
session: AsyncSession | None = None,
|
||||
final_page: _SuccessfulPage | _FailedPage | None = None,
|
||||
) -> Job:
|
||||
"""Persist the terminal aggregate status after all page outcomes are durable."""
|
||||
"""Persist the final page outcome and the terminal aggregate status in one transaction.
|
||||
|
||||
``services.instructions.md`` ("Workflow Transaction Boundaries") requires transcript
|
||||
content and the paired terminal status to succeed or roll back together. Committing
|
||||
them separately can leave a transcript persisted against a job stuck in PROCESSING,
|
||||
which the worker never reclaims because it only claims QUEUED rows.
|
||||
|
||||
``final_page`` is ``None`` when the batch produced no page outcome to pair with the
|
||||
status change (no sources, or the batch stopped before the last page).
|
||||
"""
|
||||
if session is None:
|
||||
async with services.jobs._session_scope() as local_session:
|
||||
if final_page is not None:
|
||||
await _write_page_outcome(job=job, services=services, page=final_page, session=local_session)
|
||||
updated_job = await services.jobs.mark_job_status(job.id, status, session=local_session)
|
||||
await local_session.commit()
|
||||
return updated_job
|
||||
|
||||
if final_page is not None:
|
||||
await _write_page_outcome(job=job, services=services, page=final_page, session=session)
|
||||
updated_job = await services.jobs.mark_job_status(job.id, status, session=session)
|
||||
await session.commit()
|
||||
return updated_job
|
||||
|
||||
@@ -14,6 +14,7 @@ from transcription.services.photos import PhotoError
|
||||
from transcription.services.photos import PhotosService
|
||||
from transcription.ui.components.app_shell import render_navigation_header
|
||||
from transcription.ui.components.cards import archival_card
|
||||
from transcription.ui.components.error_presenter import show_error
|
||||
from transcription.ui.components.media_urls import resolve_media_url
|
||||
from transcription.ui.components.primitives import render_empty_state
|
||||
from transcription.ui.components.primitives import section_header_row
|
||||
@@ -209,7 +210,7 @@ def register_page() -> None: # noqa: PLR0915
|
||||
description=(description_input.value or "").strip() or None,
|
||||
)
|
||||
except PhotoError as exc:
|
||||
ui.notify(str(exc), type="negative")
|
||||
show_error(exc, title="Save failed", operation="homepage.photo.update_description")
|
||||
return
|
||||
ui.navigate.to("/homepage/edit")
|
||||
|
||||
@@ -217,7 +218,7 @@ def register_page() -> None: # noqa: PLR0915
|
||||
try:
|
||||
await photos_service.set_primary(photo_id=current_photo.id)
|
||||
except PhotoError as exc:
|
||||
ui.notify(str(exc), type="negative")
|
||||
show_error(exc, title="Update failed", operation="homepage.photo.set_primary")
|
||||
return
|
||||
ui.navigate.to("/homepage/edit")
|
||||
|
||||
@@ -225,7 +226,7 @@ def register_page() -> None: # noqa: PLR0915
|
||||
try:
|
||||
await photos_service.delete_photo(photo_id=current_photo.id)
|
||||
except PhotoError as exc:
|
||||
ui.notify(str(exc), type="negative")
|
||||
show_error(exc, title="Delete failed", operation="homepage.photo.delete")
|
||||
return
|
||||
ui.navigate.to("/homepage/edit")
|
||||
|
||||
@@ -252,7 +253,7 @@ def register_page() -> None: # noqa: PLR0915
|
||||
file_bytes=payload,
|
||||
)
|
||||
except PhotoError as exc:
|
||||
ui.notify(str(exc), type="negative")
|
||||
show_error(exc, title="Upload failed", operation="homepage.photo.create")
|
||||
return
|
||||
ui.notify(f"Uploaded {event.file.name}", type="positive")
|
||||
ui.navigate.to("/homepage/edit")
|
||||
|
||||
@@ -262,7 +262,7 @@ def register_page() -> None: # noqa: PLR0915
|
||||
file_bytes=payload,
|
||||
)
|
||||
except PhotoError as exc:
|
||||
ui.notify(str(exc), type="negative")
|
||||
show_error(exc, title="Upload failed", operation="people.photo.create")
|
||||
return
|
||||
ui.notify("Photo uploaded.", type="positive")
|
||||
ui.navigate.to(f"/people/{person.id}/photos")
|
||||
@@ -318,7 +318,11 @@ def register_page() -> None: # noqa: PLR0915
|
||||
description=(input_control.value or "").strip() or None,
|
||||
)
|
||||
except PhotoError as exc:
|
||||
ui.notify(str(exc), type="negative")
|
||||
show_error(
|
||||
exc,
|
||||
title="Save failed",
|
||||
operation="people.photo.update_description",
|
||||
)
|
||||
return
|
||||
ui.notify("Description saved.", type="positive")
|
||||
ui.navigate.to(f"/people/{person.id}/photos")
|
||||
@@ -327,7 +331,7 @@ def register_page() -> None: # noqa: PLR0915
|
||||
try:
|
||||
await photos_service.set_primary(photo_id=photo_id)
|
||||
except PhotoError as exc:
|
||||
ui.notify(str(exc), type="negative")
|
||||
show_error(exc, title="Update failed", operation="people.photo.set_primary")
|
||||
return
|
||||
ui.notify("Primary photo updated.", type="positive")
|
||||
ui.navigate.to(f"/people/{person.id}/photos")
|
||||
@@ -336,7 +340,7 @@ def register_page() -> None: # noqa: PLR0915
|
||||
try:
|
||||
await photos_service.delete_photo(photo_id=photo_id)
|
||||
except PhotoError as exc:
|
||||
ui.notify(str(exc), type="negative")
|
||||
show_error(exc, title="Delete failed", operation="people.photo.delete")
|
||||
return
|
||||
ui.notify("Photo deleted.", type="positive")
|
||||
ui.navigate.to(f"/people/{person.id}/photos")
|
||||
|
||||
Reference in New Issue
Block a user