generated from john/python-template
+9
-9
@@ -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/*
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user