Adds technical design docs for the foundation of the doc-centric ingest pivot: - 202 — Document lifecycle state machine - 203 — Per (document, store) ingestion state - 204 — Auto-detect Stale state via chunk content hash - 205 — Audit trail for chunk edits (chunks → first-class entity) - 206 — Migration of existing documents to the new model Status: Accepted on all five. Each doc spells out the domain entities, persistence schema, services orchestration, API contract, alternatives considered, risks, and testing strategy. ADR placeholders called out where load-bearing decisions warrant a follow-up document. Refs #202 #203 #204 #205 #206
13 KiB
Design: Migrate existing documents to the new lifecycle state model
- Issue: #206
- Title on issue: [CHORE] Migrate existing documents to the new lifecycle state model
- Author: Pier-Jean Malandrino
- Date: 2026-04-29
- Status: Accepted
- Target milestone: 0.6.0 — Doc-centric ingest
- Impacted layers: backend: persistence · services · infra (CLI) · frontend (none)
- Audit dimensions likely touched: CI/Build · Tests · Documentation · Security
- ADR spawned?: no
1. Problem
#202, #203, and #205 introduce four new tables / columns: documents.lifecycle_state, stores, document_store_links, chunks, chunk_edits, chunk_pushes. Existing tenants already have documents, analysis jobs, and chunks living in analysis_jobs.chunks_json plus rows in OpenSearch. After deploy, these documents must appear in /docs with sensible state — otherwise the page looks broken (every doc shows Uploaded or unknown).
This issue ships an idempotent migration that backfills:
documents.lifecycle_stateandlifecycle_state_at.- One
storesrow (thedefaultseeded by #203, no-op if already present). - One
document_store_linksrow per document already represented in OpenSearch under the legacy index. chunksrows materialized from existinganalysis_jobs.chunks_json.chunk_pushesrows reconstructed for documents currently indexed.
It also reindexes from docling-studio-chunks to docling-studio-default if needed, so the new store-aware code can read them.
2. Goals
- Idempotent CLI command
python -m document_parser.tools.migrate_06(re-runs safely; second run is a no-op). --dry-runflag prints what would change without writing.- Inference rules below produce a sensible state for every existing document.
- Per-tenant counts logged at the end (parsed=N, chunked=N, ingested=N, failed=N).
- Documented in
docs/runbooks/release-0.6.0-migration.md. - Tested on a snapshot DB representing the three relevant pre-states (no analysis, completed analysis, indexed in OpenSearch).
3. Non-goals
- Re-running ingestion. The migration only touches metadata; it does not re-embed.
- A web UI for migration progress — operator-only, run via CLI.
- Migrating tenants on environments that have already adopted parts of the new schema (e.g. a partial pre-release deploy) — out of scope; an emergency hotfix path is documented separately.
- Moving to a new vector backend — out of scope.
4. Context & constraints
Existing code surface
document-parser/persistence/database.py—_run_migrations()runs schema DDL on app start.document-parser/persistence/document_repo.py,analysis_repo.py— read paths.document-parser/infra/opensearch_store.py— read access to existing OpenSearch index.- New repos from #203 / #205:
store_repo.py,document_store_link_repo.py,chunk_repo.py,chunk_edit_repo.py.
Hexagonal Architecture constraints
- The migration is a CLI script in
document-parser/tools/migrate_06.py. It uses repositories only — never raw SQL outside of repo modules. This keeps the upgrade path testable like any service. - The reindex helper (
docling-studio-chunks → docling-studio-default) lives ininfra/opensearch_store.pyas a method, called by the script.
Hard constraints
- Idempotent: re-running on an already-migrated DB makes zero writes.
- Resumable: a crash mid-run can be restarted; checkpoints stored in a tiny
migration_progresstable (rows:(name, completed_at)). - No assumption about uptime: the script must work on a quiesced DB OR a live one (transactions short, single-row scope).
- Read-only on OpenSearch by default; write happens only when the operator explicitly passes
--reindex-default-store.
Deployment modes
Run once per environment (local, staging, prod). HF Space deployments — same script, run by the deploy step before the new image starts serving traffic.
5. Proposed design
5.1 Domain
No new domain code. The migration is a coordinator over existing repos.
5.2 Persistence
The schema additions ship in #202 / #203 / #205 migrations (already applied at app start). This issue contributes:
CREATE TABLE IF NOT EXISTS migration_progress (
name TEXT PRIMARY KEY,
completed_at TEXT NOT NULL
);
This is the only new table. It records each migration step's completion to enable resumability.
5.3 Infra adapters
OpenSearchStore.copy_legacy_to_default(*, dry_run: bool) -> int —
- If
docling-studio-chunksexists anddocling-studio-defaultdoes not, create the new index with the same mapping. - Reindex via
_reindex_op(OpenSearch reindex API) from legacy → default. - Add a read-only alias
docling-studio-chunks → docling-studio-default. - Returns the number of documents copied.
5.4 Services
document-parser/services/migration_06_service.py — orchestrator with one method per step. Each step is bracketed by:
if not progress.is_done(step_name):
do_step(...)
progress.mark_done(step_name)
Steps:
seed_default_store—INSERT OR IGNORE INTO stores (...) VALUES ('default', ...). (Already in #203's migration; this step is a guard for older deploys that skipped the seed.)backfill_document_lifecycle_state— for each document, infer state:- Has at least one
analysis_jobsrow withstatus=COMPLETEDand a non-nullchunks_json→Chunked(refined below). - Has at least one row indexed in OpenSearch (legacy or default index) →
Ingested. - Has only
analysis_jobsrows withstatus=COMPLETEDand nochunks_json→Parsed. - Has only
analysis_jobsrows withstatus=FAILED→Failed. - Else →
Uploaded.
- Has at least one
materialize_chunks_from_chunks_json— for eachanalysis_jobsrow with non-nullchunks_json, parse and insert rows intochunkstable. Stable id derivation:f"chunk-{document_id}-{sequence:05d}-{sha256(text)[:8]}"so re-runs are deterministic.backfill_links_from_opensearch— for each document, query OpenSearch (default index, then legacy as fallback) for the count of indexed chunks. If non-zero:- Insert
document_store_linksrow (state =Ingested). - Compute the chunkset hash via #204's function over the materialized
chunks. - Set
link.chunkset_hash. - Insert a
chunk_pushesrow with the materialized chunk ids (best-effort: ordered by sequence).
- Insert
reaggregate_document_lifecycle— recompute the doc state by combining the link states (#203's aggregation rule) and updatedocuments.lifecycle_stateaccordingly. This may upgrade or downgrade the value set in step 2 (e.g.Chunked → Ingested).copy_legacy_index(optional, behind--reindex-default-store) — callOpenSearchStore.copy_legacy_to_default().
Each step is wrapped in its own transaction; partial failure leaves earlier steps committed.
5.5 API
None. CLI only.
5.6 Frontend
None.
5.7 Cross-cutting
- Logging: structured at
INFOper step, atERRORper failed item withdoc_id. End summary printed to stdout in a fixed table. - Configuration via CLI flags only (no env vars introduced for this script):
python -m document_parser.tools.migrate_06 \
[--dry-run] \
[--reindex-default-store] \
[--limit N] \
[--only-step <step_name>]
- Documentation:
docs/runbooks/release-0.6.0-migration.mddescribes the operator flow (backup → run dry-run → run real → validate via/api/documents).
6. Alternatives considered
Alternative A — Schema migration applies everything at app boot
- Summary: Embed inference logic in
_run_migrations()so the app starts and self-heals. - Why not: Migration is observable and debuggable as a CLI; baking it into boot time risks slow startup and silent failures. Operators want to run it during a maintenance window with a dry-run first.
Alternative B — Migrate on demand (lazy)
- Summary: Add a "needs migration" check at runtime, materialize chunks for a doc only on first read.
- Why not: Surfaces the partial state in the API (
stores: []for unmigrated docs even if they are indexed). The library page (#211) becomes a half-true representation of reality. Eager migration is simpler.
Alternative C — Run only the schema; let users re-ingest manually
- Summary: Skip backfill; users re-trigger ingestion from the UI as needed.
- Why not: A tenant with 10k docs cannot click 10k re-ingest buttons. The killer flow promises "your existing corpus already has state".
7. API & data contract
No API changes.
Persistence schema
See §5.2 (one new table: migration_progress).
CLI
python -m document_parser.tools.migrate_06 [flags]
All flags are documented in --help.
Breaking changes
None.
8. Risks & mitigations
| Risk | Audit dimension | Likelihood | Impact | How we notice | Mitigation / rollback |
|---|---|---|---|---|---|
| Migration crashes mid-run | CI/Build | Medium | Medium | Error logs | Resumable via migration_progress; re-run picks up where it left off |
| Wrong inference for a doc edge case (e.g. multiple stores already) | DDD | Low | Medium | Operator validation (sample 10 docs by hand) | Dry-run lists changes; operator can --only-step to redo a single step |
| OpenSearch reindex copies bad data | Decoupling | Low | High | Diff in document counts | Reindex is opt-in (--reindex-default-store); operator runs it deliberately; alias keeps legacy reads working |
| Hash mismatch after migration (chunks materialized differ from what was indexed) | DDD | Medium | Medium | Newly-migrated link shows Stale immediately |
Acceptable behaviour: it correctly tells the user "your indexed chunks may not match the current source"; operator can choose to re-ingest or leave as-is |
| Missing analysis-job rows for a doc | Tests | Low | Low | Doc shows Uploaded despite being indexed |
Inference falls back to OpenSearch presence; if both empty, Uploaded is correct |
9. Testing strategy
Backend — pytest
- Unit (services):
test_migration_inference.py— table-driven: every(analysis_state, has_chunks_json, indexed)tuple → expectedlifecycle_state. - Integration:
test_migration_idempotency.py— run twice → second run zero writes; mid-run abort + restart → final state matches one-shot run. - Persistence:
test_chunks_materialization.py—chunks_json→ rows; ids deterministic (re-running produces same ids).
Snapshot fixture
document-parser/tests/fixtures/db_pre_06.sqlite — handcrafted SQLite DB with three documents (uploaded only, completed analysis, indexed in OpenSearch via fake adapter). Migration runs against it and the resulting state is asserted.
Manual QA
- Snapshot prod DB.
- Run
--dry-runon the snapshot → review the printed plan. - Run for real on the snapshot → validate via
/api/documentsthat every doc has a sensiblelifecycleStateand (where applicable)stores. - Run the same against prod during the maintenance window.
Performance
The script is O(N_docs + N_chunks). For 10k docs / 500k chunks: target < 5 minutes on a single-node SQLite. No parallelism needed in 0.6.0.
10. Rollout & observability
Release branch
release/0.6.0. The migration ships in the same release as #202 / #203 / #204 / #205 — operators run it after deploying the new code and before sending traffic.
Feature flag
None.
Observability
-
Stdout summary table at the end:
step wrote skipped seed_default_store 0 1 backfill_document_lifecycle_state 87 0 materialize_chunks_from_chunks_json 14502 0 backfill_links_from_opensearch 73 14 reaggregate_document_lifecycle 73 0 copy_legacy_index — — total 14735 15 -
Logs: per-step start / finish; per-error row.
Rollback plan
migration_progressrows can be deleted to force re-run a specific step.- The new tables can be truncated or dropped (data is recoverable from
analysis_jobs.chunks_jsonand OpenSearch). - Reverting application code: the new tables stay populated but unused; safe.
11. Open questions
- Should the script open a long SQLite transaction or many small ones? Decision: many small (per-doc), to keep the DB writeable for the live app if the operator chooses to run the script while traffic is on.
- Hash mismatch on freshly-migrated docs (chunks materialized may differ from what was indexed) — should we force-mark them
Stale, or trust the hash compare to do it implicitly? Decision: trust the compare. The result is identical and the path is uniform.
12. References
- Issue: https://github.com/scub-france/Docling-Studio/issues/206
- Related issues: #202 (lifecycle), #203 (per-store), #204 (hash), #205 (audit + chunks table)
- ADRs: none planned
- Project docs:
- Architecture:
docs/architecture.md - Coding standards:
docs/architecture/coding-standards.md - Operations playbooks:
docs/operations/
- Architecture: