V4.6 Phase 6: async I/O and configuration hygiene

MED-01 - move remaining blocking work off the event loop:
- normalization.py gains normalize_orientation_async; the Pillow decode,
  transpose, and re-encode now run via asyncio.to_thread. The sync entry point
  stays for tests and documents that it blocks.
- OrientationNormalization.digest_sha256 becomes a stored field computed inside
  normalize_orientation, which already runs off-loop, instead of a property that
  hashed page-sized derivative bytes on the caller's thread.
- SourceService._write_and_digest_artifact performs the artifact write and its
  sha256 in a single worker-thread hop; both external-artifact write sites are
  now dispatched through to_thread.
- transcribe_image dispatches load_source_payload and build_prompt_execution
  through to_thread.

MED-04 - replace functools.cache on the engine and session factories with
explicit URL-keyed registries. dispose_engine and dispose_session_factory now
evict only the requested URL; previously cache_clear() tore down every other
database in the process, and dispose_engine would construct an engine for an
unknown URL purely to throw it away. New tests/test_engine_registry.py covers
distinct engines per URL, targeted eviction, and the unknown-URL no-op.

config.py - replace object.__setattr__ in normalize_provider_models with a
model_validator(mode="before") over the raw input, so the derived selector is
produced by normal construction rather than by mutating a frozen instance.
model_copy(update=...) was tried first and rejected: pydantic-settings does not
support a top-level validator returning anything other than self when validating
via __init__. provider_model is now stripped as well as the tuple entries.

models.py - add onupdate to the five updated_at columns and to Job.date_updated,
and drop the 10 manual "updated_at = datetime.now(UTC)" assignments across the
document, job, people, registry, and source services. Verified DDL-neutral by
hashing CreateTable output for every table on both the sqlite and postgresql
dialects before and after: identical, so this stays in Phase 6 and Phase 2 does
not need re-verification. New tests/services/test_timestamps.py asserts an
update through each service advances the timestamp.

MED-08 - Job.filename no longer swallows every exception to None. Relationships
declare lazy="raise", so the new _loaded_attribute helper inspects load state
explicitly and returns None only for genuinely unloaded attributes; real errors
now surface. Job.error_detail uses the same helper, which also removes its
unguarded read of the lazy="raise" job_sources relationship.

Verification: ruff check src tests clean; 288 passed, 4 skipped.

Co-authored-by: Copilot App <[email protected]>
This commit is contained in:
zoltan57
2026-08-17 18:51:02 -05:00
co-authored by Copilot App
parent 0b63b53f53
commit 4e8c562f92
13 changed files with 310 additions and 78 deletions
+68
View File
@@ -0,0 +1,68 @@
"""Phase 6 verification that modification timestamps advance automatically.
Covers the `onupdate` change: `updated_at` / `date_updated` are now maintained
by the ORM column default rather than by hand at each call site, so update paths
that previously forgot to set them no longer report a stale timestamp.
"""
from uuid import uuid4
import pytest
from transcription.db.models import Document
from transcription.db.models import Job
from transcription.db.models import JobStatus
from transcription.db.models import Person
from transcription.services.documents import DocumentService
from transcription.services.jobs import JobService
from transcription.services.people import PeopleService
from transcription.services.people import PersonRoleRegistry
@pytest.mark.asyncio
async def test_document_update_advances_updated_at(default_session_factory):
documents = DocumentService(session_factory=default_session_factory)
document = await documents.create_document(Document(id=uuid4(), name="timestamps"))
original = document.updated_at
document.name = "timestamps renamed"
updated = await documents.update_document(document)
assert updated.updated_at > original
@pytest.mark.asyncio
async def test_person_update_advances_updated_at(default_session_factory):
people = PeopleService(session_factory=default_session_factory)
person = await people.create_person(Person(full_name="Grace Hopper"))
original = person.updated_at
person.full_name = "Rear Adm. Grace Hopper"
updated = await people.update_person(person)
assert updated.updated_at > original
@pytest.mark.asyncio
async def test_registry_update_advances_updated_at(default_session_factory):
roles = PersonRoleRegistry(session_factory=default_session_factory)
role = await roles.create_entry(label="Witness")
original = role.updated_at
updated = await roles.update_entry(role.id, label="Chief Witness", is_active=True)
assert updated.updated_at > original
@pytest.mark.asyncio
async def test_job_status_update_advances_date_updated(default_session_factory):
documents = DocumentService(session_factory=default_session_factory)
jobs = JobService(session_factory=default_session_factory)
document = await documents.create_document(Document(id=uuid4(), name="job-timestamps"))
job = await jobs.create_job(Job(document_id=document.id))
original = job.date_updated
updated = await jobs.update_job_state(job_id=job.id, status=JobStatus.PROCESSING)
assert updated.date_updated > original