generated from john/python-template
340 lines
13 KiB
Python
340 lines
13 KiB
Python
"""One-time migration of a V4.6 database into the V4.7 schema.
|
|
|
|
V4.7 is an architectural cleanup: no new user-facing behaviour, but three
|
|
structural changes plus a one-time image backfill. This tool carries all of
|
|
them, and is built up phase by phase so the live database stays usable at
|
|
every phase boundary.
|
|
|
|
Steps, in execution order:
|
|
|
|
1. Rotate every stored Source image that still carries a supported EXIF
|
|
orientation, in place, and update ``source.file_hash`` and
|
|
``source.file_size_bytes`` to describe the rewritten file.
|
|
2. Drop the ``processing_artifact`` table and delete its external files.
|
|
3. Rewrite ``execution_attempt.status`` from enum *names* to enum *values*, so
|
|
it compares equal to ``job_source.status`` (defect [45]).
|
|
4. Drop the five evidence columns from ``job_source``, leaving it a pure work
|
|
queue of ``id``, ``job_id``, ``source_id`` and ``status``.
|
|
|
|
Design notes:
|
|
|
|
- The image rewrite reuses the application's own
|
|
:func:`~transcription.services.normalization.normalize_orientation`, so the
|
|
backfilled bytes are byte-identical to what ingest would now produce. It
|
|
reuses the source quantization tables and subsampling rather than
|
|
re-quantizing, which is both smaller and higher fidelity than a fixed
|
|
quality setting.
|
|
- The hash and size are rewritten alongside the file. After V4.7 the evidence
|
|
digest is derived straight from ``source.file_hash``, so leaving it
|
|
describing the pre-rotation bytes would silently invalidate every future
|
|
export.
|
|
- The database is read and written through SQLAlchemy Core against the live
|
|
metadata, so the same script works against PostgreSQL when that cutover
|
|
happens. Raw DDL is used only for the table drop, which has no Core
|
|
equivalent that is safe to express against deleted metadata.
|
|
- The script is idempotent, keyed on state rather than on a version marker:
|
|
an image with no supported orientation tag is skipped, and a table that is
|
|
already absent is skipped. It is never invoked from application startup and
|
|
never runs in the test suite.
|
|
- **The application must not be running.** The image rewrite is not atomic
|
|
with the row update, and SQLite will refuse the schema change while another
|
|
connection holds the database.
|
|
|
|
Usage::
|
|
|
|
python tools/migrate_v46_to_v47.py --dry-run
|
|
python tools/migrate_v46_to_v47.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import sys
|
|
from collections.abc import Sequence
|
|
from pathlib import Path
|
|
|
|
from sqlalchemy import bindparam
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy import inspect as sqlalchemy_inspect
|
|
from sqlalchemy import select
|
|
from sqlalchemy import text
|
|
from sqlalchemy import update
|
|
from sqlalchemy.engine import Connection
|
|
from sqlmodel import SQLModel
|
|
|
|
from transcription.config import Settings
|
|
from transcription.config import get_settings
|
|
from transcription.db import models as _models # noqa: F401 (registers every table)
|
|
from transcription.db.engine import get_database_url
|
|
from transcription.db.models import JobSourceStatus
|
|
from transcription.services.normalization import normalize_orientation
|
|
from transcription.services.sources import source_mime_type
|
|
|
|
#: Row counts the V4.6 database is expected to carry, used as a pre-flight
|
|
#: guard so the script cannot silently run against the wrong file.
|
|
EXPECTED_ROW_COUNTS = {
|
|
"document": 8,
|
|
"document_person": 11,
|
|
"document_type": 7,
|
|
"execution_attempt": 80,
|
|
"job": 11,
|
|
"job_source": 79,
|
|
"person": 5,
|
|
"person_role": 3,
|
|
"source": 76,
|
|
}
|
|
|
|
ARTIFACT_TABLE = "processing_artifact"
|
|
|
|
#: Evidence columns removed from ``job_source`` in V4.7. Every one of them is
|
|
#: duplicated byte-for-byte by ``execution_attempt`` across all 77 rows that
|
|
#: carry evidence, so no information is lost by dropping them.
|
|
JOB_SOURCE_DROPPED_COLUMNS = (
|
|
"raw_transcription",
|
|
"ai_metadata",
|
|
"raw_api_response",
|
|
"error_detail",
|
|
"executed_at",
|
|
)
|
|
|
|
#: The V4.6 default for the deleted ``Settings.artifact_dir``. The setting no
|
|
#: longer exists, so the historical location is recorded here instead.
|
|
DEFAULT_ARTIFACT_DIR = Path("data/artifacts")
|
|
|
|
|
|
def _sync_url(settings: Settings) -> str:
|
|
"""Return the target database URL with any async driver stripped."""
|
|
url = get_database_url(settings)
|
|
return url.replace("+aiosqlite", "").replace("+asyncpg", "").replace("+psycopg", "")
|
|
|
|
|
|
def _preflight(connection: Connection, *, strict: bool) -> None:
|
|
inspector = sqlalchemy_inspect(connection)
|
|
present = set(inspector.get_table_names())
|
|
mismatched: dict[str, tuple[object, int]] = {}
|
|
for name, expected in EXPECTED_ROW_COUNTS.items():
|
|
if name not in present:
|
|
mismatched[name] = ("missing", expected)
|
|
continue
|
|
actual = connection.execute(text(f'select count(*) from "{name}"')).scalar_one()
|
|
if actual != expected:
|
|
mismatched[name] = (actual, expected)
|
|
if not mismatched:
|
|
return
|
|
detail = ", ".join(f"{name}: found {found}, expected {want}" for name, (found, want) in sorted(mismatched.items()))
|
|
message = f"Database row counts do not match the recorded V4.6 snapshot ({detail})"
|
|
if strict:
|
|
raise RuntimeError(message)
|
|
print(f"WARNING: {message}", file=sys.stderr)
|
|
|
|
|
|
def rotate_stored_images(connection: Connection, *, dry_run: bool) -> int:
|
|
"""Step 1: rewrite every mis-oriented stored image and its recorded digest."""
|
|
source = SQLModel.metadata.tables["source"]
|
|
rows = connection.execute(
|
|
select(source.c.id, source.c.file_path, source.c.filename)
|
|
).all()
|
|
|
|
rotated = 0
|
|
missing = 0
|
|
for source_id, file_path, filename in rows:
|
|
path = Path(str(file_path))
|
|
if not path.is_file():
|
|
print(f" WARNING: source file not found, skipped: {path}", file=sys.stderr)
|
|
missing += 1
|
|
continue
|
|
|
|
content = path.read_bytes()
|
|
normalized = normalize_orientation(content, media_type=source_mime_type(str(filename)))
|
|
if normalized is None:
|
|
continue
|
|
|
|
rotated += 1
|
|
print(
|
|
f" {path.name} orientation={normalized.original_orientation} "
|
|
f"rotation={normalized.applied_rotation_degrees} "
|
|
f"{len(content)} -> {len(normalized.content)} bytes"
|
|
)
|
|
if dry_run:
|
|
continue
|
|
|
|
path.write_bytes(normalized.content)
|
|
connection.execute(
|
|
update(source)
|
|
.where(source.c.id == source_id)
|
|
.values(
|
|
file_hash=hashlib.sha256(normalized.content).hexdigest(),
|
|
file_size_bytes=len(normalized.content),
|
|
)
|
|
)
|
|
|
|
print(f" rotated={rotated} upright={len(rows) - rotated - missing} missing={missing}")
|
|
return rotated
|
|
|
|
|
|
def drop_processing_artifacts(connection: Connection, artifact_dir: Path, *, dry_run: bool) -> int:
|
|
"""Step 2: drop the artifact table and delete the files it referenced."""
|
|
inspector = sqlalchemy_inspect(connection)
|
|
if ARTIFACT_TABLE not in set(inspector.get_table_names()):
|
|
print(f" {ARTIFACT_TABLE} already absent")
|
|
return 0
|
|
|
|
references = [
|
|
str(row[0])
|
|
for row in connection.execute(
|
|
text(f'select external_reference from "{ARTIFACT_TABLE}" where external_reference is not null')
|
|
)
|
|
]
|
|
count = connection.execute(text(f'select count(*) from "{ARTIFACT_TABLE}"')).scalar_one()
|
|
print(f" dropping {ARTIFACT_TABLE} ({count} row(s), {len(references)} external file(s))")
|
|
|
|
if dry_run:
|
|
return count
|
|
|
|
connection.execute(text(f'drop table "{ARTIFACT_TABLE}"'))
|
|
|
|
artifact_root = artifact_dir.resolve()
|
|
for reference in references:
|
|
relative = Path(reference)
|
|
if relative.is_absolute() or ".." in relative.parts:
|
|
print(f" WARNING: skipped unsafe artifact reference: {reference}", file=sys.stderr)
|
|
continue
|
|
artifact_path = (artifact_root / relative).resolve()
|
|
if artifact_root not in artifact_path.parents:
|
|
print(f" WARNING: skipped artifact outside root: {reference}", file=sys.stderr)
|
|
continue
|
|
artifact_path.unlink(missing_ok=True)
|
|
parent = artifact_path.parent
|
|
if parent != artifact_root and parent.is_dir() and not any(parent.iterdir()):
|
|
parent.rmdir()
|
|
|
|
return count
|
|
|
|
|
|
def normalize_attempt_status(connection: Connection, *, dry_run: bool) -> int:
|
|
"""Step 3: rewrite ``execution_attempt.status`` from enum names to values.
|
|
|
|
Defect [45]: ``execution_attempt.status`` was declared without
|
|
``values_callable``, so SQLAlchemy persisted enum *names* ('TRANSCRIBED')
|
|
while ``job_source.status`` persisted *values* ('transcribed'). The two
|
|
columns never compared equal on a single one of the 79 rows. The model
|
|
declaration is fixed in V4.7; the stored rows are fixed here.
|
|
"""
|
|
name_to_value = {member.name: member.value for member in JobSourceStatus}
|
|
recognised = sorted(set(name_to_value) | set(name_to_value.values()))
|
|
unknown = (
|
|
connection.execute(
|
|
text("select distinct status from execution_attempt where status not in :values").bindparams(
|
|
bindparam("values", recognised, expanding=True)
|
|
)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
if unknown:
|
|
raise RuntimeError(f"execution_attempt.status carries unrecognised spellings: {sorted(unknown)}")
|
|
|
|
rewritten = 0
|
|
for name, value in sorted(name_to_value.items()):
|
|
if name == value:
|
|
continue
|
|
count = connection.execute(
|
|
text("select count(*) from execution_attempt where status = :name"),
|
|
{"name": name},
|
|
).scalar_one()
|
|
if not count:
|
|
continue
|
|
print(f" {name} -> {value}: {count} row(s)")
|
|
rewritten += count
|
|
if dry_run:
|
|
continue
|
|
connection.execute(
|
|
text("update execution_attempt set status = :value where status = :name"),
|
|
{"name": name, "value": value},
|
|
)
|
|
|
|
print(f" rewritten={rewritten}")
|
|
return rewritten
|
|
|
|
|
|
def strip_job_source_columns(connection: Connection, *, dry_run: bool) -> int:
|
|
"""Step 4: drop the evidence columns from ``job_source``.
|
|
|
|
Uses ``ALTER TABLE ... DROP COLUMN``, supported by SQLite 3.35+ and by
|
|
PostgreSQL. Idempotent: a column that is already gone is skipped.
|
|
"""
|
|
inspector = sqlalchemy_inspect(connection)
|
|
present = {column["name"] for column in inspector.get_columns("job_source")}
|
|
targets = [name for name in JOB_SOURCE_DROPPED_COLUMNS if name in present]
|
|
if not targets:
|
|
print(" all evidence columns already dropped")
|
|
return 0
|
|
|
|
print(f" dropping {len(targets)} column(s): {', '.join(targets)}")
|
|
if dry_run:
|
|
return len(targets)
|
|
|
|
for name in targets:
|
|
connection.execute(text(f'alter table "job_source" drop column "{name}"'))
|
|
return len(targets)
|
|
|
|
|
|
def migrate(*, settings: Settings, artifact_dir: Path, dry_run: bool, strict_counts: bool) -> None:
|
|
"""Apply every V4.7 migration step in order."""
|
|
engine = create_engine(_sync_url(settings))
|
|
try:
|
|
with engine.begin() as connection:
|
|
_preflight(connection, strict=strict_counts)
|
|
|
|
print("\nStep 1: rotate stored images")
|
|
rotate_stored_images(connection, dry_run=dry_run)
|
|
|
|
print(f"\nStep 2: drop {ARTIFACT_TABLE}")
|
|
drop_processing_artifacts(connection, artifact_dir, dry_run=dry_run)
|
|
|
|
print("\nStep 3: normalize execution_attempt.status spelling")
|
|
normalize_attempt_status(connection, dry_run=dry_run)
|
|
|
|
print("\nStep 4: strip evidence columns from job_source")
|
|
strip_job_source_columns(connection, dry_run=dry_run)
|
|
finally:
|
|
engine.dispose()
|
|
|
|
if dry_run:
|
|
print("\nDry run: nothing was written.")
|
|
else:
|
|
print("\nDone.")
|
|
|
|
|
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument("--dry-run", action="store_true", help="Report what would change without writing")
|
|
parser.add_argument(
|
|
"--allow-count-mismatch",
|
|
action="store_true",
|
|
help="Warn instead of aborting when row counts differ from the recorded V4.6 snapshot",
|
|
)
|
|
parser.add_argument(
|
|
"--artifact-dir",
|
|
type=Path,
|
|
default=DEFAULT_ARTIFACT_DIR,
|
|
help="Directory that held external artifact files before V4.7",
|
|
)
|
|
args = parser.parse_args(argv)
|
|
|
|
settings = get_settings()
|
|
print(f"Target: {_sync_url(settings)}")
|
|
|
|
migrate(
|
|
settings=settings,
|
|
artifact_dir=args.artifact_dir,
|
|
dry_run=args.dry_run,
|
|
strict_counts=not args.allow_count_mismatch,
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|