V6 Phase 2 complete
Quality Gate / gate (push) Failing after 50s

This commit is contained in:
Jim Lancaster
2026-08-25 11:44:59 -05:00
parent 867cc9eb78
commit faa30fd27b
8 changed files with 376 additions and 15 deletions
+9 -9
View File
@@ -11,19 +11,19 @@ wheels/
# Environment secrets # Environment secrets
.env .env
.env.production
# SQLite database # SQLite database
*.db *.db
# Document images # All data including db, backups, document images, photos, and logs:
uploads/*
data/* data/*
data/backups/*
data/documents/*
data/logs/*
data/photos/*
# Local destructive-test backups # Migration tests
.test-backups/ data-migration-test/*
.migration-bundle/*
# Temporary migration files
.migration-bundle-v51
data.pre-v50-20260823/*
data.pre-v51-20260823-120434/*
+2
View File
@@ -54,6 +54,8 @@ services:
timeout: 5s timeout: 5s
retries: 10 retries: 10
start_period: 10s start_period: 10s
ports:
- "5432:5432"
cloudflared: cloudflared:
image: cloudflare/cloudflared:2026.8.0 image: cloudflare/cloudflared:2026.8.0
+42 -6
View File
@@ -19,13 +19,36 @@ Optional source overrides:
- `--source-db <path-or-sqlalchemy-url>` - `--source-db <path-or-sqlalchemy-url>`
- `--source-upload-dir <path>` - `--source-upload-dir <path>`
### 2) Import bundle into a fresh DB + uploads root ### 2) Import bundle into a fresh target (SQLite or PostgreSQL)
```bash ```bash
uv run python tools/export_import_migration.py import --bundle-dir .migration-bundle --target-db .\data\transcription-new.db --target-upload-dir .\data-new uv run python tools/export_import_migration.py import --bundle-dir .migration-bundle --target-db .\data\transcription-new.db --target-upload-dir .\data-new
``` ```
### 3) One-shot export+import PostgreSQL target example:
```bash
uv run python tools/export_import_migration.py import --bundle-dir .migration-bundle --target-db postgresql://transcription:change-me@localhost:5432/transcription --target-upload-dir .\data-new
```
### 3) Verify migration parity and integrity
```bash
uv run python tools/export_import_migration.py verify --source-db .\data\transcription.db --target-db postgresql://transcription:change-me@localhost:5432/transcription
```
The verify command checks:
- row-count parity across migration tables
- orphan-reference checks for `source`, `job`, `job_source`, and `execution_attempt`
- duplicate `(job_id, source_id, attempt_number)` in `execution_attempt`
Exit code:
- `0` when counts and integrity checks pass
- `1` when mismatches or integrity violations are detected
### 4) One-shot export+import
```bash ```bash
uv run python tools/export_import_migration.py migrate --bundle-dir .migration-bundle --target-db .\data\transcription-new.db --target-upload-dir .\data-new uv run python tools/export_import_migration.py migrate --bundle-dir .migration-bundle --target-db .\data\transcription-new.db --target-upload-dir .\data-new
@@ -50,9 +73,22 @@ Legacy V4.x portrait/homepage backfill in the export step:
- Legacy homepage markdown is relocated from `UPLOAD_DIR/homepage/homepage.md` to `UPLOAD_DIR/homepage.md`. - Legacy homepage markdown is relocated from `UPLOAD_DIR/homepage/homepage.md` to `UPLOAD_DIR/homepage.md`.
- Legacy `person.full_name` values are split into `given_names` + `last_name` for V5.1 schema compatibility. - Legacy `person.full_name` values are split into `given_names` + `last_name` for V5.1 schema compatibility.
## Cutover ## Cutover (SQLite -> PostgreSQL)
After importing to a fresh target: After importing to a fresh target:
1. Stop the app. 1. Stop app and worker services to freeze writes.
2. Point `DATABASE__*` and `UPLOAD_DIR` to the new targets. 2. Export a migration bundle from the last SQLite state.
3. Start the app and run smoke checks (`/healthz`, create/upload/process one job). 3. Import bundle to PostgreSQL target.
4. Run `verify` against source and target before switching runtime.
5. Switch runtime config to PostgreSQL (`DATABASE__DRIVER=postgres` and related `DATABASE__*` values).
6. Start app and worker services.
7. Run smoke checks (`/healthz`, create/upload/process one job).
## Rollback
If verify or smoke checks fail:
1. Stop app and worker services.
2. Revert runtime config to SQLite.
3. Start app and worker against pre-cutover SQLite database.
4. Preserve failed migration bundle and logs for analysis.
+1
View File
@@ -20,6 +20,7 @@ This runbook is the operational checklist for releasing and monitoring the trans
1. Deploy artifact/config to target environment. 1. Deploy artifact/config to target environment.
- V6.0 Phase 1 production stack: `docker compose -f docker-compose.production.yml up -d --build` - V6.0 Phase 1 production stack: `docker compose -f docker-compose.production.yml up -d --build`
- For SQLite -> PostgreSQL cutover, run `uv run python tools/export_import_migration.py verify --source-db <sqlite-path-or-url> --target-db <postgres-url>` before switching runtime.
2. Validate service startup: 2. Validate service startup:
- `/healthz` responds `200` - `/healthz` responds `200`
- if `RUN_EMBEDDED_WORKER=true`, `worker.state` is `running` - if `RUN_EMBEDDED_WORKER=true`, `worker.state` is `running`
+5
View File
@@ -38,6 +38,11 @@ Persistence:
2. Run migration in staging-like environment and validate entity counts and key relationships. 2. Run migration in staging-like environment and validate entity counts and key relationships.
3. Execute cutover with rollback guardrails and preserved evidence/provenance history. 3. Execute cutover with rollback guardrails and preserved evidence/provenance history.
Implemented workflow references:
- `tools/export_import_migration.py` (`export`, `import`, `migrate`, `verify`)
- `docs/data_migration.md` for cutover and rollback procedure
## 3.3 Cloudflare remote access ## 3.3 Cloudflare remote access
1. Configure tunnel routing for service hostnames. 1. Configure tunnel routing for service hostnames.
+168
View File
@@ -16,9 +16,12 @@ from uuid import uuid4
from sqlalchemy import URL from sqlalchemy import URL
from sqlalchemy import MetaData from sqlalchemy import MetaData
from sqlalchemy import Table from sqlalchemy import Table
from sqlalchemy import bindparam
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy import func
from sqlalchemy import inspect as sqlalchemy_inspect from sqlalchemy import inspect as sqlalchemy_inspect
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy import text
from sqlalchemy.engine import RowMapping from sqlalchemy.engine import RowMapping
from sqlalchemy.engine import make_url from sqlalchemy.engine import make_url
from sqlmodel import SQLModel from sqlmodel import SQLModel
@@ -47,6 +50,7 @@ EXPORT_TABLE_ORDER = (
) )
BYTES_FIELDS = {"transport_body"} BYTES_FIELDS = {"transport_body"}
VERIFICATION_TABLES = EXPORT_TABLE_ORDER
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -142,21 +146,74 @@ def import_bundle(*, target_db_url: str, target_upload_dir: Path, bundle_dir: Pa
engine = create_engine(target_db_url) engine = create_engine(target_db_url)
try: try:
SQLModel.metadata.create_all(engine) SQLModel.metadata.create_all(engine)
execution_attempt_ids = _collect_execution_attempt_ids(payload)
with engine.begin() as connection: with engine.begin() as connection:
for table_name in reversed(EXPORT_TABLE_ORDER): for table_name in reversed(EXPORT_TABLE_ORDER):
table = SQLModel.metadata.tables[table_name] table = SQLModel.metadata.tables[table_name]
connection.execute(table.delete()) connection.execute(table.delete())
deferred_source_preferred_attempt_updates: list[dict[str, Any]] = []
for table_name in EXPORT_TABLE_ORDER: for table_name in EXPORT_TABLE_ORDER:
rows = payload.get("tables", {}).get(table_name, []) rows = payload.get("tables", {}).get(table_name, [])
if not rows: if not rows:
continue continue
table = SQLModel.metadata.tables[table_name] table = SQLModel.metadata.tables[table_name]
if table_name == "source":
prepared_source_rows, updates = _prepare_source_rows_for_import(
rows=rows,
source_table=table,
execution_attempt_ids=execution_attempt_ids,
)
deferred_source_preferred_attempt_updates.extend(updates)
connection.execute(table.insert(), prepared_source_rows)
continue
connection.execute(table.insert(), [_deserialize_row(row, table) for row in rows]) connection.execute(table.insert(), [_deserialize_row(row, table) for row in rows])
if deferred_source_preferred_attempt_updates:
source_table = SQLModel.metadata.tables["source"]
connection.execute(
source_table.update()
.where(source_table.c.id == bindparam("source_id"))
.values(preferred_execution_attempt_id=bindparam("preferred_execution_attempt_id")),
deferred_source_preferred_attempt_updates,
)
finally: finally:
engine.dispose() engine.dispose()
def _collect_execution_attempt_ids(payload: dict[str, Any]) -> set[str]:
execution_attempt_rows = payload.get("tables", {}).get("execution_attempt", [])
return {_normalize_uuid_like(row.get("id")) for row in execution_attempt_rows if row.get("id") is not None}
def _prepare_source_rows_for_import(
*,
rows: list[dict[str, Any]],
source_table: Table,
execution_attempt_ids: set[str],
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
prepared_source_rows: list[dict[str, Any]] = []
updates: list[dict[str, Any]] = []
for row in rows:
source_row = _deserialize_row(row, source_table)
source_id = source_row.get("id")
preferred_attempt_id = source_row.get("preferred_execution_attempt_id")
if (
source_id is not None
and preferred_attempt_id is not None
and _normalize_uuid_like(preferred_attempt_id) in execution_attempt_ids
):
updates.append(
{
"source_id": source_id,
"preferred_execution_attempt_id": preferred_attempt_id,
}
)
source_row["preferred_execution_attempt_id"] = None
prepared_source_rows.append(source_row)
return prepared_source_rows, updates
def _ensure_sqlite_target_parent_exists(target_db_url: str) -> None: def _ensure_sqlite_target_parent_exists(target_db_url: str) -> None:
parsed = make_url(target_db_url) parsed = make_url(target_db_url)
if not parsed.drivername.startswith("sqlite"): if not parsed.drivername.startswith("sqlite"):
@@ -192,6 +249,24 @@ def migrate_via_bundle(paths: MigrationPaths) -> None:
) )
@dataclass(frozen=True)
class MigrationVerificationReport:
source_counts: dict[str, int]
target_counts: dict[str, int]
mismatched_tables: dict[str, dict[str, int]]
integrity_violations: dict[str, int]
success: bool
def to_dict(self) -> dict[str, Any]:
return {
"success": self.success,
"source_counts": self.source_counts,
"target_counts": self.target_counts,
"mismatched_tables": self.mismatched_tables,
"integrity_violations": self.integrity_violations,
}
def sqlite_url_from_path(path: Path) -> str: def sqlite_url_from_path(path: Path) -> str:
return URL.create(drivername="sqlite", database=str(path)).render_as_string(hide_password=False) return URL.create(drivername="sqlite", database=str(path)).render_as_string(hide_password=False)
@@ -201,6 +276,87 @@ def default_sync_db_url(settings: Settings | None = None) -> str:
return get_database_url(runtime_settings).replace("+aiosqlite", "").replace("+asyncpg", "").replace("+psycopg", "") return get_database_url(runtime_settings).replace("+aiosqlite", "").replace("+asyncpg", "").replace("+psycopg", "")
def verify_migration(*, source_db_url: str, target_db_url: str) -> MigrationVerificationReport:
source_counts = _table_counts(source_db_url)
target_counts = _table_counts(target_db_url)
mismatched_tables = {
table_name: {"source": source_counts[table_name], "target": target_counts[table_name]}
for table_name in VERIFICATION_TABLES
if source_counts[table_name] != target_counts[table_name]
}
integrity_violations = _integrity_violations(target_db_url)
success = not mismatched_tables and all(count == 0 for count in integrity_violations.values())
return MigrationVerificationReport(
source_counts=source_counts,
target_counts=target_counts,
mismatched_tables=mismatched_tables,
integrity_violations=integrity_violations,
success=success,
)
def _table_counts(db_url: str) -> dict[str, int]:
engine = create_engine(db_url)
try:
metadata = MetaData()
metadata.reflect(bind=engine)
counts: dict[str, int] = {}
with engine.connect() as connection:
for table_name in VERIFICATION_TABLES:
table = metadata.tables.get(table_name)
if table is None:
counts[table_name] = 0
continue
counts[table_name] = int(connection.execute(select(func.count()).select_from(table)).scalar_one())
return counts
finally:
engine.dispose()
def _integrity_violations(db_url: str) -> dict[str, int]:
checks = {
"orphan_source_document": (
"select count(*) from source s left join document d on d.id = s.document_id where d.id is null"
),
"orphan_job_document": (
"select count(*) from job j left join document d on d.id = j.document_id where d.id is null"
),
"orphan_job_source_job": (
"select count(*) from job_source js left join job j on j.id = js.job_id where j.id is null"
),
"orphan_job_source_source": (
"select count(*) from job_source js left join source s on s.id = js.source_id where s.id is null"
),
"orphan_attempt_job_source": (
"select count(*) from execution_attempt ea "
"left join job_source js on js.id = ea.job_source_id "
"where js.id is null"
),
"orphan_attempt_job": (
"select count(*) from execution_attempt ea left join job j on j.id = ea.job_id where j.id is null"
),
"orphan_attempt_source": (
"select count(*) from execution_attempt ea left join source s on s.id = ea.source_id where s.id is null"
),
"duplicate_attempt_numbers": (
"select count(*) from ("
" select job_id, source_id, attempt_number, count(*) as c"
" from execution_attempt"
" group by job_id, source_id, attempt_number"
" having count(*) > 1"
") x"
),
}
engine = create_engine(db_url)
try:
with engine.connect() as connection:
return {
check_name: int(connection.execute(text(query)).scalar_one()) for check_name, query in checks.items()
}
finally:
engine.dispose()
def _serialize_row(row: RowMapping, *, table_name: str, source_upload_dir: Path) -> dict[str, Any]: def _serialize_row(row: RowMapping, *, table_name: str, source_upload_dir: Path) -> dict[str, Any]:
serialized: dict[str, Any] = {} serialized: dict[str, Any] = {}
for raw_key, value in row.items(): for raw_key, value in row.items():
@@ -283,6 +439,18 @@ def _deserialize_value(python_type: type[Any], value: Any) -> Any:
return value return value
def _normalize_uuid_like(value: Any) -> str:
if isinstance(value, UUID):
return str(value)
if isinstance(value, str):
text_value = value.strip()
try:
return str(UUID(text_value))
except ValueError:
return text_value
return str(value)
def _canonical_media_relative_path(value: str, *, source_upload_dir: Path, preferred_prefix: str) -> str: def _canonical_media_relative_path(value: str, *, source_upload_dir: Path, preferred_prefix: str) -> str:
normalized = value.strip().replace("\\", "/") normalized = value.strip().replace("\\", "/")
lowered = normalized.casefold() lowered = normalized.casefold()
+141
View File
@@ -16,6 +16,7 @@ from transcription.db.migration import export_bundle
from transcription.db.migration import import_bundle from transcription.db.migration import import_bundle
from transcription.db.migration import migrate_via_bundle from transcription.db.migration import migrate_via_bundle
from transcription.db.migration import sqlite_url_from_path from transcription.db.migration import sqlite_url_from_path
from transcription.db.migration import verify_migration
def test_export_import_migration_round_trips_db_and_uploads(tmp_path): def test_export_import_migration_round_trips_db_and_uploads(tmp_path):
@@ -339,3 +340,143 @@ def test_import_bundle_does_not_leave_upload_copy_db_as_final_database(tmp_path)
assert count == 1 assert count == 1
finally: finally:
target_engine.dispose() target_engine.dispose()
def test_verify_migration_succeeds_for_matching_databases(tmp_path):
source_db_url = sqlite_url_from_path(tmp_path / "source.db")
target_db_url = sqlite_url_from_path(tmp_path / "target.db")
source_engine = create_engine(source_db_url)
target_engine = create_engine(target_db_url)
try:
SQLModel.metadata.create_all(source_engine)
SQLModel.metadata.create_all(target_engine)
finally:
source_engine.dispose()
target_engine.dispose()
report = verify_migration(source_db_url=source_db_url, target_db_url=target_db_url)
assert report.success is True
assert report.mismatched_tables == {}
assert all(count == 0 for count in report.integrity_violations.values())
def test_verify_migration_detects_count_mismatches(tmp_path):
source_db_url = sqlite_url_from_path(tmp_path / "source.db")
target_db_url = sqlite_url_from_path(tmp_path / "target.db")
document_id = uuid4()
source_engine = create_engine(source_db_url)
target_engine = create_engine(target_db_url)
try:
SQLModel.metadata.create_all(source_engine)
SQLModel.metadata.create_all(target_engine)
with source_engine.begin() as connection:
connection.execute(
SQLModel.metadata.tables["document"].insert(),
[{"id": document_id, "name": "Source-only row"}],
)
finally:
source_engine.dispose()
target_engine.dispose()
report = verify_migration(source_db_url=source_db_url, target_db_url=target_db_url)
assert report.success is False
assert report.mismatched_tables["document"] == {"source": 1, "target": 0}
def test_export_import_preserves_source_preferred_attempt_reference(tmp_path):
source_db_path = tmp_path / "source-preferred.db"
target_db_path = tmp_path / "target-preferred.db"
source_upload_dir = tmp_path / "source_uploads"
target_upload_dir = tmp_path / "target_uploads"
bundle_dir = tmp_path / "bundle-preferred"
source_db_url = sqlite_url_from_path(source_db_path)
target_db_url = sqlite_url_from_path(target_db_path)
document_id = uuid4()
job_id = uuid4()
source_id = uuid4()
job_source_id = uuid4()
attempt_id = uuid4()
filename = f"{source_id}.jpg"
media_path = source_upload_dir / "documents" / str(document_id) / filename
media_path.parent.mkdir(parents=True, exist_ok=True)
media_path.write_bytes(b"sample-image")
source_engine = create_engine(source_db_url)
try:
SQLModel.metadata.create_all(source_engine)
with source_engine.begin() as connection:
connection.execute(
SQLModel.metadata.tables["document"].insert(),
[{"id": document_id, "name": "Preferred attempt doc"}],
)
connection.execute(
SQLModel.metadata.tables["job"].insert(),
[{"id": job_id, "document_id": document_id, "status": "queued"}],
)
connection.execute(
SQLModel.metadata.tables["source"].insert(),
[
{
"id": source_id,
"document_id": document_id,
"page_number": 1,
"upload_name": "upload.jpg",
"filename": filename,
"file_path": str(media_path),
"file_hash": "b" * 64,
"file_size_bytes": len(b"sample-image"),
}
],
)
connection.execute(
SQLModel.metadata.tables["job_source"].insert(),
[{"id": job_source_id, "job_id": job_id, "source_id": source_id, "status": "pending"}],
)
connection.execute(
SQLModel.metadata.tables["execution_attempt"].insert(),
[
{
"id": attempt_id,
"job_source_id": job_source_id,
"job_id": job_id,
"source_id": source_id,
"attempt_number": 1,
"status": "transcribed",
"provider": "fixture",
"model": "fixture-model",
"raw_transcription": "hello",
"started_at": datetime.now(UTC),
"finished_at": datetime.now(UTC),
"duration_ms": 10,
}
],
)
connection.execute(
SQLModel.metadata.tables["source"]
.update()
.where(SQLModel.metadata.tables["source"].c.id == source_id)
.values(preferred_execution_attempt_id=attempt_id),
)
finally:
source_engine.dispose()
export_bundle(source_db_url=source_db_url, source_upload_dir=source_upload_dir, bundle_dir=bundle_dir)
import_bundle(target_db_url=target_db_url, target_upload_dir=target_upload_dir, bundle_dir=bundle_dir)
target_engine = create_engine(target_db_url)
try:
with target_engine.connect() as connection:
preferred_attempt = connection.execute(
select(SQLModel.metadata.tables["source"].c.preferred_execution_attempt_id).where(
SQLModel.metadata.tables["source"].c.id == source_id
)
).scalar_one()
assert preferred_attempt == attempt_id
finally:
target_engine.dispose()
+8
View File
@@ -17,6 +17,7 @@ Usage examples:
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import json
from pathlib import Path from pathlib import Path
from typing import Final from typing import Final
@@ -27,6 +28,7 @@ from transcription.db.migration import export_bundle
from transcription.db.migration import import_bundle from transcription.db.migration import import_bundle
from transcription.db.migration import migrate_via_bundle from transcription.db.migration import migrate_via_bundle
from transcription.db.migration import sqlite_url_from_path from transcription.db.migration import sqlite_url_from_path
from transcription.db.migration import verify_migration
DEFAULT_BUNDLE_DIR: Final[Path] = Path(".migration-bundle") DEFAULT_BUNDLE_DIR: Final[Path] = Path(".migration-bundle")
@@ -77,6 +79,7 @@ def main() -> int:
subparsers.add_parser("export", parents=[common], help="Export DB rows and uploads into bundle") subparsers.add_parser("export", parents=[common], help="Export DB rows and uploads into bundle")
subparsers.add_parser("import", parents=[common], help="Import bundle into target DB and uploads") subparsers.add_parser("import", parents=[common], help="Import bundle into target DB and uploads")
subparsers.add_parser("migrate", parents=[common], help="Run export then import in one command") subparsers.add_parser("migrate", parents=[common], help="Run export then import in one command")
subparsers.add_parser("verify", parents=[common], help="Compare source/target row counts and integrity checks")
args = parser.parse_args() args = parser.parse_args()
bundle_dir = Path(args.bundle_dir) bundle_dir = Path(args.bundle_dir)
@@ -104,6 +107,11 @@ def main() -> int:
print(f"Import complete: db={target_db_url} uploads={target_upload_dir}") print(f"Import complete: db={target_db_url} uploads={target_upload_dir}")
return 0 return 0
if args.command == "verify":
report = verify_migration(source_db_url=source_db_url, target_db_url=target_db_url)
print(json.dumps(report.to_dict(), indent=2))
return 0 if report.success else 1
migrate_via_bundle( migrate_via_bundle(
MigrationPaths( MigrationPaths(
source_db_url=source_db_url, source_db_url=source_db_url,