Results-layout backfill + flat/dated coexistence¶
Epic #64, issue #73. Closes the main migration sequence.
Coexistence (already guaranteed by #66/#67)¶
Flat (legacy) and dated (new) jobs read correctly side by side with no
migration, because every result read resolves through
resolve_results_prefix(job_id):
- a flat job's row carries
results_prefix = {job_id}/→ readsresults/{job_id}/; - a dated job's row carries
results_prefix = YYYY/MM/DD/{job_id}/→ reads there.
This is proven by the resolver tests (flag-off skip-lookup, flag-on dated/flat rows, lookup-failure degrade) and the backfill tests (flat jobs move, dated jobs skip).
Optional backfill (move old flat jobs into the dated layout)¶
api/services/storage/results_backfill.py backfill_results_layout(dry_run=, limit=):
- Gated on BOTH
STORAGE_DFS_ENABLEDandSTORAGE_DATE_LAYOUT_ENABLED(moving to a dated layout only makes sense when the dated layout is the live target). No-op otherwise. - dry-run by default — returns a
plan(from/toper job) without touching storage. - Atomic move:
dfs_io.rename_directory_dfsmovesresults/{job_id}→results/YYYY/MM/DD/{job_id}in one metadata op (no blob copy), where the date is derived from the job'screated_at.expected_src_leaf=job_idguards it. - Idempotent + self-healing: a job already on the dated layout is skipped; the
row's
results_prefixis stamped (JobStateRepository.update(results_prefix=…)) only after a successful rename, so a partial prior run re-attempts cleanly. - Bounded by
limitso a manual/beat drain proceeds gradually.
Migration runbook¶
- Prereqs (do NOT skip):
STORAGE_DFS_ENABLED=true, blob soft-delete enabled (#76 — recoverability net), split jobs date-aware (#75 — the flag gate), and a live-cluster validation pass. - Flip
STORAGE_DATE_LAYOUT_ENABLED=true— new submissions go dated; old jobs stay flat and keep reading correctly (coexistence). - Dry-run the backfill, review the plan:
backfill_results_layout(dry_run=True, limit=50). - Drain in bounded batches:
backfill_results_layout(dry_run=False, limit=50)repeatedly untilscanned == skipped(every completed job dated). - Cutover (optional, future): once all jobs are dated, the legacy
{job_id}/fallback inresolve_results_prefixcan be removed — tracked separately; not required (the fallback is harmless).
Retention purge + archive live in #76.
Validation evidence¶
uv run pytest api/tests/test_results_backfill.py→ 12 passed (rename guards: moves/true, absent/false, rejects bad src/dest, refuses wrong src leaf; backfill: flags-off no-op, dry-run plans-without-moving, live moves+stamps row, skips already-dated, records error without raising or stamping the row).uv run pytest api/tests/test_state_repo.py→ green (update(results_prefix=…)additive kwarg).uv run ruff check api→ clean.
Self-critique (design pass)¶
- Atomicity: rename then stamp; on rename failure the row is NOT stamped (no dated-row/flat-blobs desync); on stamp-after-rename failure the next run finds the source gone, re-stamps → self-heals. ✓
- Idempotency: already-dated rows skipped; bounded + resumable. ✓
- Security:
expected_src_leaf=job_idguard; no SAS; flag-gated OFF. ✓ - Liveness: bounded by
limit; no unbounded loop. ✓ - Verdict: no Critical/High.