Source code for genvarloader._dataset._migrate

"""In-place, streaming, idempotent migration of a 1.x AoS dataset to 2.0 SoA.

Per track under ``intervals/<track>/`` and ``annot_intervals/<track>/``:
stream ``intervals.npy`` (INTERVAL_DTYPE) in record chunks into three contiguous
``starts/ends/values.npy`` files. Only after every track's SoA is durable do we
bump ``metadata.json`` (last durable write); then delete the AoS files.

Crash-safety by ordering: an interruption before the metadata bump leaves the
dataset still-1.x (old AoS intact, re-runnable); an interruption after the bump
but before deletion leaves both layouts, and a re-run completes the cleanup.
"""

from __future__ import annotations

import json
import os
from collections.abc import Iterator
from pathlib import Path

import numpy as np
from loguru import logger
from pydantic_extra_types.semantic_version import SemanticVersion

from .._ragged import INTERVAL_DTYPE
from ._write import DATASET_FORMAT_VERSION

_CHUNK = 1_000_000  # records per streamed block


def _track_dirs(path: Path) -> Iterator[Path]:
    for base in ("intervals", "annot_intervals"):
        d = path / base
        if d.is_dir():
            for child in sorted(d.iterdir()):
                if child.is_dir():
                    yield child


def _migrate_track(track_dir: Path) -> None:
    """Stream one track's AoS intervals.npy into SoA starts/ends/values.npy.

    No-op if intervals.npy is absent (already migrated or never AoS). Leaves the
    AoS file in place; the caller deletes it only after metadata is bumped.
    """
    aos = track_dir / "intervals.npy"
    if not aos.exists():
        return
    src = np.memmap(aos, dtype=INTERVAL_DTYPE, mode="r")
    n = int(src.shape[0])
    starts = np.memmap(track_dir / "starts.npy", dtype=np.int32, mode="w+", shape=n)
    ends = np.memmap(track_dir / "ends.npy", dtype=np.int32, mode="w+", shape=n)
    values = np.memmap(track_dir / "values.npy", dtype=np.float32, mode="w+", shape=n)
    for i in range(0, n, _CHUNK):
        j = min(i + _CHUNK, n)
        block = src[i:j]
        starts[i:j] = block["start"]
        ends[i:j] = block["end"]
        values[i:j] = block["value"]
    for m in (starts, ends, values):
        m.flush()
    logger.info(f"Migrated {n} intervals in {track_dir} to SoA.")
    del src, starts, ends, values


[docs] def migrate(path: str | Path) -> None: """Migrate a GVL dataset's track intervals from format 1.x (array-of-structs) to format 2.0 (struct-of-arrays), in place. Streaming and crash-safe: peak extra disk is one track's interval store. Genotypes, regions, and reference are untouched. Idempotent — a no-op (with leftover-AoS cleanup) on a dataset that is already 2.0. Args: path: Path to the GVL dataset directory. """ path = Path(path) meta_path = path / "metadata.json" if not meta_path.exists(): raise FileNotFoundError(f"No metadata.json at {meta_path}") raw = json.loads(meta_path.read_text()) fv = raw.get("format_version") already_v2 = ( fv is not None and SemanticVersion.parse(fv).major >= DATASET_FORMAT_VERSION.major ) track_dirs = list(_track_dirs(path)) if already_v2: # Idempotent cleanup: remove leftover AoS from an interrupted delete. for d in track_dirs: aos = d / "intervals.npy" if aos.exists() and (d / "starts.npy").exists(): aos.unlink() return # 1. Convert every track to SoA (AoS left in place). for d in track_dirs: _migrate_track(d) # 2. Durably bump metadata LAST (atomic replace). raw["format_version"] = str(DATASET_FORMAT_VERSION) tmp = meta_path.with_suffix(".json.tmp") tmp.write_text(json.dumps(raw)) with open(tmp, "rb") as f: os.fsync(f.fileno()) os.replace(tmp, meta_path) # 3. Delete AoS files. for d in track_dirs: aos = d / "intervals.npy" if aos.exists(): aos.unlink() logger.info(f"Migrated dataset {path} to format {DATASET_FORMAT_VERSION}.")