docling-studio/document-parser/tools/migrate_06.py
Pier-Jean Malandrino 0c09274836 chore(#206): backfill 0.6.0 doc-centric data model
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
2026-05-05 09:38:39 +02:00

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())