quranic-universal-audio / docs /reference /data-migrations.md
hetchyy's picture
deploy: inspector prod @ 349d570
4cc98c7 verified
|
Raw
History Blame Contribute Delete
19 kB
# Data migrations & one-shot scripts
Playbook for one-shot migrations / backfills against bucket data (`reciters/<slug>/` per-reciter artefacts, and the SQLite substrate). Completed migrations live in `scripts/migrations/` (frozen); re-runnable backfills in `scripts/backfills/`. Shared shape: **dry-run by default**, write to a parallel `archive/<migration>/<slug>/…` (or `.bak`) path before mutating, drift- or parity-check, then atomically promote. Per-reciter loops process independently β€” a mid-loop failure leaves earlier slugs done, later ones untouched.
> The historical per-reciter scan-count tables and bug-forensics that used to live here are dropped β€” they're frozen in commit history. This doc keeps the durable bits an agent needs to *re-run* a migration: detection predicate, apply CLI, archive/rollback path, idempotency, and what each does NOT touch.
## Script catalogue
| Script | Kind | Target | Idempotent | Notes |
|---|---|---|---|---|
| `migrate_json_to_sqlite.py` | substrate | 7 bucket JSON stores + audit → `db/inspector.db` | yes (`--force` to overwrite) | The JSON→SQLite cutover. See [database.md](database.md). |
| `migrate_pending_orphans.py` | substrate | legacy pending-request orphans β†’ `requests/completed.json` | yes | Archives orphans whose state moved past `awaiting_alignment`. |
| `migrate_to_reciters_prefix.py` | bucket layout | `wip/<slug>/` + `published/<slug>/` β†’ `reciters/<slug>/` | yes (skip-existing) | One-shot prefix unification. Copy β†’ verify β†’ `--delete-old`. |
| `purge_stale_wraps.py` | detailed.json | strip stale `wrap_word_ranges` | yes | Migration #1. |
| `backfill_qalqala_letter.py` | detailed.json | persist `qalqala_letter` per seg | yes (drift-gated) | Migration #2. See [validation.md](validation.md). |
| `backfill_boundary_adj.py` | detailed.json | persist `is_boundary_adj` per seg | yes (drift-gated) | Migration #2. Needs `quranic_phonemizer` (offline-only). |
| `purge_pad_migration.py` | edit_history.jsonl | strip `batch_type=="pad_migration"` batches | yes (noop on clean) | Migration #3. |
| `convert_peaks_v2_to_v3.py` | peaks/ | plain `<ch>.json` v2 β†’ slim `<ch>.json.gz` v3 | yes | Migration #5 wire shape. See the inspector-audio skill. |
| `backfill_deleted_basmala.py` | pipeline_meta.json | derive `deleted_basmala_chapters` sidecar | yes (byte-equal) | Migration #5. |
| `derive_pipeline_meta.py` | pipeline_meta.json | derive sidecar for a local slug dir | yes (modulo `generated_at`) | New-pipeline + legacy fallback paths. |
| `backfill_pipeline_peaks.py` | edit_history_peaks.jsonl | resync per-op pipeline peaks with history | yes | Migration #5 catch-up tool. |
| `backfill_peaks_slim.py` / `rollback_peaks_slim.py` | peaks/ | legacy `.json` β†’ slim `.json.gz` (+ reverse) | yes | See the inspector-audio skill. |
| `backfill_deleted_basmala.py` is paired with extraction's native write | β€” | β€” | β€” | New reciters land pre-stamped; backfill is legacy catch-up only. |
| `unignore_category.py` | edit_history.jsonl | bulk-revert `ignore_issue` ops for a category | yes (skips reverted) | Data fix, not a migration β€” drives `services.segments.undo.undo_ops`. |
| `audit_bucket_reciter.py` | (read-only) | per-reciter integrity audit | n/a | Run before publishing a reciter. |
| `qua_jobs/reshape_timestamps_shards.py` | timestamps/ | v2 occurrence-list shard β†’ temporal segment-array shard | yes (`target` shards skipped) | Migration #6. Lives in `qua_jobs/`. |
Tooling (not migrations): `download_bucket_reciter.py`, `upload_bucket_reciter.py` (download β†’ migrate β†’ re-upload workflow), `bench_storage.py`, `regen_fe_types.py` (FE codegen β€” see CLAUDE.md schema convention).
## Bucket layout: `wip/` + `published/` β†’ `reciters/` (`migrate_to_reciters_prefix.py`)
One-shot unification of the two state-driven per-reciter prefixes into a single `reciters/<slug>/`. Content location is now lifecycle-independent: a publish (or any) transition is a pure DB write and never moves files. The old `wip/` and `published/` slug sets were disjoint, so this is a conflict-free union β€” no per-slug merge.
**Apply** β€” server-side Xet-hash `backend.copy()` of every file under `wip/<slug>/…` and `published/<slug>/…` to `reciters/<slug>/…`. Idempotent (skips a file whose destination exists), so re-run to catch a delta before deploy. `--bucket {dev,prod}` (prod requires `--allow-prod`), `--verify` (assert every source file landed under `reciters/`), `--dry-run`, `--delete-old` (remove the legacy trees β€” run only after the deploy is confirmed reading `reciters/`).
**Sequence** β€” copy (dev) β†’ deploy β†’ verify β†’ copy (prod) β†’ deploy β†’ verify β†’ `--delete-old`. The old trees are the rollback until the final delete. `--delete-old` is two-key (`--confirm-delete`); without it, it only previews.
**⚠ Prod `--delete-old` needs the aligner Space redeployed first.** The `quranic-universal-aligner` HF Space reads released reciters' `segments.json` + `timestamps/` from the **prod** inspector bucket via a read-only mount. It has been migrated (quranic-universal-aligner repo: `src/preload/{repo_loader,manifest_client}.py`) to read content from `reciters/<slug>/` and derive its released set from `db/inspector.db` (`delivery_states.state='released' AND visibility='public'`, ∩ segments present) instead of enumerating the `published/` folder β€” so it no longer depends on `published/`, and won't leak WIP reciters (folder presence alone would). **Redeploy the aligner (`upload-aligner`) + verify, THEN** prod `--delete-old` is safe. Dev cleanup was always unaffected (the aligner mounts prod).
**Does NOT** β€” touch `db/inspector.db` (state rows unchanged β€” only content location moves); change any lifecycle state; rewrite the audit/`transitions` stream.
**Status (2026-05-31)** β€” COMPLETE on **prod**. Legacy `wip/` (18 slugs) + `published/` (6 slugs) trees deleted from the prod bucket after verifying `reciters/` held an equal-or-newer superset of every file. The legacy pre-SQLite JSON stores (`access/ activity/ audit/ requests/ state/`) were removed in the same pass (`catalog/` kept as the read-only backup). `wip` was dropped from `auto_detect._SCAN_PREFIXES` β€” it now scans `reciters/` only.
## Substrate migrations
### JSON β†’ SQLite (`migrate_json_to_sqlite.py`)
The cutover. Reads the legacy stores via the configured backend, decomposes into SQLite tables, runs a **semantic** parity readback (list order normalized, not byte), optionally uploads to `db/inspector.db`. Refuses to overwrite an existing bucket DB without `--force`. Decomposition + parity gate fully documented in [database.md](database.md). `python scripts/migrations/migrate_json_to_sqlite.py --bucket dev [--force] [--allow-orphans] [--dry-run]`.
### Pending orphans (`migrate_pending_orphans.py`)
Legacy boot-time reconcile *deleted* pending-request orphans (losing proposed_edits + comments + auto_claim). New design archives at each terminal transition; this catches up legacy data by writing orphans into `requests/completed.json` with a synthetic system actor + `reason="migrated from orphan"`. Dry-run default; `--apply` to commit. Idempotent (second run archives zero).
## detailed.json migrations
### #1 Stale `wrap_word_ranges` purge (`purge_stale_wraps.py`)
**Detection** β€” `inspector/utils/repetitions.py::is_wrap_consistent`: a wrap is stale when its word positions fall outside the seg's `matched_ref` range (the split/edit-ref/merge inheritance bug, now closed at the reducer + `adapters/save_payload.py::make_seg` guard). `classify_wrap` returns `None`/`"stale"`/`"corrupted"`.
**Apply** β€” `--apply` writes `archive/stale_wraps/<slug>/detailed.json.<ts>.bak` then overwrites. Flags `--slug`, `--show-details`.
**Does NOT** β€” touch `edit_history.jsonl`; invalidate caches (restart / reciter-switch to see); re-segment the corrupted-geometry segs (wrap stripped, re-segment manually if needed).
### #2 `qalqala_letter` + `is_boundary_adj` backfill (`backfill_qalqala_letter.py`, `backfill_boundary_adj.py`)
Persist two classifier fields per seg so the runtime validate loop is a dict-lookup. **One source-of-truth helper each** (`services/segments/qalqala.py::compute_qalqala_letter`, `services/validation/classifier.py::compute_is_boundary_adj`) shared by save / extraction / backfill. Field semantics + write-vs-read ownership in [validation.md](validation.md).
**Apply** β€” `--slug <s> --dry-run` / `--all`. Each slug **drift-gated** independently: stamp β†’ run validate against persisted fields β†’ compare to `bench/ground_truth/<slug>.json` β†’ promote only on byte-equal; else abort. Writes `archive/backfill/<slug>/detailed.json` before promotion.
**Re-run after** β€” restoring an old `archive/` snapshot, a `quranic_phonemizer` version bump (re-stamps phonemic side of `is_boundary_adj`), or a manual catalog import that bypasses extraction/save. Legacy segs missing the field fall through to live compute (identical answer, slower).
**Does NOT** β€” touch `edit_history.jsonl`; cross-process cache invalidate; delete `quranic_phonemizer` (still imported by the backfill + extraction).
## edit_history migrations
### #3 `pad_migration` batch purge (`purge_pad_migration.py`)
**Detection** β€” `record.get("batch_type") == "pad_migration"`. Whole batch is the unit; ops never inspected. NOT purged: `null` (user edits), `"strip_specials"`.
**Apply** β€” backup β†’ `archive/pad_migration/<slug>/edit_history.jsonl.<ts>.bak`, purged batches β†’ `archive/pad_migration/<slug>/edit_history.jsonl`, kept batches β†’ live file (atomic in that order). Flags `--apply`, `--slug`. Idempotent (zero pad batches β‡’ `noop`).
**Does NOT** β€” delete data (archived + recoverable); touch `detailed.json`/`segments.json`; edit the state `transitions`/audit stream; bump `HISTORY_SCHEMA_VERSION`.
### Edit-history schema slim-down (code change, no script)
Writer-side only: new batches drop op timestamps (`started/applied/ready_at_utc`), `validation_summary_*`, top-level `reciter`, and snapshot `matched_text` (derived server-side via `dk_text_for_ref`). Legacy records keep their shape forever; readers tolerate both via pydantic `extra="allow"` + `seg.get("matched_text") or dk_text_for_ref(...)` fall-through. No backfill exists β€” if storage cleanup of legacy records is ever needed, mirror #3's archive-then-overwrite shape.
### `unignore_category.py` (data fix)
Bulk-reverse `ignore_issue` ops for one `--category` on a reciter by driving `services.segments.undo.undo_ops` (real undo path: audit trail + cache evict + schema-correct `revert` records). Selects segs currently ignored for the category whose origin `ignore_issue` op isn't already reverted. Dry-run default; `--apply` to mutate.
## Migration #5 β€” peaks slim + pipeline_meta + snapshot stamping
A pipeline-era cluster of changes; the post-#5 invariants are: every history snapshot carries a `chapter` + `audio_url` stamp, and every peaks record on disk uses the slim `peaks_b64` + `bps` shape.
- **`convert_peaks_v2_to_v3.py`** β€” walks a dir of plain `peaks/<ch>.json` (v2: `schema_version:2`, nested float `peaks`) β†’ emits slim `<ch>.json.gz` siblings (mirrors `services/audio/peaks_slim.py::pack_slim`). Deletes the plain files unless `--keep-legacy`. Runs in a download β†’ migrate β†’ re-upload flow; orphaned plain `.json` on the bucket need a separate delete pass. Peaks shape detail lives in the **inspector-audio skill**.
- **`derive_pipeline_meta.py`** β€” derives `<dir>/pipeline_meta.json` for a local slug dir. New path: walk `strip_specials` snapshots, collect Basmala-class chapters from the `chapter` stamp. Legacy fallback: pair each Basmala-class op with the next `batch.chapters[]` entry (valid because strip ops emit in chapter+time order). Idempotent modulo `generated_at`.
- **`backfill_deleted_basmala.py`** β€” same derivation via `qua_shared/pipeline_meta.py::collect_deleted_basmalas` (the helper extraction calls β€” byte-identical), writing the `deleted_basmala_chapters` sidecar field for legacy reciters. The post-#5 inspector reads this sidecar instead of re-deriving from `edit_history.jsonl` on every cold validate. `--slug` / `--all --dry-run`. Deterministic β€” re-run as a drift check.
- **`backfill_pipeline_peaks.py`** β€” catch-up when `edit_history_peaks.jsonl` drifts from `edit_history.jsonl` (partial migration / manual mutation / pre-#5 reciter). Assumes the #5 invariants β€” if unmet, fix source data (`.local/extraction/scripts/migrate_wip5_in_place.py`) or re-extract rather than adding fallbacks here.
## Migration #6 β€” timestamps occurrence-list β†’ segment-array reshape (`qua_jobs/reshape_timestamps_shards.py`)
Reshape each bucket per-chapter timestamps shard from the historical **v2 occurrence-list**
(`{_meta, "<verse>": [occurrence,...]}`, verse-keyed) into the **temporal segment-array** shape
(`{_meta, segments: [{ref, t, words}, ...]}`, recitation order, single-verse refs). This is a
**reshape, not a regeneration** β€” no MFA re-run; occurrences are flattened by `time_start`. It lets
the read path serve the bucket gz as a byte pass-through and gives external consumers a clean
artifact. Full contract: [timestamps-job.md Β§1, Β§6](timestamps-job.md);
[`docs/planning/ts-segment-array-migration.md`](../planning/ts-segment-array-migration.md).
**Safe-because.** A prod-bucket audit (`.local/ts_migration_audit/`) verified all 10 released
reciters are reshape-safe (clean v2, 0 compound cross-verse, 0 skips/orphans, every verse coverable
in one contiguous occasion). The transform delegates to the offline writer's `build_segment_shards`
(`qua_shared/timestamps_shards.py`), so a reshaped shard is **byte-shape identical** to a freshly
generated one β€” the single segment-array builder.
- **Pure transform** β€” `qua_shared/timestamps_reshape.py`: `classify_shard` (`target`/`v2`/`v1`/
`empty` β€” `target` already segment-array, detected by the top-level `segments` key) +
`reshape_shard` (raises on compound cross-verse or non-single-chapter input). No Flask, no bucket
I/O. Tests: `qua_shared/tests/test_timestamps_reshape.py`.
- **CLI** β€” `qua_jobs/reshape_timestamps_shards.py`. `--src-dir` of LOCAL `<chapter>.json.gz`
shards (a synced mirror or the audit cache `.local/ts_migration_audit/raw/reciters/<slug>/
timestamps`); dry-run reports per-shard shape + segment-count + byte delta; optional `--out-dir`
writes reshaped `.gz` LOCALLY for inspection; `target` and `v1`/`empty` shards are skipped
(idempotent). It also reports a **delete-list** of stale shadowed `<chapter>.json` (the
`mishary`/`minshawi`/`qatami` uncompressed shards next to a live `.gz`).
- **Does NOT** β€” read or write the bucket. The actual bucket reshape/write + stale-`.json` delete is
a deferred, coordinated cutover (dev bucket β†’ verify end-to-end β†’ prod; Space restart to clear the
shard LRU), driven directly against the bucket (`/hf-buckets`), not by this CLI.
## Source of truth for byte shapes
All these scripts read/write through the shared pydantic schemas in `qua_shared/schemas/` (and `qua_shared/pipeline_meta.py` for the sidecar) β€” never dict literals. Extraction and the inspector save flow stamp the same fields natively, so new reciters land pre-migrated and only legacy / archive-restored data needs a backfill pass.
`qua_shared/schemas/` is split into three subpackages by where the shape lives: `bucket/` (external bucket-resident JSON artefacts β€” `segment`, `edit_history`, `peaks_history`, `catalog`, `pipeline_meta`, `ts_validation`, `ts_job_record`, `playlist_map`, `ts_shard`, `segments_doc`), `wire/` (HTTP request/response β€” `admin_*`, `intake_requests`, `mark_ready`, `release`, `seg`, `timestamps`, `public`, `audio`, plus `_envelopes` `ErrorEnvelope`/`OkAck`), and `config/` (state+db blobs β€” `state`, `access`, `audit`, `capabilities`, `automation`, `activity_state`, `pending_requests`). The root keeps the full barrel (`__init__.py`), the slim FE codegen surface (`fe_types.py`), `_extras.py` (`strip_and_warn`, surviving only for the `ts_shard` `_meta` forward-compat exception), and `smoke.py`. `extra` policy: bucket artefacts are pure `extra="forbid"` (an unknown/legacy field raises `ValidationError`, never silently stripped); wire request/response + config use `extra="forbid"`; `extra="allow"` only where a forward-compat fixture proves an unknown field rides through unchanged β€” the only survivors are `ts_validation`, `playlist_map`, and `ts_shard`'s `_meta` (the last still via `strip_and_warn`). Codegen is unchanged in mechanism β€” `scripts/codegen/regen_fe_types.py` over `fe_types.py` β†’ `inspector/frontend/src/lib/types/generated/schemas.ts`, gated by CI `schema-codegen-check`.
### Route serialization through wire models
The `seg`/`timestamps`/`public`/`audio` routes serialize **through** the `wire/` models β€” `Model.model_validate(...)` on the way in, `model.model_dump(mode="json")` out, with `ErrorEnvelope`/`OkAck` for errors and acks instead of inline dicts. This is byte-identical to the prior hand-built JSON (proven by `inspector/tests/routes/test_response_snapshots.py` + the wire match-route tests), so it is not a data migration β€” no on-disk bytes move. The FE consumes the codegen'd `schemas.ts` directly; the genuinely FE-only view-models, peaks transport, ts-client projections, and public-bucket display vocabulary live in `lib/types/{view-models,peaks-transport,ts-client,public-bucket}.ts`.
### Bucket-validation harness
`inspector/services/storage/bucket_audit.py` is the Flask-free engine that walks one `reciters/<slug>/` folder and parses every artefact through the canonical schemas, surfacing any `ValidationError` an unknown/legacy field raises under the pure-`forbid` models (post-migration this should be silent for all reciters). It backs three callers: the per-reciter CLI `scripts/bucket/audit_bucket_reciter.py`, the whole-bucket gate `scripts/diagnostics/validate_bucket.py` (per-reciter artefacts + DB-catalog rebuild + audio-manifest sidecars; exits non-zero on any hard error, `--strict` also fails on unknown fields), and `sample_validation` wired into `/healthz?deep=1`. The nightly `.github/workflows/bucket-validate.yml` runs the whole-bucket validator so external-file drift is caught in CI rather than mid-request.
### Dead-set-emptying prod migration (done, 2026-06)
The bucket artefact models are now pure `extra="forbid"`: the `strip_and_warn` helper and the per-model dead-field sets (`_OP_DEAD_FIELDS` / `_BATCH_DEAD_FIELDS`, etc.) are gone, so any unknown/legacy field raises `ValidationError` rather than being silently stripped. The one-shot prod-data migration ran in 2026-06 β€” every reciter's on-disk artefacts were rewritten to the canonical shape via round-trip through the schemas, then re-audited to ZERO legacy strips. The only survivors of the old tolerance are the documented forward-compat exceptions `ts_validation`, `playlist_map`, and `ts_shard`'s `_meta`.