CLI script to migrate existing tenants to the data model introduced
by #202-205. Idempotent and resumable — re-running is a no-op.
tools/migrate_06.py
- Step 1: backfill_lifecycle — infers documents.lifecycle_state from
analysis_jobs.status + chunks_json presence
Failed analyses, no completed -> Failed
Completed + chunks_json -> Chunked
Completed, no chunks_json -> Parsed
Otherwise -> Uploaded
- Step 2: materialize_chunks — promotes the latest chunks_json blob
for each doc into rows in the new chunks table. Stable id derivation
via uuid5(NAMESPACE_OID, '<doc>|<seq>|<text>') so re-running lands
on the same ids
- Step 3: backfill_links — for any doc that has chunks materialized,
creates a (doc, default-store) link in Ingested state with a freshly
computed chunkset_hash. Treats 'chunks exist' as proxy for 'doc was
ingested into the legacy single index' — sufficient for the typical
pre-0.6.0 deployment, with OpenSearch reindex documented separately
- Step 4: reaggregate — applies #203's aggregate_lifecycle rule so
doc-chunked transitions to Ingested
Persistence
- migration_progress table for resumability + per-step idempotency
CLI
python -m tools.migrate_06 # full migration
python -m tools.migrate_06 --dry-run # plan only, no writes
python -m tools.migrate_06 --only-step <name> # rerun a phase
Tests
- 9 tests: inference rule per (completed, failed, chunks_json) tuple,
end-to-end migration on a hand-built three-doc snapshot, idempotency
(second run = no writes), --dry-run writes nothing, deterministic
chunk ids across reruns
Refs #206
433 lines
14 KiB
Python
433 lines
14 KiB
Python
"""0.6.0 migration — backfill the doc-centric data model.
|
|
|
|
Run after deploying the 0.6.0 release code, before sending traffic. The
|
|
script is idempotent and resumable — re-running on an already-migrated
|
|
database is a no-op. Each step records itself in `migration_progress`.
|
|
|
|
Inference rules for `documents.lifecycle_state`:
|
|
|
|
has any FAILED analysis_jobs row, no completed → Failed
|
|
has any COMPLETED analysis_jobs row + chunks_json → Chunked
|
|
has any COMPLETED analysis_jobs row, no chunks_json → Parsed
|
|
else → Uploaded
|
|
|
|
After chunks are materialized into the new `chunks` table and links are
|
|
backfilled (one per existing default-store ingestion), the document
|
|
state is recomputed using the standard #203 aggregation rule. This
|
|
covers Stale / Ingested upgrades where applicable.
|
|
|
|
Usage:
|
|
|
|
python -m tools.migrate_06 [--dry-run] [--only-step <name>]
|
|
|
|
The CLI is intentionally minimal — see docs/design/206-lifecycle-state-migration.md
|
|
for the full set of flags considered.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import sys
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from typing import TYPE_CHECKING
|
|
|
|
from domain.hashing import chunkset_hash
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable, Iterable
|
|
|
|
import aiosqlite
|
|
from domain.lifecycle_aggregation import aggregate_lifecycle
|
|
from domain.models import Chunk, DocumentStoreLink
|
|
from domain.value_objects import (
|
|
ChunkBbox,
|
|
ChunkDocItem,
|
|
ChunkResult,
|
|
DocumentLifecycleState,
|
|
DocumentStoreLinkState,
|
|
)
|
|
from persistence.chunk_repo import SqliteChunkRepository
|
|
from persistence.database import get_connection, init_db
|
|
from persistence.document_store_link_repo import SqliteDocumentStoreLinkRepository
|
|
from persistence.store_repo import SqliteStoreRepository
|
|
|
|
logger = logging.getLogger("migrate_06")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Progress tracking
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def _is_done(db: aiosqlite.Connection, name: str) -> bool:
|
|
cursor = await db.execute("SELECT 1 FROM migration_progress WHERE name = ?", (name,))
|
|
return await cursor.fetchone() is not None
|
|
|
|
|
|
async def _mark_done(db: aiosqlite.Connection, name: str) -> None:
|
|
await db.execute(
|
|
"INSERT OR IGNORE INTO migration_progress (name) VALUES (?)",
|
|
(name,),
|
|
)
|
|
await db.commit()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Step: backfill_document_lifecycle_state
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class _DocFacts:
|
|
document_id: str
|
|
has_completed: bool
|
|
has_failed: bool
|
|
has_chunks_json: bool
|
|
|
|
|
|
async def _gather_document_facts(db: aiosqlite.Connection) -> list[_DocFacts]:
|
|
"""One row per document with the analysis-state booleans we care about."""
|
|
cursor = await db.execute(
|
|
"""SELECT d.id AS document_id,
|
|
MAX(CASE WHEN a.status = 'COMPLETED' THEN 1 ELSE 0 END) AS has_completed,
|
|
MAX(CASE WHEN a.status = 'FAILED' THEN 1 ELSE 0 END) AS has_failed,
|
|
MAX(CASE WHEN a.chunks_json IS NOT NULL AND a.chunks_json != ''
|
|
THEN 1 ELSE 0 END) AS has_chunks_json
|
|
FROM documents d
|
|
LEFT JOIN analysis_jobs a ON a.document_id = d.id
|
|
GROUP BY d.id"""
|
|
)
|
|
rows = await cursor.fetchall()
|
|
return [
|
|
_DocFacts(
|
|
document_id=r["document_id"],
|
|
has_completed=bool(r["has_completed"]),
|
|
has_failed=bool(r["has_failed"]),
|
|
has_chunks_json=bool(r["has_chunks_json"]),
|
|
)
|
|
for r in rows
|
|
]
|
|
|
|
|
|
def _infer_lifecycle(facts: _DocFacts) -> DocumentLifecycleState:
|
|
if facts.has_completed and facts.has_chunks_json:
|
|
return DocumentLifecycleState.CHUNKED
|
|
if facts.has_completed:
|
|
return DocumentLifecycleState.PARSED
|
|
if facts.has_failed:
|
|
return DocumentLifecycleState.FAILED
|
|
return DocumentLifecycleState.UPLOADED
|
|
|
|
|
|
async def _step_backfill_lifecycle(db: aiosqlite.Connection, *, dry_run: bool) -> int:
|
|
facts = await _gather_document_facts(db)
|
|
written = 0
|
|
for f in facts:
|
|
target = _infer_lifecycle(f)
|
|
cursor = await db.execute(
|
|
"SELECT lifecycle_state FROM documents WHERE id = ?",
|
|
(f.document_id,),
|
|
)
|
|
row = await cursor.fetchone()
|
|
if row and row["lifecycle_state"] == target.value:
|
|
continue
|
|
logger.info(
|
|
"lifecycle: doc=%s -> %s",
|
|
f.document_id,
|
|
target.value,
|
|
)
|
|
if not dry_run:
|
|
await db.execute(
|
|
"UPDATE documents SET lifecycle_state = ?, "
|
|
"lifecycle_state_at = datetime('now') WHERE id = ?",
|
|
(target.value, f.document_id),
|
|
)
|
|
written += 1
|
|
if not dry_run:
|
|
await db.commit()
|
|
return written
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Step: materialize_chunks_from_chunks_json
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _stable_chunk_id(document_id: str, sequence: int, text: str) -> str:
|
|
"""Deterministic id so re-running the migration doesn't duplicate rows."""
|
|
seed = f"{document_id}|{sequence}|{text}"
|
|
return uuid.uuid5(uuid.NAMESPACE_OID, seed).hex
|
|
|
|
|
|
def _chunk_dicts_for_doc(
|
|
db: aiosqlite.Connection,
|
|
) -> Callable[[str], asyncio.Future[list[dict]]]:
|
|
"""Return an async-compatible accessor over the latest chunks_json
|
|
for a document. Returns an empty list when no chunks_json is available.
|
|
"""
|
|
|
|
async def _read(document_id: str) -> list[dict]:
|
|
cursor = await db.execute(
|
|
"""SELECT chunks_json FROM analysis_jobs
|
|
WHERE document_id = ? AND chunks_json IS NOT NULL AND chunks_json != ''
|
|
ORDER BY completed_at DESC, created_at DESC
|
|
LIMIT 1""",
|
|
(document_id,),
|
|
)
|
|
row = await cursor.fetchone()
|
|
if row is None:
|
|
return []
|
|
try:
|
|
return list(json.loads(row["chunks_json"]))
|
|
except (TypeError, ValueError):
|
|
logger.warning("Invalid chunks_json for doc %s — skipping", document_id)
|
|
return []
|
|
|
|
return _read
|
|
|
|
|
|
def _chunk_dict_to_chunk(d: dict, *, document_id: str, sequence: int) -> Chunk:
|
|
text = d.get("text", "")
|
|
return Chunk(
|
|
id=_stable_chunk_id(document_id, sequence, text),
|
|
document_id=document_id,
|
|
sequence=sequence,
|
|
text=text,
|
|
headings=list(d.get("headings", [])),
|
|
source_page=d.get("sourcePage"),
|
|
bboxes=[
|
|
ChunkBbox(page=b.get("page", 0), bbox=list(b.get("bbox", [])))
|
|
for b in d.get("bboxes", [])
|
|
],
|
|
doc_items=[
|
|
ChunkDocItem(
|
|
self_ref=di.get("selfRef", ""),
|
|
label=di.get("label", ""),
|
|
)
|
|
for di in d.get("docItems", [])
|
|
],
|
|
token_count=d.get("tokenCount"),
|
|
)
|
|
|
|
|
|
async def _step_materialize_chunks(db: aiosqlite.Connection, *, dry_run: bool) -> int:
|
|
"""For each document with chunks_json, insert chunk rows if absent."""
|
|
chunks_repo = SqliteChunkRepository()
|
|
cursor = await db.execute("SELECT id FROM documents")
|
|
doc_rows = await cursor.fetchall()
|
|
written = 0
|
|
read = _chunk_dicts_for_doc(db)
|
|
for doc_row in doc_rows:
|
|
document_id = doc_row["id"]
|
|
existing = await chunks_repo.find_for_document(document_id, include_deleted=True)
|
|
if existing:
|
|
continue # already materialized
|
|
chunk_dicts = await read(document_id)
|
|
if not chunk_dicts:
|
|
continue
|
|
chunks = [
|
|
_chunk_dict_to_chunk(d, document_id=document_id, sequence=i)
|
|
for i, d in enumerate(chunk_dicts)
|
|
]
|
|
logger.info("chunks: doc=%s materializing %d", document_id, len(chunks))
|
|
if not dry_run:
|
|
await chunks_repo.insert_many(chunks)
|
|
written += len(chunks)
|
|
return written
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Step: backfill_default_store_links
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _chunks_to_results(chunks: Iterable[Chunk]) -> list[ChunkResult]:
|
|
return [
|
|
ChunkResult(
|
|
text=c.text,
|
|
headings=list(c.headings),
|
|
source_page=c.source_page,
|
|
bboxes=[ChunkBbox(page=b.page, bbox=list(b.bbox)) for b in c.bboxes],
|
|
doc_items=[ChunkDocItem(self_ref=d.self_ref, label=d.label) for d in c.doc_items],
|
|
)
|
|
for c in chunks
|
|
]
|
|
|
|
|
|
async def _step_backfill_links(db: aiosqlite.Connection, *, dry_run: bool) -> int:
|
|
"""For each doc that already has chunks materialized, create a
|
|
`default`-store link in `Ingested` state with a freshly-computed
|
|
chunkset hash. This treats "chunks exist" as a proxy for "the
|
|
document has been ingested into the default store" — which is true
|
|
for tenants with the legacy single-index deployment that 0.6.0
|
|
formalises into the `default` store.
|
|
|
|
This step does NOT call OpenSearch. Operators with non-trivial
|
|
OpenSearch state should run a separate reindex / verification
|
|
after this script (see runbook).
|
|
"""
|
|
store_repo = SqliteStoreRepository()
|
|
chunks_repo = SqliteChunkRepository()
|
|
link_repo = SqliteDocumentStoreLinkRepository()
|
|
|
|
default_store = await store_repo.get_default()
|
|
if default_store is None:
|
|
logger.warning(
|
|
"No default store — skipping link backfill (run init_db first)",
|
|
)
|
|
return 0
|
|
|
|
cursor = await db.execute("SELECT id FROM documents")
|
|
doc_rows = await cursor.fetchall()
|
|
written = 0
|
|
for doc_row in doc_rows:
|
|
document_id = doc_row["id"]
|
|
existing = await link_repo.find_one(document_id, default_store.id)
|
|
if existing is not None:
|
|
continue
|
|
chunks = await chunks_repo.find_for_document(document_id)
|
|
if not chunks:
|
|
continue
|
|
h = chunkset_hash(_chunks_to_results(chunks))
|
|
link = DocumentStoreLink(
|
|
document_id=document_id,
|
|
store_id=default_store.id,
|
|
state=DocumentStoreLinkState.INGESTED,
|
|
chunkset_hash=h,
|
|
)
|
|
logger.info(
|
|
"links: doc=%s -> store=%s ingested (hash=%s…)",
|
|
document_id,
|
|
default_store.id,
|
|
h[:8],
|
|
)
|
|
if not dry_run:
|
|
await link_repo.upsert(link)
|
|
written += 1
|
|
return written
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Step: reaggregate_document_lifecycle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def _step_reaggregate(db: aiosqlite.Connection, *, dry_run: bool) -> int:
|
|
"""Recompute the doc lifecycle state from per-store links + the
|
|
fallback inferred in step 1."""
|
|
link_repo = SqliteDocumentStoreLinkRepository()
|
|
cursor = await db.execute("SELECT id, lifecycle_state FROM documents")
|
|
rows = await cursor.fetchall()
|
|
written = 0
|
|
for row in rows:
|
|
doc_id = row["id"]
|
|
current = DocumentLifecycleState(row["lifecycle_state"])
|
|
links = await link_repo.find_for_document(doc_id)
|
|
target = aggregate_lifecycle(links, fallback=current)
|
|
if target == current:
|
|
continue
|
|
logger.info(
|
|
"reaggregate: doc=%s %s -> %s",
|
|
doc_id,
|
|
current.value,
|
|
target.value,
|
|
)
|
|
if not dry_run:
|
|
await db.execute(
|
|
"UPDATE documents SET lifecycle_state = ?, "
|
|
"lifecycle_state_at = datetime('now') WHERE id = ?",
|
|
(target.value, doc_id),
|
|
)
|
|
written += 1
|
|
if not dry_run:
|
|
await db.commit()
|
|
return written
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Orchestration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
_STEPS = [
|
|
("backfill_lifecycle", _step_backfill_lifecycle),
|
|
("materialize_chunks", _step_materialize_chunks),
|
|
("backfill_links", _step_backfill_links),
|
|
("reaggregate", _step_reaggregate),
|
|
]
|
|
|
|
|
|
@dataclass
|
|
class StepResult:
|
|
name: str
|
|
skipped: bool
|
|
written: int
|
|
|
|
|
|
async def run(
|
|
*,
|
|
dry_run: bool = False,
|
|
only_step: str | None = None,
|
|
) -> list[StepResult]:
|
|
"""Run the migration. Returns one StepResult per step (skipped or not)."""
|
|
await init_db() # ensures the schema and migration_progress table exist
|
|
results: list[StepResult] = []
|
|
async with get_connection() as db:
|
|
for name, step in _STEPS:
|
|
if only_step and only_step != name:
|
|
continue
|
|
if await _is_done(db, name):
|
|
results.append(StepResult(name=name, skipped=True, written=0))
|
|
logger.info("step %s: already done — skipping", name)
|
|
continue
|
|
logger.info("step %s: running (dry_run=%s)", name, dry_run)
|
|
written = await step(db, dry_run=dry_run)
|
|
if not dry_run:
|
|
await _mark_done(db, name)
|
|
results.append(StepResult(name=name, skipped=False, written=written))
|
|
return results
|
|
|
|
|
|
def _print_summary(results: list[StepResult], *, dry_run: bool) -> None:
|
|
print()
|
|
print(f"{'step':35s} {'wrote':>10s} {'skipped':>10s}")
|
|
print("-" * 60)
|
|
total_written = 0
|
|
for r in results:
|
|
print(f"{r.name:35s} {r.written:>10d} {'yes' if r.skipped else 'no':>10s}")
|
|
total_written += r.written
|
|
print("-" * 60)
|
|
print(f"{'total':35s} {total_written:>10d}")
|
|
print()
|
|
print(f"dry_run={dry_run}")
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
)
|
|
parser = argparse.ArgumentParser(prog="migrate_06")
|
|
parser.add_argument(
|
|
"--dry-run",
|
|
action="store_true",
|
|
help="Print the plan without writing.",
|
|
)
|
|
parser.add_argument(
|
|
"--only-step",
|
|
choices=[name for name, _ in _STEPS],
|
|
help="Run a single named step (useful when re-doing one phase).",
|
|
)
|
|
args = parser.parse_args(argv)
|
|
|
|
results = asyncio.run(run(dry_run=args.dry_run, only_step=args.only_step))
|
|
_print_summary(results, dry_run=args.dry_run)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|